fix: SQL optimizer runtime — predicate pushdown, FROM/JOIN dedup, cost model

- Predicate pushdown now splits AND predicates and pushes each to
  the correct side of the join (left table vs right table)
- FROM clause no longer duplicates tables that appear in JOINs
- Constant folding no longer mangles date literals (skips strings)
- Cost model: hash join uses build/probe costs, nested_loop uses
  per-row probe cost instead of cartesian product
- Physical operator selection defaults to hash join for medium+ tables
- Two-pass SQL generation: collect join tables first, then emit FROM
- Demo shows 86% cost reduction on users/orders query, 25% on
  departments/employees query from ekeric13/sql-query-optimization-guide
This commit is contained in:
Scott Scoble 2026-05-13 18:06:30 -07:00
parent a910f07377
commit 35583630cb

View file

@ -66,11 +66,11 @@ function parseSQL(sql: string): SQLParts {
} }
// Extract WHERE // 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() : ''; const where = whereMatch ? whereMatch[1].trim() : '';
// Extract GROUP BY // Extract GROUP BY (stop before HAVING)
const groupMatch = s.match(/\bGROUP\s+BY\s+(.+?)(?:\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i); 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() : ''; const groupBy = groupMatch ? groupMatch[1].trim() : '';
// Extract ORDER BY // Extract ORDER BY
@ -207,50 +207,77 @@ export function pushDownFilters(tree: PlanTree): PlanTree {
const nodes = { ...tree.nodes }; const nodes = { ...tree.nodes };
let root = tree.root; 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 { function pushDown(nodeId: string): string {
const node = nodes[nodeId]; const node = nodes[nodeId];
if (!node) return nodeId; if (!node) return nodeId;
// Recurse into children // Recurse into children first
node.children = node.children.map(pushDown); node.children = node.children.map(pushDown);
if (node.op === 'Filter' && nodes[node.children[0]]?.op === 'Join') { if (node.op === 'Filter' && nodes[node.children[0]]?.op === 'Join') {
const joinNode = nodes[node.children[0]]; const joinNode = nodes[node.children[0]];
const predicate = node.predicate || ''; 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 leftTable = nodes[joinNode.leftChild!]?.table ?? '';
const rightTable = nodes[joinNode.rightChild!]?.table ?? ''; const rightTable = nodes[joinNode.rightChild!]?.table ?? '';
const predCols = extractColumnRefs(predicate);
const onlyLeft = predCols.every(c => c.startsWith(leftTable + '.') || !c.includes('.')); const leftPreds: string[] = [];
const onlyRight = predCols.every(c => c.startsWith(rightTable + '.') || !c.includes('.')); const rightPreds: string[] = [];
const remainingPreds: string[] = [];
if (onlyLeft && joinNode.leftChild) { for (const pred of conjuncts) {
// Push filter to left child 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'); const filterId = nextId('filter');
nodes[filterId] = { nodes[filterId] = {
id: filterId, op: 'Filter', predicate, id: filterId, op: 'Filter', predicate: leftPreds.join(' AND '),
estimatedRows: Math.ceil(nodes[joinNode.leftChild].estimatedRows * 0.3), estimatedRows: Math.ceil(nodes[joinNode.leftChild].estimatedRows * 0.3),
children: [joinNode.leftChild] children: [joinNode.leftChild]
}; };
joinNode.leftChild = filterId; joinNode.leftChild = filterId;
joinNode.children = [filterId, joinNode.rightChild!]; joinNode.children = [filterId, joinNode.rightChild!];
joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.7); joinNode.estimatedRows = Math.ceil(joinNode.estimatedRows * 0.5);
return joinNode.id; }
} else if (onlyRight && joinNode.rightChild) {
// Push filter to right child // Push right-side predicates below the join's right child
if (rightPreds.length > 0 && joinNode.rightChild) {
const filterId = nextId('filter'); const filterId = nextId('filter');
nodes[filterId] = { nodes[filterId] = {
id: filterId, op: 'Filter', predicate, id: filterId, op: 'Filter', predicate: rightPreds.join(' AND '),
estimatedRows: Math.ceil(nodes[joinNode.rightChild].estimatedRows * 0.3), estimatedRows: Math.ceil(nodes[joinNode.rightChild].estimatedRows * 0.3),
children: [joinNode.rightChild] children: [joinNode.rightChild]
}; };
joinNode.rightChild = filterId; joinNode.rightChild = filterId;
joinNode.children = [joinNode.leftChild!, 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; 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); node.children.forEach(fold);
if (node.op === 'Filter' && node.predicate) { if (node.op === 'Filter' && node.predicate) {
node.predicate = node.predicate // Constant-fold arithmetic, but NOT inside string literals or date literals
.replace(/\b(\d+)\s*\+\s*(\d+)\b/g, (_, a, b) => String(Number(a) + Number(b))) const parts = node.predicate.split(/(')/);
.replace(/\b(\d+)\s*-\s*(\d+)\b/g, (_, a, b) => String(Number(a) - Number(b))) for (let i = 0; i < parts.length; i++) {
.replace(/\b(\d+)\s*\*\s*(\d+)\b/g, (_, a, b) => String(Number(a) * Number(b))); 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<string, Ta
if (leftRows > 10000 || rightRows > 10000) { if (leftRows > 10000 || rightRows > 10000) {
node.joinType = 'hash'; node.joinType = 'hash';
} else if (leftRows < 100 || rightRows < 100) { } else if (leftRows < 100 && rightRows < 100) {
node.joinType = 'nested_loop'; node.joinType = 'nested_loop';
} else { } else {
node.joinType = 'merge'; node.joinType = 'hash';
} }
} }
@ -427,13 +460,43 @@ export function generateSQL(nodes: Record<string, PlanNode>, rootId: string): st
let orderBy = ''; let orderBy = '';
let limit = ''; 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<string>(); // tables on the right side of a JOIN
const leftmostJoinTable = new Set<string>(); // 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 { function walk(nid: string): void {
const n = nodes[nid]; const n = nodes[nid];
if (!n) return; if (!n) return;
switch (n.op) { switch (n.op) {
case 'Scan': 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; break;
case 'Filter': case 'Filter':
if (where) { if (where) {
@ -444,8 +507,19 @@ export function generateSQL(nodes: Record<string, PlanNode>, rootId: string): st
break; break;
case 'Join': { case 'Join': {
const jt = n.joinType === 'hash' ? 'INNER' : n.joinType === 'nested_loop' ? 'INNER' : (n.joinType?.toUpperCase() ?? 'INNER'); const jt = n.joinType === 'hash' ? 'INNER' : n.joinType === 'nested_loop' ? 'INNER' : (n.joinType?.toUpperCase() ?? 'INNER');
const rt = nodes[n.rightChild!]?.table ?? 'unknown'; const resolveTable = (nid: string | undefined): string => {
const lt = nodes[n.leftChild!]?.table ?? 'unknown'; 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`; const onClause = n.onClause ?? `${rt}.id = ${lt}.id`;
joins.push(`${jt} JOIN ${rt} ON ${onClause}`); joins.push(`${jt} JOIN ${rt} ON ${onClause}`);
if (n.leftChild) walk(n.leftChild); if (n.leftChild) walk(n.leftChild);
@ -477,7 +551,7 @@ export function generateSQL(nodes: Record<string, PlanNode>, rootId: string): st
walk(rootId); 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 (joins.length > 0) sql += ' ' + joins.join(' ');
if (where) sql += ` WHERE ${where}`; if (where) sql += ` WHERE ${where}`;
if (groupBy) sql += ` GROUP BY ${groupBy}`; if (groupBy) sql += ` GROUP BY ${groupBy}`;