diff --git a/examples/SQLOptimizer-runtime.ts b/examples/SQLOptimizer-runtime.ts index 1835a38..5022612 100644 --- a/examples/SQLOptimizer-runtime.ts +++ b/examples/SQLOptimizer-runtime.ts @@ -66,11 +66,11 @@ function parseSQL(sql: string): SQLParts { } // Extract WHERE - const whereMatch = s.match(/\bWHERE\s+(.+?)(?:\s+GROUP\s+BY\b|\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i); + const whereMatch = s.match(/\bWHERE\s+(.+?)(?:\s+GROUP\s+BY\b|\s+HAVING\b|\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i); const where = whereMatch ? whereMatch[1].trim() : ''; - // Extract GROUP BY - const groupMatch = s.match(/\bGROUP\s+BY\s+(.+?)(?:\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i); + // Extract GROUP BY (stop before HAVING) + const groupMatch = s.match(/\bGROUP\s+BY\s+(.+?)(?:\s+HAVING\b|\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i); const groupBy = groupMatch ? groupMatch[1].trim() : ''; // Extract ORDER BY @@ -207,50 +207,77 @@ export function pushDownFilters(tree: PlanTree): PlanTree { const nodes = { ...tree.nodes }; let root = tree.root; - // Find Filter nodes directly above Join nodes and push predicates to the correct side + // Split AND predicates into individual conditions, push each to the correct side function pushDown(nodeId: string): string { const node = nodes[nodeId]; if (!node) return nodeId; - // Recurse into children + // Recurse into children first node.children = node.children.map(pushDown); if (node.op === 'Filter' && nodes[node.children[0]]?.op === 'Join') { const joinNode = nodes[node.children[0]]; const predicate = node.predicate || ''; - // Check which side the predicate references + // Split AND predicates + const conjuncts = predicate.split(/\s+AND\s+/i).map((s: string) => s.trim()).filter(Boolean); + const leftTable = nodes[joinNode.leftChild!]?.table ?? ''; const rightTable = nodes[joinNode.rightChild!]?.table ?? ''; - const predCols = extractColumnRefs(predicate); - const onlyLeft = predCols.every(c => c.startsWith(leftTable + '.') || !c.includes('.')); - const onlyRight = predCols.every(c => c.startsWith(rightTable + '.') || !c.includes('.')); + const leftPreds: string[] = []; + const rightPreds: string[] = []; + const remainingPreds: string[] = []; - if (onlyLeft && joinNode.leftChild) { - // Push filter to left child + for (const pred of conjuncts) { + const refs = extractColumnRefs(pred); + const onlyLeft = refs.length === 0 || refs.every(c => c.startsWith(leftTable + '.') || !c.includes('.')); + const onlyRight = refs.length === 0 || refs.every(c => c.startsWith(rightTable + '.') || !c.includes('.')); + + if (onlyLeft && !onlyRight) { + leftPreds.push(pred); + } else if (onlyRight && !onlyLeft) { + rightPreds.push(pred); + } else { + remainingPreds.push(pred); + } + } + + let currentRoot = joinNode.id; + + // Push left-side predicates below the join's left child + if (leftPreds.length > 0 && joinNode.leftChild) { const filterId = nextId('filter'); nodes[filterId] = { - id: filterId, op: 'Filter', predicate, + id: filterId, op: 'Filter', predicate: leftPreds.join(' AND '), estimatedRows: Math.ceil(nodes[joinNode.leftChild].estimatedRows * 0.3), children: [joinNode.leftChild] }; joinNode.leftChild = filterId; joinNode.children = [filterId, joinNode.rightChild!]; - joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.7); - return joinNode.id; - } else if (onlyRight && joinNode.rightChild) { - // Push filter to right child + joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.5); + } + + // Push right-side predicates below the join's right child + if (rightPreds.length > 0 && joinNode.rightChild) { const filterId = nextId('filter'); nodes[filterId] = { - id: filterId, op: 'Filter', predicate, + id: filterId, op: 'Filter', predicate: rightPreds.join(' AND '), estimatedRows: Math.ceil(nodes[joinNode.rightChild].estimatedRows * 0.3), children: [joinNode.rightChild] }; joinNode.rightChild = filterId; joinNode.children = [joinNode.leftChild!, filterId]; - joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.7); + joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.3); + } + + // If we pushed everything, replace the Filter with the Join + if (remainingPreds.length === 0) { return joinNode.id; + } else { + // Keep remaining predicates as a Filter above the Join + node.predicate = remainingPreds.join(' AND '); + node.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.3); } } @@ -270,10 +297,16 @@ export function foldConstants(tree: PlanTree): PlanTree { node.children.forEach(fold); if (node.op === 'Filter' && node.predicate) { - node.predicate = node.predicate - .replace(/\b(\d+)\s*\+\s*(\d+)\b/g, (_, a, b) => String(Number(a) + Number(b))) - .replace(/\b(\d+)\s*-\s*(\d+)\b/g, (_, a, b) => String(Number(a) - Number(b))) - .replace(/\b(\d+)\s*\*\s*(\d+)\b/g, (_, a, b) => String(Number(a) * Number(b))); + // Constant-fold arithmetic, but NOT inside string literals or date literals + const parts = node.predicate.split(/(')/); + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) { // outside quotes + parts[i] = parts[i] + .replace(/\b(\d+)\s*\+\s*(\d+)\b/g, (_, a, b) => String(Number(a) + Number(b))) + .replace(/\b(\d+)\s*\*\s*(\d+)\b/g, (_, a, b) => String(Number(a) * Number(b))); + } + } + node.predicate = parts.join(''); } } @@ -345,10 +378,10 @@ export function choosePhysicalOperators(tree: PlanTree, stats: Record 10000 || rightRows > 10000) { node.joinType = 'hash'; - } else if (leftRows < 100 || rightRows < 100) { + } else if (leftRows < 100 && rightRows < 100) { node.joinType = 'nested_loop'; } else { - node.joinType = 'merge'; + node.joinType = 'hash'; } } @@ -427,13 +460,43 @@ export function generateSQL(nodes: Record, rootId: string): st let orderBy = ''; let limit = ''; + // Pass 1: collect which tables appear in JOINs, and identify the leftmost table + // The leftmost table goes in FROM; everything else appears via JOIN + const joinedAsRight = new Set(); // tables on the right side of a JOIN + const leftmostJoinTable = new Set(); // tables on the left side of a JOIN + function collectJoinTables(nid: string): void { + const n = nodes[nid]; + if (!n) return; + n.children.forEach(collectJoinTables); + if (n.op === 'Join') { + if (n.rightChild) { + let cur = n.rightChild; + while (nodes[cur] && !nodes[cur].table && nodes[cur].children.length > 0) cur = nodes[cur].children[0]; + if (nodes[cur]?.table) joinedAsRight.add(nodes[cur].table.replace(/ \/\*.*\*\//, '')); + } + if (n.leftChild) { + let cur = n.leftChild; + while (nodes[cur] && !nodes[cur].table && nodes[cur].children.length > 0) cur = nodes[cur].children[0]; + if (nodes[cur]?.table) leftmostJoinTable.add(nodes[cur].table.replace(/ \/\*.*\*\//, '')); + } + } + } + collectJoinTables(rootId); + + // Pass 2: generate SQL function walk(nid: string): void { const n = nodes[nid]; if (!n) return; switch (n.op) { case 'Scan': - if (n.table) fromTables.push(n.table); + if (n.table) { + const cleanName = n.table.replace(/ \/\*.*\*\//, ''); + // Only add to FROM if not appearing as right side of a JOIN + if (!joinedAsRight.has(cleanName)) { + fromTables.push(cleanName); + } + } break; case 'Filter': if (where) { @@ -444,8 +507,19 @@ export function generateSQL(nodes: Record, rootId: string): st break; case 'Join': { const jt = n.joinType === 'hash' ? 'INNER' : n.joinType === 'nested_loop' ? 'INNER' : (n.joinType?.toUpperCase() ?? 'INNER'); - const rt = nodes[n.rightChild!]?.table ?? 'unknown'; - const lt = nodes[n.leftChild!]?.table ?? 'unknown'; + const resolveTable = (nid: string | undefined): string => { + if (!nid) return 'unknown'; + const nd = nodes[nid]; + if (!nd) return 'unknown'; + if (nd.table) return nd.table.replace(/ \/\*.*\*\//, ''); + for (const cid of nd.children) { + const t = resolveTable(cid); + if (t !== 'unknown') return t; + } + return 'unknown'; + }; + const rt = resolveTable(n.rightChild); + const lt = resolveTable(n.leftChild); const onClause = n.onClause ?? `${rt}.id = ${lt}.id`; joins.push(`${jt} JOIN ${rt} ON ${onClause}`); if (n.leftChild) walk(n.leftChild); @@ -477,7 +551,7 @@ export function generateSQL(nodes: Record, rootId: string): st walk(rootId); - let sql = `SELECT ${select} FROM ${fromTables.join(', ')}`; + let sql = `SELECT ${select} FROM ${[...new Set(fromTables)].join(', ')}`; if (joins.length > 0) sql += ' ' + joins.join(' '); if (where) sql += ` WHERE ${where}`; if (groupBy) sql += ` GROUP BY ${groupBy}`;