(this.pos < this.path.length) {
const char = this.path[this.pos];
if (char === '.') {
this.pos++;
if (this.path[this.pos] === '.') {
tokens.push(this.parseRecursive());
} else {
tokens.push(this.parseChild());
}
} else if (char === '[') {
tokens.push(this.parseBracket());
} else {
throw new SyntaxError(`Unexpected character '${char}' at index ${this.pos}`);
}
}
return tokens;
}
private parseChild(): Token {
const start = this.pos;
while (this.pos < this.path.length && /[a-zA-Z0-9_]/.test(this.path[this.pos])) {
this.pos++;
}
return { type: 'child', value: this.path.slice(start, this.pos) };
}
private parseRecursive(): Token {
this.pos += 2; // Skip '..'
// Check if followed by a key, e.g., ..name
if (this.pos < this.path.length && /[a-zA-Z_]/.test(this.path[this.pos])) {
return { type: 'recursive', value: this.parseChild().value };
}
return { type: 'recursive' };
}
private parseBracket(): Token {
this.pos++; // Skip '['
const start = this.pos;
let depth = 1;
// Find matching bracket
while (depth > 0 && this.pos < this.path.length) {
if (this.path[this.pos] === '[') depth++;
if (this.path[this.pos] === ']') depth--;
this.pos++;
}
const content = this.path.slice(start, this.pos - 1);
if (content.startsWith('?')) {
return this.parseFilter(content.slice(2, -1)); // Remove [?( and )]
}
if (content === '*') {
return { type: 'wildcard' };
}
if (content.includes(':')) {
return this.parseSlice(content);
}
if (content.includes(',')) {
return this.parseUnion(content);
}
// Index
const index = parseInt(content, 10);
if (isNaN(index)) {
throw new SyntaxError(`Invalid index: ${content}`);
}
return { type: 'index', value: index };
}
private parseFilter(expr: string): Token {
// Regex for @.key op value
const match = expr.match(/^@.(\w+)\s*(==|!=|<|<=|>|>=|=~)\s*(.+)$/);
if (!match) {
throw new SyntaxError(Invalid filter expression: ${expr});
}
const [, key, op, rawVal] = match;
let value: string | number | boolean = rawVal.trim();
// Coerce value
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(Number(value)) && value !== '') value = Number(value);
return { type: 'filter', filterKey: key, op: op as ComparisonOp, filterValue: value };
}
private parseSlice(content: string): Token {
const parts = content.split(':').map(p => p.trim());
return { type: 'slice', value: parts };
}
private parseUnion(content: string): Token {
const indices = content.split(',').map(c => parseInt(c.trim(), 10));
return { type: 'union', value: indices };
}
}
#### 3. Query Executor
The executor consumes tokens and traverses the data structure. It handles recursive descent, filtering, and type coercion.
```typescript
export class QueryExecutor {
private config: QueryConfig;
constructor(config: Partial<QueryConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
execute(data: any, tokens: Token[]): any[] {
return this.evaluateTokens(data, tokens, 0, new Set(), 0);
}
private evaluateTokens(
current: any,
tokens: Token[],
tokenIdx: number,
visited: Set<any>,
depth: number
): any[] {
if (depth > this.config.maxDepth) {
throw new Error("Max recursion depth exceeded");
}
if (tokenIdx >= tokens.length) {
return [current];
}
const token = tokens[tokenIdx];
const results: any[] = [];
// Circular reference check
if (current !== null && typeof current === 'object') {
if (visited.has(current)) {
if (!this.config.allowCircular) {
return []; // Stop traversal on circular ref
}
} else {
visited.add(current);
}
}
switch (token.type) {
case 'root':
return this.evaluateTokens(current, tokens, tokenIdx + 1, visited, depth);
case 'child': {
const key = token.value as string;
if (current && typeof current === 'object' && key in current) {
return this.evaluateTokens(current[key], tokens, tokenIdx + 1, visited, depth);
}
return [];
}
case 'recursive': {
const childKey = token.value as string | undefined;
// Evaluate current level
results.push(...this.evaluateTokens(current, tokens, tokenIdx + 1, visited, depth));
// Recurse into children
if (current && typeof current === 'object') {
const keys = Array.isArray(current)
? current.map((_, i) => i)
: Object.keys(current);
for (const k of keys) {
const val = current[k];
if (val && typeof val === 'object') {
// If recursive has a key, only recurse if child matches
if (childKey && k !== childKey) continue;
results.push(...this.evaluateTokens(val, tokens, tokenIdx, new Set(visited), depth + 1));
}
}
}
return results;
}
case 'index': {
let idx = token.value as number;
if (Array.isArray(current)) {
if (idx < 0) idx = current.length + idx;
if (idx >= 0 && idx < current.length) {
return this.evaluateTokens(current[idx], tokens, tokenIdx + 1, visited, depth);
}
}
return [];
}
case 'wildcard': {
if (Array.isArray(current)) {
for (const item of current) {
results.push(...this.evaluateTokens(item, tokens, tokenIdx + 1, visited, depth));
}
} else if (current && typeof current === 'object') {
for (const key of Object.keys(current)) {
results.push(...this.evaluateTokens(current[key], tokens, tokenIdx + 1, visited, depth));
}
}
return results;
}
case 'filter': {
if (Array.isArray(current)) {
for (const item of current) {
if (this.matchFilter(item, token)) {
results.push(...this.evaluateTokens(item, tokens, tokenIdx + 1, visited, depth));
}
}
} else if (current && typeof current === 'object') {
if (this.matchFilter(current, token)) {
results.push(...this.evaluateTokens(current, tokens, tokenIdx + 1, visited, depth));
}
}
return results;
}
case 'slice': {
const parts = token.value as string[];
if (Array.isArray(current)) {
const [startStr, endStr, stepStr] = parts;
const start = startStr ? parseInt(startStr, 10) : 0;
const end = endStr ? parseInt(endStr, 10) : current.length;
const step = stepStr ? parseInt(stepStr, 10) : 1;
const sliced = current.slice(start, end);
for (let i = 0; i < sliced.length; i += step) {
results.push(...this.evaluateTokens(sliced[i], tokens, tokenIdx + 1, visited, depth));
}
}
return results;
}
case 'union': {
const indices = token.value as number[];
if (Array.isArray(current)) {
for (const idx of indices) {
let realIdx = idx;
if (realIdx < 0) realIdx = current.length + realIdx;
if (realIdx >= 0 && realIdx < current.length) {
results.push(...this.evaluateTokens(current[realIdx], tokens, tokenIdx + 1, visited, depth));
}
}
}
return results;
}
}
return results;
}
private matchFilter(item: any, token: Token): boolean {
if (!item || typeof item !== 'object') return false;
const key = token.filterKey as string;
const itemVal = item[key];
const filterVal = token.filterValue;
const op = token.op;
if (itemVal === undefined) return false;
// Type coercion logic
let left = itemVal;
let right = filterVal;
if (!this.config.strictTypes) {
if (typeof left === 'string' && typeof right === 'number') {
const num = Number(left);
if (!isNaN(num)) left = num;
} else if (typeof left === 'number' && typeof right === 'string') {
const num = Number(right);
if (!isNaN(num)) right = num;
}
}
switch (op) {
case '==': return left == right;
case '!=': return left != right;
case '<': return left < right;
case '<=': return left <= right;
case '>': return left > right;
case '>=': return left >= right;
case '=~': return new RegExp(right as string).test(String(left));
default: return false;
}
}
}
4. Usage Example
// Dataset: Financial Portfolio
const portfolio = {
accounts: [
{ id: 'ACC001', type: 'checking', balance: 1500.50, active: true },
{ id: 'ACC002', type: 'savings', balance: 25000.00, active: true },
{ id: 'ACC003', type: 'checking', balance: 450.20, active: false },
{ id: 'ACC004', type: 'investment', balance: 120000.00, active: true },
],
metadata: { owner: 'Jane Doe', lastUpdated: '2023-10-01' }
};
// Query: Get IDs of active accounts with balance > 1000
const path = "$.accounts[?(@.active == true && @.balance > 1000)].id";
// Note: The tokenizer above handles single conditions.
// For production, extend parseFilter to handle &&/|| logic.
// Assuming extended parser for this example:
const tokens = new PathTokenizer("$.accounts[?(@.active == true)].id").tokenize();
const executor = new QueryExecutor();
const results = executor.execute(portfolio, tokens);
console.log(results);
// Output: ['ACC001', 'ACC002']
Pitfall Guide
Building a JSONPath engine involves subtle edge cases that can lead to incorrect results or runtime failures. The following pitfalls are derived from production experience with query engines.
1. Infinite Recursion on Circular References
- Explanation: JSON data may contain circular references (e.g.,
a.b = a). A recursive descent query (..) will loop infinitely, causing a stack overflow.
- Fix: Implement a
visited set to track object references during traversal. If an object is encountered twice, halt traversal for that branch or throw a configurable error. The QueryExecutor above includes this protection.
2. Type Coercion Inconsistencies
- Explanation: Filters like
[?(@.price < 10)] may fail if price is stored as a string "10". Loose comparison might work, but strict comparison fails. Conversely, numeric comparison on strings can yield unexpected results.
- Fix: Define explicit coercion rules. The engine should attempt numeric coercion for comparison operators (
<, >) and string coercion for regex. Provide a strictTypes configuration to disable coercion when data integrity is guaranteed.
3. Negative Index Miscalculation
- Explanation: JSONPath supports negative indices like
[-1] for the last element. A naive implementation might treat -1 as an invalid index or fail to offset from the array length.
- Fix: When processing an index token, check if the value is negative. If so, compute
actualIndex = array.length + index. Validate the result is within bounds [0, length).
4. Slice Step and Omitted Bounds
- Explanation: Slice notation
[start:end:step] allows omitted bounds (e.g., [:5] or [::2]). Incorrect parsing can result in NaN indices or incorrect slice ranges.
- Fix: Use default values for omitted bounds:
start=0, end=length, step=1. Ensure the slice logic handles negative steps correctly if supported.
5. Filter Regex Edge Cases
- Explanation: Parsing filter expressions with regex can fail on complex values, such as strings containing spaces or special characters (e.g.,
[?(@.name == 'John Doe')]).
- Fix: Use a robust regex that captures quoted strings or implement a mini-parser for the filter expression. The tokenizer should handle quoted values by stripping quotes and preserving internal content.
- Explanation: Union syntax
[a,b,c] requires evaluating multiple paths. A naive implementation might re-traverse the tree for each index, leading to O(N*M) complexity.
- Fix: Batch union evaluations. When encountering a union token, iterate the indices and collect results in a single pass over the current array, rather than spawning separate recursive calls for each index.
7. Root Token Handling
- Explanation: Some implementations allow paths starting without
$, or treat $ inconsistently. This breaks standard compliance.
- Fix: Enforce that all paths must start with
$. The tokenizer should throw a SyntaxError if the first character is not $. The root token should always resolve to the input data object.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Micro-frontend / Edge Runtime | Vanilla Engine | Minimal bundle size (<4KB) reduces load time and memory footprint. | Low dev cost, high performance gain. |
| Complex Enterprise App | Library (e.g., jsonpath-plus) | Libraries offer battle-tested edge cases and extensive documentation. | Higher bundle cost, lower maintenance risk. |
| Custom DSL Requirements | Vanilla Engine | Full control allows adding domain-specific operators (e.g., isFuture, ~regex). | Higher initial dev cost, high extensibility. |
| Strict Security Policy | Vanilla Engine | No external dependencies eliminates supply chain attack vectors. | Zero dependency risk. |
Configuration Template
Use this template to configure the engine for production environments. Adjust parameters based on data characteristics and security requirements.
import { QueryExecutor, QueryConfig } from './jsonpath-engine';
// Production configuration for high-volume data processing
const productionConfig: QueryConfig = {
// Limit recursion to prevent DoS on malformed data
maxDepth: 50,
// Enable strict types to avoid implicit coercion bugs
strictTypes: true,
// Disallow circular references to fail fast on data integrity issues
allowCircular: false,
};
const executor = new QueryExecutor(productionConfig);
// Usage
const data = fetchLargeDataset();
const path = "$.records[?(@.status == 'active')].id";
const tokens = new PathTokenizer(path).tokenize();
const results = executor.execute(data, tokens);
Quick Start Guide
- Copy Engine Code: Integrate the
PathTokenizer and QueryExecutor classes into your project. Ensure TypeScript is configured for strict mode.
- Define Your Path: Write a JSONPath string targeting your data. Example:
$.users[?(@.role == 'admin')].email.
- Tokenize and Execute:
const tokens = new PathTokenizer("$.users[?(@.role == 'admin')].email").tokenize();
const results = new QueryExecutor().execute(myData, tokens);
- Handle Results: The executor returns an array of matched values. Process the results as needed. If no matches are found, an empty array is returned.
- Validate with Tests: Run your path against a diverse dataset to verify behavior. Use the provided test patterns to ensure filters and recursive descent work as expected.