SQL Joins, Visualized
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
join-engine.ts
import type { Relation, Scalar, Tuple } from "../relational/relation";import { RelationQuery } from "../relational/relation-query";import type { Tracer } from "../trace";/** How the pairs are found: every pair, a hash table on one side, or two sorted runs. */export const JOIN_ALGORITHMS = ["nested-loop", "hash", "sort-merge"] as const;export type JoinAlgorithm = (typeof JOIN_ALGORITHMS)[number];/** Which rows without a partner survive: none, the left side's, the right side's, or both. */export const JOIN_TYPES = ["inner", "left", "right", "full"] as const;export type JoinType = (typeof JOIN_TYPES)[number];/** Rows per side: the stage draws both inputs and the result as tables. */export const MAX_JOIN_ROWS = 12;/** What a padded row carries where the other side had no row: the stage's null, since a cell is a scalar. */export const NULL_CELL = "null";export interface JoinSpec { left: Relation; right: Relation; /** The attribute of each side whose values must agree. */ on: { left: string; right: string }; algorithm: JoinAlgorithm; type: JoinType; /** The result relation's name. */ name: string;}export interface JoinResult { relation: Relation; /** Key comparisons made: every pair for a nested loop, the bucket entries examined for a hash, the pointer comparisons for a merge. */ comparisons: number; matches: number; unmatchedLeft: number; unmatchedRight: number;}/** * A join pairs the rows of two tables whose keys agree, which is a * Cartesian product with a filter, and three algorithms find the same * pairs at very different prices. The nested loop compares every left * row with every right row, n times m comparisons and no memory. The * hash join builds a table of the right rows keyed by their join value * and probes it once per left row, n plus m work when the table fits in * memory. The sort-merge join sorts both sides and walks them with two * pointers, advancing whichever points at the smaller key, so every * pair is found in one pass after the sort. An outer join then pads * every row that found no partner with nulls on the other side: the * left rows, the right rows, or both. * * Time: O(nm) for the nested loop, O(n + m) for the hash join, O(n log n + m log m) for the merge. Space: O(m) for the hash table, O(n + m) for the sorted orders. */export class JoinEngine { private readonly rows: Tuple[] = []; private readonly attributes: readonly string[]; private readonly matchedLeft = new Set<number>(); private readonly matchedRight = new Set<number>(); private comparisons = 0; private matchedPairs = 0; constructor( readonly spec: JoinSpec, private readonly tracer?: Tracer, ) { const { left, right, on, algorithm, type } = spec; if (!left.attributes.includes(on.left)) throw new RangeError(`no attribute "${on.left}" on ${left.name}`); if (!right.attributes.includes(on.right)) throw new RangeError(`no attribute "${on.right}" on ${right.name}`); if (!JOIN_ALGORITHMS.includes(algorithm)) throw new RangeError(`unknown algorithm ${String(algorithm)}`); if (!JOIN_TYPES.includes(type)) throw new RangeError(`unknown join type ${String(type)}`); if (left.tuples.length > MAX_JOIN_ROWS || right.tuples.length > MAX_JOIN_ROWS) throw new RangeError(`a side exceeds ${MAX_JOIN_ROWS} rows`); this.attributes = JoinEngine.outputAttributes(left, right, on.right); } /** * The result's attributes: the left side's, then the right side's * without its join attribute; a right attribute whose name the left * side already uses is prefixed with the right relation's name. */ static outputAttributes(left: Relation, right: Relation, rightKey: string): string[] { const fromRight = right.attributes.filter((attribute) => attribute !== rightKey).map((attribute) => (left.attributes.includes(attribute) ? `${right.name}.${attribute}` : attribute)); return [...left.attributes, ...fromRight]; } /** Numbers before strings, each in their own order; the key order every sorted run uses. */ static compareKeys(a: Scalar, b: Scalar): number { if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "number") return -1; if (typeof b === "number") return 1; return a < b ? -1 : a > b ? 1 : 0; } /** The whole join: the algorithm's matching phase, then the padding an outer join asks for. */ run(): JoinResult { const { algorithm, type, left, right, name } = this.spec; if (algorithm === "nested-loop") this.nestedLoop(); else if (algorithm === "hash") this.hashJoin(); else this.sortMerge(); if (type === "left" || type === "full") { for (let i = 0; i < left.tuples.length; i++) if (!this.matchedLeft.has(i)) this.pad("left", i); for (let leftRow = 0; leftRow < left.tuples.length; leftRow++) if (!this.matchedLeft.has(leftRow)) this.pad("left", leftRow); } if (type === "right" || type === "full") { for (let j = 0; j < right.tuples.length; j++) if (!this.matchedRight.has(j)) this.pad("right", j); for (let rightRow = 0; rightRow < right.tuples.length; rightRow++) if (!this.matchedRight.has(rightRow)) this.pad("right", rightRow); } return { relation: { name, attributes: [...this.attributes], tuples: [...this.rows] }, comparisons: this.comparisons, matches: this.matchedPairs, unmatchedLeft: left.tuples.length - this.matchedLeft.size, unmatchedRight: right.tuples.length - this.matchedRight.size, }; } private leftKey(i: number): Scalar { private leftKey(leftRow: number): Scalar { return RelationQuery.cell(this.spec.left.tuples[i]!, this.spec.on.left) as Scalar; return RelationQuery.cell(this.spec.left.tuples[leftRow]!, this.spec.on.left) as Scalar; } private rightKey(j: number): Scalar { private rightKey(rightRow: number): Scalar { return RelationQuery.cell(this.spec.right.tuples[j]!, this.spec.on.right) as Scalar; return RelationQuery.cell(this.spec.right.tuples[rightRow]!, this.spec.on.right) as Scalar; } /** Every left row against every right row: no memory, n times m comparisons. */ private nestedLoop(): void { const { left, right } = this.spec; for (let i = 0; i < left.tuples.length; i++) { for (let leftRow = 0; leftRow < left.tuples.length; leftRow++) { for (let j = 0; j < right.tuples.length; j++) { for (let rightRow = 0; rightRow < right.tuples.length; rightRow++) { const equal = this.compare(i, j, j === right.tuples.length - 1 ? "left" : "right"); const equal = this.compare(leftRow, rightRow, rightRow === right.tuples.length - 1 ? "left" : "right"); if (equal) this.match(i, j); if (equal) this.match(leftRow, rightRow); } } } /** Build a table of the right rows by key, then probe it once per left row. */ private hashJoin(): void { const { left, right } = this.spec; const buckets = new Map<string, number[]>(); for (let j = 0; j < right.tuples.length; j++) { for (let rightRow = 0; rightRow < right.tuples.length; rightRow++) { const key = this.rightKey(j); const key = this.rightKey(rightRow); const bucket = buckets.get(JSON.stringify(key)) ?? []; bucket.push(j); bucket.push(rightRow); buckets.set(JSON.stringify(key), bucket); // ▸ drop this right row into the bucket its key hashes to; later probes find it there this.tracer?.emit("build", { right: j, key, bucket: bucket.length, buckets: buckets.size }); this.tracer?.emit("build", { right: rightRow, key, bucket: bucket.length, buckets: buckets.size }); } for (let i = 0; i < left.tuples.length; i++) { for (let leftRow = 0; leftRow < left.tuples.length; leftRow++) { const key = this.leftKey(i); const key = this.leftKey(leftRow); const bucket = buckets.get(JSON.stringify(key)) ?? []; this.comparisons += bucket.length; // ▸ hash the left row's key and read its bucket: the right rows that can match are all there this.tracer?.emit("probe", { left: i, key, found: bucket.length, comparisons: this.comparisons }); this.tracer?.emit("probe", { left: leftRow, key, found: bucket.length, comparisons: this.comparisons }); for (const j of bucket) this.match(i, j); for (const rightRow of bucket) this.match(leftRow, rightRow); } } /** Sort both sides by key, then walk them with two pointers, advancing the smaller. */ private sortMerge(): void { const { left, right } = this.spec; const leftOrder = left.tuples.map((_, i) => i).sort((a, b) => JoinEngine.compareKeys(this.leftKey(a), this.leftKey(b)) || a - b); const leftOrder = left.tuples.map((_, leftRow) => leftRow).sort((a, b) => JoinEngine.compareKeys(this.leftKey(a), this.leftKey(b)) || a - b); const rightOrder = right.tuples.map((_, j) => j).sort((a, b) => JoinEngine.compareKeys(this.rightKey(a), this.rightKey(b)) || a - b); const rightOrder = right.tuples.map((_, rightRow) => rightRow).sort((a, b) => JoinEngine.compareKeys(this.rightKey(a), this.rightKey(b)) || a - b); // ▸ sort both sides by key, so equal keys sit level and a single pass finds every pair this.tracer?.emit("sort", { leftOrder, rightOrder, leftKeys: leftOrder.map((i) => this.leftKey(i)), rightKeys: rightOrder.map((j) => this.rightKey(j)) }); this.tracer?.emit("sort", { leftOrder, rightOrder, leftKeys: leftOrder.map((leftRow) => this.leftKey(leftRow)), rightKeys: rightOrder.map((rightRow) => this.rightKey(rightRow)) }); let a = 0; let b = 0; while (a < leftOrder.length && b < rightOrder.length) { const i = leftOrder[a]!; const leftRow = leftOrder[a]!; const j = rightOrder[b]!; const rightRow = rightOrder[b]!; const order = JoinEngine.compareKeys(this.leftKey(i), this.rightKey(j)); const order = JoinEngine.compareKeys(this.leftKey(leftRow), this.rightKey(rightRow)); const equal = this.compare(i, j, order < 0 ? "left" : order > 0 ? "right" : "both"); const equal = this.compare(leftRow, rightRow, order < 0 ? "left" : order > 0 ? "right" : "both"); if (!equal) { if (order < 0) a++; else b++; continue; } // A run of equal keys on each side: every pair across the two runs is a match. let aEnd = a; while (aEnd < leftOrder.length && JoinEngine.compareKeys(this.leftKey(leftOrder[aEnd]!), this.leftKey(i)) === 0) aEnd++; while (aEnd < leftOrder.length && JoinEngine.compareKeys(this.leftKey(leftOrder[aEnd]!), this.leftKey(leftRow)) === 0) aEnd++; let bEnd = b; while (bEnd < rightOrder.length && JoinEngine.compareKeys(this.rightKey(rightOrder[bEnd]!), this.rightKey(j)) === 0) bEnd++; while (bEnd < rightOrder.length && JoinEngine.compareKeys(this.rightKey(rightOrder[bEnd]!), this.rightKey(rightRow)) === 0) bEnd++; for (let x = a; x < aEnd; x++) for (let y = b; y < bEnd; y++) this.match(leftOrder[x]!, rightOrder[y]!); a = aEnd; b = bEnd; } } /** One key comparison, the single place the count grows; `advance` says which pointer moves next. */ private compare(i: number, j: number, advance: "left" | "right" | "both"): boolean { private compare(leftRow: number, rightRow: number, advance: "left" | "right" | "both"): boolean { const leftKey = this.leftKey(i); const leftKey = this.leftKey(leftRow); const rightKey = this.rightKey(j); const rightKey = this.rightKey(rightRow); const equal = JoinEngine.compareKeys(leftKey, rightKey) === 0; this.comparisons++; // ▸ put a left key beside a right key: equal means the two rows belong together this.tracer?.emit("compare", { left: i, right: j, leftKey, rightKey, equal, advance, comparisons: this.comparisons }); this.tracer?.emit("compare", { left: leftRow, right: rightRow, leftKey, rightKey, equal, advance, comparisons: this.comparisons }); return equal; } /** Output the left row joined to the right row: the one place a pair becomes a result row. */ private match(i: number, j: number): void { private match(leftRow: number, rightRow: number): void { const { left, right, on } = this.spec; const row: Record<string, Scalar | readonly Scalar[]> = {}; for (const attribute of left.attributes) row[attribute] = RelationQuery.cell(left.tuples[i]!, attribute); for (const attribute of left.attributes) row[attribute] = RelationQuery.cell(left.tuples[leftRow]!, attribute); for (const attribute of right.attributes) { if (attribute === on.right) continue; row[left.attributes.includes(attribute) ? `${right.name}.${attribute}` : attribute] = RelationQuery.cell(right.tuples[j]!, attribute); row[left.attributes.includes(attribute) ? `${right.name}.${attribute}` : attribute] = RelationQuery.cell(right.tuples[rightRow]!, attribute); } this.rows.push(row); this.matchedLeft.add(i); this.matchedLeft.add(leftRow); this.matchedRight.add(j); this.matchedRight.add(rightRow); this.matchedPairs++; // ▸ the keys agree: output the left row joined to the right row this.tracer?.emit("match", { left: i, right: j, key: this.leftKey(i), row, rows: this.rows.length }); this.tracer?.emit("match", { left: leftRow, right: rightRow, key: this.leftKey(leftRow), row, rows: this.rows.length }); } /** * Output a row that found no partner, nulls on the other side: the * outer join's one addition. The join column is shared, as a USING * join shares it, so a padded right row shows its key there. */ private pad(side: "left" | "right", index: number): void { const { left, right, on } = this.spec; const row: Record<string, Scalar | readonly Scalar[]> = {}; const source = side === "left" ? left.tuples[index]! : right.tuples[index]!; for (const attribute of left.attributes) row[attribute] = side === "left" ? RelationQuery.cell(source, attribute) : NULL_CELL; for (const attribute of right.attributes) { if (attribute === on.right) continue; row[left.attributes.includes(attribute) ? `${right.name}.${attribute}` : attribute] = side === "right" ? RelationQuery.cell(source, attribute) : NULL_CELL; } if (side === "right") row[on.left] = RelationQuery.cell(source, on.right); this.rows.push(row); const key = side === "left" ? this.leftKey(index) : this.rightKey(index); // ▸ an outer join keeps a row nobody matched, with nulls where the other side would be this.tracer?.emit("pad", { side, index, key, row, rows: this.rows.length }); }}The inner join by nested loop made 54 comparisons and found 8 pairs; 1 left row and 1 right row had no partner. The result holds 8 rows.