import { tokenize } from './lexer.js'; import { SqlSyntaxError } from './errors.js'; /** * AST node shapes produced by the parser: * * @typedef {{type: 'Literal', value: string|number|boolean|null}} LiteralNode * @typedef {{type: 'ArrayLiteral', elements: Expr[]}} ArrayLiteralNode * @typedef {{type: 'ObjectLiteral', properties: {key: string, value: Expr}[]}} ObjectLiteralNode * @typedef {{type: 'Identifier', name: string}} IdentifierNode * @typedef {{type: 'Member', object: Expr, property: string}} MemberNode * @typedef {{type: 'Index', object: Expr, index: Expr}} IndexNode * @typedef {{type: 'Unnest', object: Expr}} UnnestNode * @typedef {{type: 'Call', name: string, args: Expr[]}} CallNode * @typedef {{type: 'Negate', expr: Expr}} NegateNode * @typedef {{type: 'Not', expr: Expr}} NotNode * @typedef {{type: 'Logical', op: 'AND'|'OR', left: Expr, right: Expr}} LogicalNode * @typedef {{type: 'Comparison', op: '='|'!='|'<'|'>'|'<='|'>=', left: Expr, right: Expr}} ComparisonNode * @typedef {{type: 'IsNull', expr: Expr, negate: boolean}} IsNullNode * @typedef {{type: 'Like', expr: Expr, pattern: Expr, negate: boolean}} LikeNode * @typedef {{type: 'InList', expr: Expr, items: Expr[], negate: boolean}} InListNode * @typedef {{type: 'InArray', expr: Expr, array: Expr, negate: boolean}} InArrayNode * @typedef {{type: 'Binary', op: '+'|'-'|'*'|'/'|'%'|'||', left: Expr, right: Expr}} BinaryNode * @typedef {LiteralNode|ArrayLiteralNode|ObjectLiteralNode|IdentifierNode|MemberNode|IndexNode|UnnestNode|CallNode|NegateNode|NotNode|LogicalNode|ComparisonNode|IsNullNode|LikeNode|InListNode|InArrayNode|BinaryNode} Expr */ const COMPARISON_OPS = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']); export class Parser { /** @param {import('./lexer.js').Token[]} tokens */ constructor(tokens) { this.tokens = tokens; this.pos = 0; } peek() { return this.tokens[this.pos]; } peekNext() { return this.tokens[this.pos + 1]; } next() { return this.tokens[this.pos++]; } /** @param {string} type */ check(type) { return this.peek().type === type; } /** @param {string} type */ expect(type) { const t = this.peek(); if (t.type !== type) { throw new SqlSyntaxError(`Expected ${type} but found ${describeToken(t)}`, t); } return this.next(); } /** @returns {Expr} */ parseExpression() { const expr = this.parseOr(); this.expect('eof'); return expr; } /** @returns {Expr} */ parseOr() { let node = this.parseAnd(); while (this.check('OR')) { this.next(); const right = this.parseAnd(); node = { type: 'Logical', op: 'OR', left: node, right }; } return node; } /** @returns {Expr} */ parseAnd() { let node = this.parseNot(); while (this.check('AND')) { this.next(); const right = this.parseNot(); node = { type: 'Logical', op: 'AND', left: node, right }; } return node; } /** @returns {Expr} */ parseNot() { if (this.check('NOT')) { this.next(); const expr = this.parseNot(); return { type: 'Not', expr }; } return this.parseComparison(); } /** @returns {Expr} */ parseComparison() { const left = this.parseAdditive(); const t = this.peek(); if (COMPARISON_OPS.has(t.type)) { this.next(); const right = this.parseAdditive(); return { type: 'Comparison', op: t.type === '<>' ? '!=' : t.type, left, right }; } if (t.type === 'IS') { this.next(); let negate = false; if (this.check('NOT')) { negate = true; this.next(); } this.expect('NULL'); return { type: 'IsNull', expr: left, negate }; } if (t.type === 'BETWEEN') { this.next(); return this.finishBetween(left, false); } if (t.type === 'LIKE') { this.next(); const pattern = this.parseAdditive(); return { type: 'Like', expr: left, pattern, negate: false }; } if (t.type === 'IN') { this.next(); return this.parseInRhs(left, false); } if (t.type === 'NOT') { const t2 = this.peekNext(); if (t2.type === 'LIKE') { this.next(); this.next(); const pattern = this.parseAdditive(); return { type: 'Like', expr: left, pattern, negate: true }; } if (t2.type === 'IN') { this.next(); this.next(); return this.parseInRhs(left, true); } if (t2.type === 'BETWEEN') { this.next(); this.next(); return this.finishBetween(left, true); } } return left; } /** * @param {Expr} left * @param {boolean} negate * @returns {Expr} */ finishBetween(left, negate) { const lo = this.parseAdditive(); this.expect('AND'); const hi = this.parseAdditive(); if (!negate) { return { type: 'Logical', op: 'AND', left: { type: 'Comparison', op: '>=', left, right: lo }, right: { type: 'Comparison', op: '<=', left, right: hi }, }; } return { type: 'Logical', op: 'OR', left: { type: 'Comparison', op: '<', left, right: lo }, right: { type: 'Comparison', op: '>', left, right: hi }, }; } /** * @param {Expr} left * @param {boolean} negate * @returns {Expr} */ parseInRhs(left, negate) { if (this.check('(')) { this.next(); const items = [this.parseOr()]; while (this.check(',')) { this.next(); items.push(this.parseOr()); } this.expect(')'); return { type: 'InList', expr: left, items, negate }; } const array = this.parseAdditive(); return { type: 'InArray', expr: left, array, negate }; } /** @returns {Expr} */ parseAdditive() { let node = this.parseMultiplicative(); while (this.check('+') || this.check('-') || this.check('||')) { const op = /** @type {'+'|'-'|'||'} */ (this.next().type); const right = this.parseMultiplicative(); node = { type: 'Binary', op, left: node, right }; } return node; } /** @returns {Expr} */ parseMultiplicative() { let node = this.parseUnary(); while (this.check('*') || this.check('/') || this.check('%')) { const op = /** @type {'*'|'/'|'%'} */ (this.next().type); const right = this.parseUnary(); node = { type: 'Binary', op, left: node, right }; } return node; } /** @returns {Expr} */ parseUnary() { if (this.check('-')) { this.next(); const expr = this.parseUnary(); return { type: 'Negate', expr }; } return this.parsePostfix(); } /** @returns {Expr} */ parsePostfix() { let node = this.parsePrimary(); while (true) { if (this.check('.')) { this.next(); const idTok = this.expectIdentLike(); node = { type: 'Member', object: node, property: idTok.value }; } else if (this.check('[')) { this.next(); if (this.check(']')) { this.next(); node = { type: 'Unnest', object: node }; } else { const index = this.parseOr(); this.expect(']'); node = { type: 'Index', object: node, index }; } } else { break; } } return node; } expectIdentLike() { const t = this.peek(); if (t.type !== 'ident') { throw new SqlSyntaxError(`Expected a property name but found ${describeToken(t)}`, t); } return this.next(); } /** @returns {ObjectLiteralNode} */ parseObjectLiteral() { this.expect('{'); /** @type {{key: string, value: Expr}[]} */ const properties = []; if (!this.check('}')) { properties.push(this.parseObjectProperty()); while (this.check(',')) { this.next(); properties.push(this.parseObjectProperty()); } } this.expect('}'); return { type: 'ObjectLiteral', properties }; } /** @returns {{key: string, value: Expr}} */ parseObjectProperty() { if (this.check('ident') && this.peekNext().type === ':') { const keyTok = this.next(); this.next(); const value = this.parseOr(); return { key: keyTok.value, value }; } const startTok = this.peek(); const value = this.parseOr(); return { key: inferPathKey(value, startTok), value }; } /** @returns {Expr} */ parsePrimary() { const t = this.peek(); switch (t.type) { case 'number': this.next(); return { type: 'Literal', value: t.value }; case 'string': this.next(); return { type: 'Literal', value: t.value }; case 'TRUE': this.next(); return { type: 'Literal', value: true }; case 'FALSE': this.next(); return { type: 'Literal', value: false }; case 'NULL': this.next(); return { type: 'Literal', value: null }; case '(': { this.next(); const expr = this.parseOr(); this.expect(')'); return expr; } case '[': { this.next(); /** @type {Expr[]} */ const elements = []; if (!this.check(']')) { elements.push(this.parseOr()); while (this.check(',')) { this.next(); elements.push(this.parseOr()); } } this.expect(']'); return { type: 'ArrayLiteral', elements }; } case '{': return this.parseObjectLiteral(); case 'ident': { this.next(); if (this.check('(')) { this.next(); /** @type {Expr[]} */ const args = []; if (!this.check(')')) { args.push(this.parseOr()); while (this.check(',')) { this.next(); args.push(this.parseOr()); } } this.expect(')'); return { type: 'Call', name: t.value, args }; } return { type: 'Identifier', name: t.value }; } default: throw new SqlSyntaxError(`Unexpected token ${describeToken(t)}`, t); } } } /** @param {import('./lexer.js').Token} t */ function describeToken(t) { if (t.type === 'eof') return 'end of input'; return JSON.stringify(t.value ?? t.type); } /** * Infers a property/column key from a bare (unaliased) expression: an `Identifier` * uses its own name, a `Member` chain uses its trailing `.property`. Anything else * has no natural name and must be given one explicitly (`key: expr` / `expr AS key`). * @param {Expr} expr * @param {import('./lexer.js').Token} atToken - used for error position * @returns {string} */ export function inferPathKey(expr, atToken) { if (expr.type === 'Identifier') return expr.name; if (expr.type === 'Member') return expr.property; throw new SqlSyntaxError('Cannot infer a name for this expression; give it one explicitly (`key: expr` or `expr AS key`)', atToken); } /** * Parses a SQL-like expression string into an AST. * @param {string} source * @returns {Expr} */ export function parseExpression(source) { const tokens = tokenize(source); const parser = new Parser(tokens); return parser.parseExpression(); }