import { tokenize } from './lexer.js'; import { Parser, inferPathKey } from './parser.js'; import { SqlSyntaxError, SqlEvaluationError } from './errors.js'; import { evaluate } from './evaluate.js'; /** * @typedef {{kind: 'Star'}} StarSelect * @typedef {{kind: 'Shape', expr: import('./parser.js').ObjectLiteralNode}} ShapeSelect * @typedef {{kind: 'Columns', items: {expr: import('./parser.js').Expr, key: string}[]}} ColumnsSelect * @typedef {StarSelect|ShapeSelect|ColumnsSelect} Select * @typedef {{kind: 'Source', expr: import('./parser.js').Expr, alias: string|null}} SourceFromItem * @typedef {{kind: 'Unnest', expr: import('./parser.js').Expr, alias: string|null}} UnnestFromItem * @typedef {SourceFromItem|UnnestFromItem} FromItem * @typedef {{type: 'INNER'|'LEFT'|'CROSS', item: FromItem, on: import('./parser.js').Expr|null}} JoinClause * @typedef {{first: FromItem, joins: JoinClause[]}} From * @typedef {{expr: import('./parser.js').Expr, dir: 'ASC'|'DESC'}} OrderItem * @typedef {{type: 'Query', select: Select, from: From, where: import('./parser.js').Expr|null, orderBy: OrderItem[]|null, limit: import('./parser.js').Expr|null}} Query */ class QueryParser extends Parser { /** @returns {Query} */ parseQuery() { this.expect('SELECT'); const select = this.parseSelect(); this.expect('FROM'); const from = this.parseFrom(); let where = null; if (this.check('WHERE')) { this.next(); where = this.parseOr(); } let orderBy = null; if (this.check('ORDER')) { this.next(); this.expect('BY'); orderBy = this.parseOrderByList(); } let limit = null; if (this.check('LIMIT')) { this.next(); limit = this.parseOr(); } this.expect('eof'); return { type: 'Query', select, from, where, orderBy, limit }; } /** @returns {OrderItem[]} */ parseOrderByList() { const items = [this.parseOrderByItem()]; while (this.check(',')) { this.next(); items.push(this.parseOrderByItem()); } return items; } /** @returns {OrderItem} */ parseOrderByItem() { const expr = this.parseOr(); let dir = 'ASC'; if (this.check('ASC')) { this.next(); } else if (this.check('DESC')) { this.next(); dir = 'DESC'; } return { expr, dir }; } /** @returns {Select} */ parseSelect() { if (this.check('*')) { this.next(); return { kind: 'Star' }; } if (this.check('{')) { const expr = this.parseObjectLiteral(); return { kind: 'Shape', expr }; } const items = [this.parseSelectItem()]; while (this.check(',')) { this.next(); items.push(this.parseSelectItem()); } return { kind: 'Columns', items }; } /** @returns {{expr: import('./parser.js').Expr, key: string}} */ parseSelectItem() { const startTok = this.peek(); const expr = this.parseOr(); if (this.check('AS')) { this.next(); const key = this.expectIdentLike().value; return { expr, key }; } return { expr, key: inferPathKey(expr, startTok) }; } /** @returns {From} */ parseFrom() { const first = this.parseFromItem(); /** @type {JoinClause[]} */ const joins = []; while (this.isJoinStart()) { joins.push(this.parseJoinClause()); } validateFromAliases(first, joins); return { first, joins }; } /** @returns {boolean} */ isJoinStart() { return this.check('JOIN') || this.check('INNER') || this.check('LEFT') || this.check('CROSS'); } /** @returns {FromItem} */ parseFromItem() { if (this.check('UNNEST')) { this.next(); this.expect('('); const expr = this.parseOr(); this.expect(')'); let alias = null; if (this.check('AS')) { this.next(); alias = this.expectIdentLike().value; } return { kind: 'Unnest', expr, alias }; } const expr = this.parsePostfix(); let alias = null; if (this.check('AS')) { this.next(); alias = this.expectIdentLike().value; } return { kind: 'Source', expr, alias }; } /** @returns {JoinClause} */ parseJoinClause() { const startTok = this.peek(); /** @type {'INNER'|'LEFT'|'CROSS'} */ let type = 'INNER'; if (this.check('INNER')) { this.next(); this.expect('JOIN'); } else if (this.check('LEFT')) { this.next(); this.expect('JOIN'); type = 'LEFT'; } else if (this.check('CROSS')) { this.next(); this.expect('JOIN'); type = 'CROSS'; } else { this.expect('JOIN'); } const item = this.parseFromItem(); let on = null; if (this.check('ON')) { this.next(); on = this.parseOr(); } if (type === 'CROSS' && on) { throw new SqlSyntaxError('CROSS JOIN cannot have an ON condition', startTok); } if (type !== 'CROSS' && item.kind !== 'Unnest' && !on) { throw new SqlSyntaxError('JOIN requires an ON condition (except CROSS JOIN or JOIN UNNEST(...))', startTok); } return { type, item, on }; } } /** * @param {FromItem} first * @param {JoinClause[]} joins */ function validateFromAliases(first, joins) { const hasJoins = joins.length > 0; if (first.kind === 'Unnest' && !first.alias) { throw new SqlSyntaxError('UNNEST(...) in FROM requires AS alias'); } if (hasJoins && !first.alias) { throw new SqlSyntaxError('FROM sources must be aliased when using JOIN'); } for (const join of joins) { if (!join.item.alias) { throw new SqlSyntaxError('Joined sources must be aliased'); } } } /** * Parses a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query string into an AST. * @param {string} source * @returns {Query} */ export function parseQuery(source) { const tokens = tokenize(source); const parser = new QueryParser(tokens); return parser.parseQuery(); } /** * Executes a parsed query against a JSON context object. * @param {Query} ast * @param {Record} context * @param {{functions?: Record any>}} [options] * @returns {any[]} */ export function executeQuery(ast, context, options = {}) { const hasJoins = ast.from.joins.length > 0; const firstArray = resolveFromItemArray(ast.from.first, context, options); /** @type {{ctx: any, pristineRow: any}[]} */ let rows = firstArray.map((row) => ({ ctx: ast.from.first.alias ? bind({}, row, ast.from.first.alias) : row, pristineRow: row, })); for (const join of ast.from.joins) { /** @type {{ctx: any, pristineRow: any}[]} */ const nextRows = []; for (const left of rows) { const scopeCtx = { ...context, ...left.ctx }; const rightArray = resolveFromItemArray(join.item, scopeCtx, options); let matchedAny = false; for (const rightRow of rightArray) { const ctx = bind(left.ctx, rightRow, /** @type {string} */ (join.item.alias)); if (join.on && evaluate(join.on, ctx, options) !== true) continue; nextRows.push({ ctx, pristineRow: undefined }); matchedAny = true; } if (!matchedAny && join.type === 'LEFT') { nextRows.push({ ctx: bind(left.ctx, null, /** @type {string} */ (join.item.alias)), pristineRow: undefined }); } } rows = nextRows; } /** @type {{ctx: any, pristineRow: any}[]} */ let matched = []; for (const r of rows) { if (ast.where && evaluate(ast.where, r.ctx, options) !== true) continue; matched.push(r); } if (ast.orderBy) { const comparator = makeRowComparator(ast.orderBy, options); matched = matched.slice().sort(comparator); } if (ast.limit) { const n = evaluate(ast.limit, context, options); if (n !== null) { if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) { throw new SqlEvaluationError('LIMIT must be a non-negative integer or NULL'); } matched = matched.slice(0, n); } } return matched.map((r) => ast.select.kind === 'Star' && !hasJoins ? r.pristineRow : project(ast.select, r.ctx, options), ); } /** * @param {FromItem} item * @param {Record} scopeCtx * @param {{functions?: Record any>}} options */ function resolveFromItemArray(item, scopeCtx, options) { const value = evaluate(item.expr, scopeCtx, options); if (item.kind === 'Unnest') { if (value === null || value === undefined) return []; if (!Array.isArray(value)) throw new SqlEvaluationError('UNNEST requires an array value'); return value; } if (!Array.isArray(value)) throw new SqlEvaluationError('FROM/JOIN source must reference an array'); return value; } /** * Binds a row into a context: its own fields (if it's a plain object) plus its alias. * @param {Record} baseCtx * @param {any} row * @param {string} alias */ function bind(baseCtx, row, alias) { const flat = isPlainObject(row) ? row : {}; return { ...baseCtx, ...flat, [alias]: row }; } /** @param {any} v */ function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); } /** * @param {OrderItem[]} orderBy * @param {{functions?: Record any>}} options */ function makeRowComparator(orderBy, options) { return (a, b) => { for (const item of orderBy) { const va = evaluate(item.expr, a.ctx, options); const vb = evaluate(item.expr, b.ctx, options); // Nulls always sort last, regardless of ASC/DESC - checked before the // direction flip below, so DESC never pulls nulls back to the front. if (va === null && vb === null) continue; if (va === null) return 1; if (vb === null) return -1; const cmp = compareForOrder(va, vb); if (cmp !== 0) return item.dir === 'DESC' ? -cmp : cmp; } return 0; }; } /** @param {any} a @param {any} b */ function compareForOrder(a, b) { const ta = typeof a; const tb = typeof b; if (ta !== tb || (ta !== 'number' && ta !== 'string' && ta !== 'boolean')) { throw new SqlEvaluationError(`Cannot compare ${orderTypeName(a)} and ${orderTypeName(b)} in ORDER BY`); } if (a < b) return -1; if (a > b) return 1; return 0; } /** @param {any} v */ function orderTypeName(v) { if (v === null) return 'null'; if (Array.isArray(v)) return 'array'; return typeof v; } /** * @param {Select} select * @param {Record} ctx * @param {{functions?: Record any>}} options */ function project(select, ctx, options) { if (select.kind === 'Star') return ctx; if (select.kind === 'Shape') return evaluate(select.expr, ctx, options); /** @type {Record} */ const out = {}; for (const item of select.items) { out[item.key] = evaluate(item.expr, ctx, options); } return out; } /** * Parses (if needed) and executes a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query. * @param {string|Query} source - query text, or an already-parsed AST * @param {Record} context * @param {{functions?: Record any>}} [options] * @returns {any[]} */ export function query(source, context, options) { const ast = typeof source === 'string' ? parseQuery(source) : source; return executeQuery(ast, context, options); }