UNPKG

jev-ql

Version:

PostgreSQL-compatible query language powered by TypeSafe Jev System One models

503 lines (442 loc) • 17.2 kB
import { SCALAR_FUNCTIONS, AGGREGATE_FUNCTIONS } from './functions.js'; import { createQueryPlan } from './planner.js'; import { getFieldCaseInsensitive, getProp } from './utils.js'; export class Executor { constructor(jevClient, dataAdapter, options = {}) { this.jevClient = jevClient; this.dataAdapter = dataAdapter; this.options = options; } evalLiteral(node) { if (!node) return null; if (node.type === 'Literal') return node.value; if (node.type === 'Identifier') return node.name; if (node.type === 'ArrayLiteral') { return node.elements.map(e => this.evalLiteral(e)); } if (node.type === 'ObjectLiteral') { const obj = {}; for (const [k, v] of Object.entries(node.properties)) { obj[k] = this.evalLiteral(v); } return obj; } return null; } resolveAutoText(row) { if (typeof row === 'string') return row; if (!row || typeof row !== 'object') return String(row || ''); const candidateKeys = ['message', 'body', 'text', 'content', 'description', 'review', 'comment', 'input', 'query', 'summary', 'title']; for (const k of candidateKeys) { const val = getFieldCaseInsensitive(row, k); if (typeof val === 'string' && val.trim().length > 0) return val; } for (const v of Object.values(row)) { if (typeof v === 'string' && v.trim().length > 0) return v; } return row; } /** * Evaluate an expression AST node for a given row and Jev answers. */ evalExpr(node, row, answers = {}, groupRows = null) { if (!node) return null; switch (node.type) { case 'Wildcard': return row; case 'Literal': return node.value; case 'ArrayLiteral': return node.elements.map(e => this.evalExpr(e, row, answers, groupRows)); case 'ObjectLiteral': { const res = {}; for (const [k, v] of Object.entries(node.properties)) { res[k] = this.evalExpr(v, row, answers, groupRows); } return res; } case 'Identifier': { if (node.name === 'auto') { return this.resolveAutoText(row); } if (node.table) { const tbl = row[node.table]; if (tbl && typeof tbl === 'object') { return getFieldCaseInsensitive(tbl, node.name); } } return getFieldCaseInsensitive(row, node.name); } case 'BinaryExpression': { const op = node.operator.toUpperCase(); if (op === 'AND') { return Boolean(this.evalExpr(node.left, row, answers, groupRows)) && Boolean(this.evalExpr(node.right, row, answers, groupRows)); } if (op === 'OR') { return Boolean(this.evalExpr(node.left, row, answers, groupRows)) || Boolean(this.evalExpr(node.right, row, answers, groupRows)); } const left = this.evalExpr(node.left, row, answers, groupRows); const right = this.evalExpr(node.right, row, answers, groupRows); 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 left + right; case '-': return left - right; case '*': return left * right; case '/': return right === 0 ? null : left / right; case '%': return left % right; case '||': return String(left ?? '') + String(right ?? ''); case '->': return typeof left === 'object' && left !== null ? left[right] : null; case '->>': return typeof left === 'object' && left !== null ? String(left[right] ?? '') : null; default: throw new Error(`Unknown operator: ${op}`); } } case 'UnaryExpression': { const val = this.evalExpr(node.argument, row, answers, groupRows); if (node.operator === 'NOT') return !val; if (node.operator === '-') return -val; if (node.operator === '+') return +val; return val; } case 'IsNullExpression': { const val = this.evalExpr(node.expr, row, answers, groupRows); const isNull = val === null || val === undefined; return node.not ? !isNull : isNull; } case 'LikeExpression': { const val = String(this.evalExpr(node.expr, row, answers, groupRows) ?? ''); const pat = String(this.evalExpr(node.pattern, row, answers, groupRows) ?? ''); // Convert SQL LIKE pattern to Regex const regexStr = '^' + pat .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/%/g, '.*') .replace(/_/g, '.') + '$'; const regex = new RegExp(regexStr, node.caseInsensitive ? 'i' : ''); const matches = regex.test(val); return node.not ? !matches : matches; } case 'InExpression': { const val = this.evalExpr(node.expr, row, answers, groupRows); const list = node.values.map(v => this.evalExpr(v, row, answers, groupRows)); const inside = list.includes(val); return node.not ? !inside : inside; } case 'BetweenExpression': { const val = this.evalExpr(node.expr, row, answers, groupRows); const lower = this.evalExpr(node.lower, row, answers, groupRows); const upper = this.evalExpr(node.upper, row, answers, groupRows); const between = val >= lower && val <= upper; return node.not ? !between : between; } case 'CaseExpression': { for (const cond of node.conditions) { if (this.evalExpr(cond.when, row, answers, groupRows)) { return this.evalExpr(cond.then, row, answers, groupRows); } } if (node.elseExpr) { return this.evalExpr(node.elseExpr, row, answers, groupRows); } return null; } case 'FunctionCall': { const fnName = node.name.toUpperCase(); // 1. Semantic evaluation from Jev answers if (node._questionKey && answers[node._questionKey]) { const ans = answers[node._questionKey]; if (fnName === 'NOUL' || fnName === 'SEMANTIC') return ans.noul; if (fnName === 'IS_TRUE') { const thresh = node.arguments[2] ? this.evalExpr(node.arguments[2], row, answers, groupRows) : 0.5; return ans.noul >= thresh; } if (fnName === 'IS_FALSE') { const thresh = node.arguments[2] ? this.evalExpr(node.arguments[2], row, answers, groupRows) : 0.5; return ans.noul < thresh; } if (fnName === 'CHOICE') return ans.choice; if (fnName === 'SCORE') return ans.score; if (fnName === 'JEV') return ans; } // 2. Metapredicates on semantic choices: CONFIDENCE(CHOICE(...)) if (fnName === 'CONFIDENCE') { const innerArg = node.arguments[0]; if (innerArg && innerArg._questionKey && answers[innerArg._questionKey]) { return answers[innerArg._questionKey].confidence ?? 1.0; } return 1.0; } // 3. Probability of specific option: PROB(CHOICE(...), 'opt') if (fnName === 'PROB') { const innerArg = node.arguments[0]; const targetOpt = this.evalExpr(node.arguments[1], row, answers, groupRows); if (innerArg && innerArg._questionKey && answers[innerArg._questionKey]) { const probs = answers[innerArg._questionKey].probabilities || {}; return probs[targetOpt] ?? 0.0; } return 0.0; } // 4. Aggregate functions if (AGGREGATE_FUNCTIONS[fnName]) { if (!groupRows) { // Evaluated outside group, evaluate on single row return this.evalExpr(node.arguments[0], row, answers, groupRows); } const isWildcard = node.arguments[0]?.type === 'Wildcard'; const values = isWildcard ? groupRows : groupRows.map(r => this.evalExpr(node.arguments[0], r.row, r.answers, null)); return AGGREGATE_FUNCTIONS[fnName](values, isWildcard); } // 5. Standard scalar functions if (SCALAR_FUNCTIONS[fnName]) { const evaluatedArgs = node.arguments.map(a => this.evalExpr(a, row, answers, groupRows)); return SCALAR_FUNCTIONS[fnName](...evaluatedArgs); } throw new Error(`Unknown function: ${fnName}`); } default: throw new Error(`Unknown expression type: ${node.type}`); } } /** * Execute the parsed SELECT statement AST against data. */ async execute(ast, directData = null) { const startTime = Date.now(); // 1. Compile Query Plan const plan = createQueryPlan(ast, (node) => this.evalLiteral(node)); // 2. Scan & Load Initial Rows let rows = []; if (directData) { rows = Array.isArray(directData) ? directData : [directData]; } else { rows = await this.dataAdapter.loadSource(plan.source, this.options.baseDir); } const totalScanned = rows.length; // 3. Perform Joins if any for (const join of plan.joins) { const joinData = await this.dataAdapter.loadSource(join.target, this.options.baseDir); const joinedRows = []; for (const leftRow of rows) { let matched = false; for (const rightRow of joinData) { const combined = { ...leftRow, ...(join.alias ? { [join.alias]: rightRow } : rightRow) }; if (!join.on || this.evalExpr(join.on, combined)) { matched = true; joinedRows.push(combined); } } if (!matched && (join.joinType === 'LEFT' || join.joinType === 'FULL')) { joinedRows.push({ ...leftRow }); } } rows = joinedRows; } // 4. Relational Pushdown Filter (Drop cheap non-matching rows before Jev AI!) if (plan.pushdownFilter) { rows = rows.filter(row => { const res = this.evalExpr(plan.pushdownFilter, row); return typeof res === 'number' ? res >= 0.5 : Boolean(res); }); } const rowsAfterPushdown = rows.length; // If this is an EXPLAIN query, return the plan without running Jev if (ast.explain && !ast.analyze) { return { plan: { scannedRows: totalScanned, pushdownPrunedRows: totalScanned - rowsAfterPushdown, candidateRowsForJev: rowsAfterPushdown, semanticQuestions: plan.questions.map(q => ({ key: q.key, type: q.question.type, instructions: q.question.instructions, criteria: q.question.criteria })), speculativeFanOut: true, estimatedHttpRequests: rowsAfterPushdown } }; } // 5. Speculative Fan-out & Single-Pass Jev Evaluation const rowAnswersMap = new Map(); if (plan.questions.length > 0 && rows.length > 0) { const batchItems = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowId = i; const rowQuestions = {}; for (const qDesc of plan.questions) { // If stateExpr is specified (e.g. `message` column or `user.bio`), resolve it let state = row; if (qDesc.stateExpr) { state = this.evalExpr(qDesc.stateExpr, row); } if (state === undefined || state === null) { state = this.resolveAutoText(row); } rowQuestions[qDesc.key] = qDesc.question; } // Default state: if only one column was referenced in question stateExpr let stateForCall = row; if (plan.questions[0]?.stateExpr) { stateForCall = this.evalExpr(plan.questions[0].stateExpr, row); } if (stateForCall === undefined || stateForCall === null) { stateForCall = this.resolveAutoText(row); } batchItems.push({ id: rowId, state: stateForCall, questions: rowQuestions }); } const evaluatedResults = await this.jevClient.evaluateBatch(batchItems, this.options); for (const [id, answers] of evaluatedResults.entries()) { rowAnswersMap.set(id, answers); } } // 6. Semantic Filter (Evaluate remaining WHERE conditions with Jev answers) let filteredRows = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const answers = rowAnswersMap.get(i) || {}; if (plan.semanticFilter) { const cond = this.evalExpr(plan.semanticFilter, row, answers); const passed = typeof cond === 'number' ? cond >= 0.5 : Boolean(cond); if (passed) { filteredRows.push({ row, answers, originalIndex: i }); } } else { filteredRows.push({ row, answers, originalIndex: i }); } } // 7. Aggregation & Group By let evaluatedTuples = []; if (plan.isAggregateQuery) { const groups = new Map(); for (const item of filteredRows) { let groupKey = 'all'; if (plan.groupBy && plan.groupBy.length > 0) { const keyVals = plan.groupBy.map(g => String(this.evalExpr(g, item.row, item.answers))); groupKey = keyVals.join(':::'); } if (!groups.has(groupKey)) { groups.set(groupKey, []); } groups.get(groupKey).push(item); } // Compute aggregated rows for (const [groupKey, groupItems] of groups.entries()) { const representative = groupItems[0]; // Evaluate HAVING if present if (plan.having) { const passesHaving = this.evalExpr(plan.having, representative.row, representative.answers, groupItems); if (!passesHaving) continue; } const outRow = {}; for (let colIdx = 0; colIdx < ast.columns.length; colIdx++) { const col = ast.columns[colIdx]; const alias = col.alias || (col.expr.type === 'Identifier' ? col.expr.name : `col_${colIdx}`); outRow[alias] = this.evalExpr(col.expr, representative.row, representative.answers, groupItems); } evaluatedTuples.push({ item: representative, outRow, groupItems }); } } else { // Non-aggregate: direct projection for (const item of filteredRows) { const outRow = {}; for (let colIdx = 0; colIdx < ast.columns.length; colIdx++) { const col = ast.columns[colIdx]; if (col.expr.type === 'Wildcard') { Object.assign(outRow, item.row); } else { const alias = col.alias || (col.expr.type === 'Identifier' ? col.expr.name : `col_${colIdx}`); outRow[alias] = this.evalExpr(col.expr, item.row, item.answers); } } evaluatedTuples.push({ item, outRow, groupItems: null }); } } // 8. ORDER BY (can sort by projected aliases OR original row columns) if (plan.orderBy && plan.orderBy.length > 0) { evaluatedTuples.sort((tA, tB) => { for (const orderItem of plan.orderBy) { let valA, valB; if (orderItem.expr.type === 'Identifier' && orderItem.expr.name in tA.outRow) { valA = tA.outRow[orderItem.expr.name]; valB = tB.outRow[orderItem.expr.name]; } else { valA = this.evalExpr(orderItem.expr, tA.item.row, tA.item.answers, tA.groupItems); valB = this.evalExpr(orderItem.expr, tB.item.row, tB.item.answers, tB.groupItems); } if (valA === valB) continue; if (valA == null) return 1; if (valB == null) return -1; const cmp = valA < valB ? -1 : 1; return orderItem.direction === 'DESC' ? -cmp : cmp; } return 0; }); } // 9. DISTINCT if (plan.distinct) { const seen = new Set(); evaluatedTuples = evaluatedTuples.filter(t => { const str = JSON.stringify(t.outRow); if (seen.has(str)) return false; seen.add(str); return true; }); } // 10. OFFSET and LIMIT if (plan.offset) { const offsetVal = Number(this.evalLiteral(plan.offset)) || 0; evaluatedTuples = evaluatedTuples.slice(offsetVal); } if (plan.limit) { const limitVal = Number(this.evalLiteral(plan.limit)); if (!isNaN(limitVal)) { evaluatedTuples = evaluatedTuples.slice(0, limitVal); } } let projectedRows = evaluatedTuples.map(t => t.outRow); const durationMs = Date.now() - startTime; if (ast.analyze) { return { rows: projectedRows, telemetry: { ...this.jevClient.telemetry, totalDurationMs: durationMs, scannedRows: totalScanned, pushdownPruned: totalScanned - rowsAfterPushdown, evaluatedRows: rowsAfterPushdown, returnedRows: projectedRows.length } }; } return projectedRows; } }