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:
parent
a910f07377
commit
35583630cb
1 changed files with 102 additions and 28 deletions
|
|
@ -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}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue