Part 10 · 6 chapters · ~20 min

Build a SQL engine

The capstone. You will write a tokenizer, a recursive-descent parser, a logical and physical planner, and volcano iterators including window functions and recursive CTEs — then connect it to the storage engine from the MySQL module's Part 11, giving you a small but genuinely complete database.

79

Tokenizer and grammar

A lexer turns characters into tokens; a recursive-descent parser turns tokens into a tree. The structure of the parser mirrors the grammar directly, which is why this approach is worth knowing even outside databases.

code
// lexer.ts
export type Tok =
  | { k: 'kw';  v: string }
  | { k: 'id';  v: string }
  | { k: 'num'; v: number }
  | { k: 'str'; v: string }
  | { k: 'op';  v: string }
  | { k: 'eof' };

const KEYWORDS = new Set([
  'SELECT','FROM','WHERE','GROUP','BY','HAVING','ORDER','LIMIT',
  'JOIN','LEFT','INNER','ON','AS','AND','OR','NOT','NULL',
  'IS','IN','WITH','RECURSIVE','UNION','ALL','OVER','PARTITION',
  'ASC','DESC','COUNT','SUM','AVG','MIN','MAX','ROW_NUMBER'
]);

export function lex(src: string): Tok[] {
  const out: Tok[] = [];
  let i = 0;

  while (i < src.length) {
    const c = src[i];

    if (/\s/.test(c)) { i++; continue; }

    // comment
if (c === '-' && src[i+1] === '-') {
      while (i < src.length && src[i] !== '\n') i++;
      continue;
    }

    // string literal — '' is an escaped quote
if (c === "'") {
      let v = ''; i++;
      while (i < src.length) {
        if (src[i] === "'" && src[i+1] === "'") { v += "'"; i += 2; }
        else if (src[i] === "'") { i++; break; }
        else v += src[i++];
      }
      out.push({ k: 'str', v }); continue;
    }

    // number
if (/[0-9]/.test(c)) {
      let v = '';
      while (i < src.length && /[0-9.]/.test(src[i])) v += src[i++];
      out.push({ k: 'num', v: parseFloat(v) }); continue;
    }

    // identifier or keyword
if (/[A-Za-z_]/.test(c)) {
      let v = '';
      while (i < src.length && /[A-Za-z0-9_.]/.test(src[i])) v += src[i++];
      const up = v.toUpperCase();
      out.push(KEYWORDS.has(up) ? { k: 'kw', v: up } : { k: 'id', v });
      continue;
    }

    // multi-char operators first, then single
const two = src.substr(i, 2);
    if (['>=','<=','<>','!='].includes(two)) {
      out.push({ k: 'op', v: two === '!=' ? '<>' : two }); i += 2; continue;
    }
    out.push({ k: 'op', v: c }); i++;
  }

  out.push({ k: 'eof' });
  return out;
}

The grammar, in the subset we implement:

code
query      := [ with ] select
with       := WITH [RECURSIVE] cte { ',' cte }
cte        := id AS '(' select [ UNION ALL select ] ')'
select     := SELECT projList FROM tableRef { join }
              [ WHERE expr ] [ GROUP BY exprList ]
              [ HAVING expr ] [ ORDER BY ordList ] [ LIMIT num ]
join       := [LEFT|INNER] JOIN tableRef ON expr
expr       := orExpr
orExpr     := andExpr { OR andExpr }          -- lowest precedence
andExpr    := notExpr { AND notExpr }
notExpr    := [NOT] cmpExpr
cmpExpr    := addExpr [ (= | <> | < | > | <= | >= | IS [NOT] NULL) addExpr ]
addExpr    := mulExpr { (+ | -) mulExpr }
mulExpr    := atom { (* | /) atom }               -- highest precedence
atom       := num | str | id | func | '(' expr ')'
precedence falls out of the nesting
Each grammar rule calls the one below it, so the deeper the rule, the tighter it binds. orExpr calls andExpr calls cmpExpr, which is exactly why a OR b AND c means a OR (b AND c). You do not implement precedence — you get it free by ordering the functions correctly. That is the whole trick of recursive descent.
80

AST to logical plan to physical plan

Three representations. The AST mirrors the text; the logical plan mirrors the evaluation order from chapter 6; the physical plan chooses algorithms.

code
// plan.ts
// ── logical operators, in evaluation order (ch 6) ──
export type Logical =
  | { op:'scan';   table:string; alias:string }
  | { op:'join';   left:Logical; right:Logical; on:Expr; kind:'inner'|'left' }
  | { op:'filter'; src:Logical; pred:Expr }
  | { op:'group';  src:Logical; keys:Expr[]; aggs:Agg[] }
  | { op:'having'; src:Logical; pred:Expr }
  | { op:'window'; src:Logical; fns:WinFn[] }
  | { op:'project';src:Logical; exprs:Expr[]; names:string[] }
  | { op:'sort';   src:Logical; keys:{expr:Expr; desc:boolean}[] }
  | { op:'limit';  src:Logical; n:number };

/** build the logical plan in the ORDER THE ENGINE EVALUATES, not
    the order the clauses were written. this function IS chapter 6. */
export function toLogical(q: SelectAst): Logical {
  let node: Logical = { op:'scan', table:q.from.table, alias:q.from.alias };

  // 1. FROM + JOIN
for (const j of q.joins)
    node = { op:'join', left:node,
             right:{op:'scan', table:j.table, alias:j.alias},
             on:j.on, kind:j.kind };

  // 2. WHERE
if (q.where)   node = { op:'filter', src:node, pred:q.where };
  // 3. GROUP BY
if (q.groupBy)  node = { op:'group', src:node, keys:q.groupBy, aggs:q.aggs };
  // 4. HAVING
if (q.having)   node = { op:'having', src:node, pred:q.having };
  // 5. window functions
if (q.windows.length) node = { op:'window', src:node, fns:q.windows };
  // 6. SELECT — aliases come into existence HERE
  node = { op:'project', src:node, exprs:q.projExprs, names:q.projNames };
  // 8. ORDER BY — can see the aliases
if (q.orderBy)  node = { op:'sort', src:node, keys:q.orderBy };
  // 9. LIMIT
if (q.limit != null) node = { op:'limit', src:node, n:q.limit };

  return node;
}

// ── physical: choose algorithms. this is the optimizer. ──
export function toPhysical(l: Logical, cat: Catalog): Physical {
  switch (l.op) {
    case 'filter': {
      const src = l.src;
      // THE one optimization: a sargable predicate on an indexed
// column becomes a range scan instead of scan + filter (ch 73)
if (src.op === 'scan') {
        const range = sargable(l.pred, cat.indexes(src.table));
        if (range)
          return { op:'indexScan', table:src.table,
                   index:range.index, lo:range.lo, hi:range.hi };
      }
      return { op:'filter', src:toPhysical(src, cat), pred:l.pred };
    }
    case 'join':
      // equi-join on an indexed column → nested loop with index lookup
// otherwise → hash join (MySQL module ch 79)
return isEquiIndexed(l, cat)
        ? { op:'nestedLoop', ...toBoth(l, cat) }
        : { op:'hashJoin',  ...toBoth(l, cat) };
    default:
      return mapChildren(l, n => toPhysical(n, cat));
  }
}

/** is this predicate a bare column compared to a constant? (ch 73) */
function sargable(e: Expr, idx: Index[]) {
  if (e.kind !== 'cmp') return null;
  // the column must appear ALONE — a function call disqualifies it
if (e.left.kind !== 'col')  return null;
  if (e.right.kind !== 'lit') return null;
  const ix = idx.find(x => x.cols[0] === e.left.name);
  if (!ix) return null;
  switch (e.op) {
    case '=':  return { index:ix, lo:e.right.v, hi:e.right.v };
    case '>':
    case '>=': return { index:ix, lo:e.right.v, hi:Infinity };
    case '<':
    case '<=': return { index:ix, lo:-Infinity, hi:e.right.v };
    default:   return null;
  }
}
the exercise that makes chapter 73 concrete
Wrap the column in a function in your test query and watch sargable() return null at the e.left.kind !== 'col' line — so the plan falls back to scan plus filter. You will have written the exact mechanism that makes WHERE DATE(created_at) = ... slow, in about four lines.
81

Volcano iterators

Each operator is a generator that pulls from its child. Composing them builds the pipeline, and JavaScript's generators map onto the model almost exactly.

code
// exec.ts
export type Row = Record<string, any>;
export type Iter = Generator<Row>;

export function* exec(p: Physical, ctx: Ctx): Iter {
  switch (p.op) {

    case 'scan':
      yield* ctx.storage.scan(p.table);      // the B+tree (ch 84)
return;

    case 'indexScan':
      yield* ctx.storage.range(p.table, p.index, p.lo, p.hi);
      return;

    case 'filter':
      for (const r of exec(p.src, ctx))
        if (evalExpr(p.pred, r) === true)   // ← TRUE only. UNKNOWN is
yield r;                          //   discarded, per ch 8.
return;

    case 'project':
      for (const r of exec(p.src, ctx)) {
        const out: Row = {};
        p.exprs.forEach((e, i) => out[p.names[i]] = evalExpr(e, r));
        yield out;
      }
      return;

    case 'nestedLoop': {
      const right = [...exec(p.right, ctx)];   // materialize inner
for (const l of exec(p.left, ctx)) {
        let matched = false;
        for (const rr of right) {
          const joined = { ...l, ...rr };
          if (evalExpr(p.on, joined) === true) { matched = true; yield joined; }
        }
        // LEFT JOIN: emit the unmatched left row, NULL-padded (ch 14)
if (!matched && p.kind === 'left') yield { ...l, ...p.rightNulls };
      }
      return;
    }

    case 'hashJoin': {
      // build on the smaller side, probe with the larger (ch 79 MySQL)
const table = new Map<any, Row[]>();
      for (const r of exec(p.build, ctx)) {
        const k = evalExpr(p.buildKey, r);
        if (k == null) continue;            // NULL never matches (ch 9)
        (table.get(k) ?? table.set(k, []).get(k)!).push(r);
      }
      for (const r of exec(p.probe, ctx)) {
        const k = evalExpr(p.probeKey, r);
        for (const m of table.get(k) ?? []) yield { ...m, ...r };
      }
      return;
    }

    case 'group': {
      // BLOCKING: must consume everything before emitting (ch 13 MySQL)
const groups = new Map<string, { key:Row; rows:Row[] }>();
      for (const r of exec(p.src, ctx)) {
        const kv = p.keys.map(e => evalExpr(e, r));
        const id = JSON.stringify(kv);        // NULLs group together (ch 9)
if (!groups.has(id)) groups.set(id, { key:r, rows:[] });
        groups.get(id)!.rows.push(r);
      }
      for (const g of groups.values())
        yield { ...g.key, ...applyAggs(p.aggs, g.rows) };
      return;
    }

    case 'sort': {
      const all = [...exec(p.src, ctx)];        // also blocking
      all.sort((a, b) => {
        for (const k of p.keys) {
          const av = evalExpr(k.expr, a), bv = evalExpr(k.expr, b);
          if (av === bv) continue;
          const c = av == null ? -1 : bv == null ? 1 : (av < bv ? -1 : 1);
          return k.desc ? -c : c;
        }
        return 0;
      });
      yield* all;
      return;
    }

    case 'limit': {
      let n = 0;
      for (const r of exec(p.src, ctx)) {
        if (n++ >= p.n) return;   // ← stops pulling. the child is abandoned.
yield r;
      }
      return;
    }
  }
}

/** three-valued logic, implemented exactly as in chapter 8 */
function evalExpr(e: Expr, r: Row): any {
  switch (e.kind) {
    case 'lit': return e.v;
    case 'col': return r[e.name] ?? null;
    case 'cmp': {
      const a = evalExpr(e.left, r), b = evalExpr(e.right, r);
      if (a === null || b === null) return null;   // ← UNKNOWN
switch (e.op) {
        case '=':  return a === b;
        case '<>': return a !== b;
        case '<':  return a < b;
        case '>':  return a > b;
        case '<=': return a <= b;
        case '>=': return a >= b;
      }
    }
    case 'and': {
      const a = evalExpr(e.left, r), b = evalExpr(e.right, r);
      if (a === false || b === false) return false;  // FALSE wins
if (a === null  || b === null)  return null;
      return true;
    }
    case 'or': {
      const a = evalExpr(e.left, r), b = evalExpr(e.right, r);
      if (a === true || b === true) return true;     // TRUE wins
if (a === null || b === null) return null;
      return false;
    }
    case 'isNull': return evalExpr(e.arg, r) === null;  // always 2-valued
  }
}
two things this code proves
The limit case returns early, which abandons the child generator. That is exactly why LIMIT on an indexed, ordered query is nearly free, and why it is not when a blocking sort sits beneath it.

evalExpr returns null for UNKNOWN, and filter requires strict === true. Those two lines are the entire NOT IN trap from chapter 18 — try it and watch your own engine return zero rows.
82

Implementing window functions

Partition, sort within each partition, then compute a frame per row. The ROWS-versus-RANGE distinction from chapter 32 becomes two lines of code.

code
// window.ts
export function* windowOp(src: Iter, fns: WinFn[]): Iter {
  const all = [...src];                       // blocking: needs everything
for (const fn of fns) {
    // 1. partition
const parts = new Map<string, Row[]>();
    for (const r of all) {
      const k = JSON.stringify(fn.partitionBy.map(e => evalExpr(e, r)));
      (parts.get(k) ?? parts.set(k, []).get(k)!).push(r);
    }

    for (const rows of parts.values()) {
      // 2. order within the partition
if (fn.orderBy.length) rows.sort(cmpBy(fn.orderBy));

      // 3. compute per row
      rows.forEach((r, i) => {
        if (fn.name === 'ROW_NUMBER') { r[fn.alias] = i + 1; return; }

        const [lo, hi] = frameBounds(fn, rows, i);
        r[fn.alias] = aggregate(fn.name, rows.slice(lo, hi + 1), fn.arg);
      });
    }
  }
  yield* all;
}

/** THE chapter 32 distinction, in one function */
function frameBounds(fn: WinFn, rows: Row[], i: number): [number, number] {
  const mode = fn.frame?.mode ?? 'RANGE';        // ← the DEFAULT is RANGE
const start = 0;                              // UNBOUNDED PRECEDING
if (mode === 'ROWS') return [start, i];      // exactly this row
// RANGE: extend to include every PEER — rows whose ORDER BY
// values equal the current row's. this is the "jump" (ch 32).
let end = i;
  const cur = fn.orderBy.map(o => evalExpr(o.expr, rows[i]));
  while (end + 1 < rows.length) {
    const nxt = fn.orderBy.map(o => evalExpr(o.expr, rows[end + 1]));
    if (JSON.stringify(cur) !== JSON.stringify(nxt)) break;
    end++;
  }
  return [start, end];
}
the experiment
Run chapter 32's exact test data through this — four rows, two sharing a date — with and without an explicit ROWS clause. You will reproduce the 60/60 jump yourself. Having written frameBounds, the trap stops being a rule to remember and becomes something you can derive.
83

Recursive CTEs as a fixpoint loop

Chapter 43's algorithm, transcribed. The whole thing is twenty lines, and writing it is what makes "the self-reference sees only the previous iteration" obvious.

code
// recursive.ts
export function* recursiveCte(
  cte: RecursiveCte, ctx: Ctx, maxIter = 1000
): Iter {

  // 1. the anchor runs ONCE
let working: Row[] = [...exec(cte.anchor, ctx)];
  yield* working;

  let iterations = 0;

  // 2-4. loop until an iteration produces nothing
while (working.length > 0) {
    if (++iterations > maxIter)
      throw new Error(`recursion exceeded ${maxIter} iterations`);

    // the self-reference resolves to the WORKING TABLE — only the
// previous iteration's rows, never the accumulated result.
    ctx.cteRows.set(cte.name, working);

    const next = [...exec(cte.recursive, ctx)];
    yield* next;

    working = next;        // ← becomes the new working table
  }
}

Two extensions worth adding, both from chapter 44:

code
// UNION (not ALL): dedupe against everything seen so far.
// this alone terminates a cyclic graph.
const seen = new Set<string>();
const next = [...exec(cte.recursive, ctx)]
  .filter(r => {
    const k = JSON.stringify(r);
    if (seen.has(k)) return false;
    seen.add(k); return true;
  });

// cycle detection by path, as in chapter 44 — carry the visited
// set per row rather than deduping globally, so genuine distinct
// paths to the same node are preserved.
84

Connecting to the storage engine

The final step, and the reason both capstones exist. Replace the in-memory row source with the B+tree from the MySQL module's Part 11, and you have a database: persistent storage, crash recovery, MVCC, and a SQL interface over it.

code
// engine.ts
import { MiniDB } from '../minidb/db';        // MySQL module, part 11
import { lex } from './lexer';
import { parse } from './parser';
import { toLogical, toPhysical } from './plan';
import { exec } from './exec';

export class Engine {
  constructor(private db: MiniDB) {}

  query(sql: string): Row[] {
    const ast  = parse(lex(sql));
    const log  = toLogical(ast);
    const phys = toPhysical(log, this.db.catalog);

    const trx  = this.db.begin();
    const view = this.db.readView(trx);      // MVCC snapshot (ch 42)
try {
      return [...exec(phys, {
        storage: {
          // full scan → walk the leaf chain, MVCC-filtered
          *scan(table: string) {
            for (const rec of this.db.tree(table).range(-Infinity, Infinity)) {
              const row = this.db.visible(rec, view);
              if (row) yield row;
            }
          },
          // index range → ONE descent, then a sideways walk (ch 24 MySQL)
          *range(table: string, index: string, lo: any, hi: any) {
            for (const rec of this.db.tree(table, index).range(lo, hi)) {
              const row = this.db.visible(rec, view);
              if (row) yield row;
            }
          }
        },
        cteRows: new Map()
      })];
    } finally {
      this.db.commit(trx);
    }
  }
}

// ── and it runs ──
const engine = new Engine(new MiniDB('app.db', 'app.wal'));

engine.query(`
  SELECT u.name, COUNT(o.id) AS orders
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  WHERE u.id > 100
  GROUP BY u.name
  ORDER BY orders DESC
  LIMIT 10
`);

What you have built

  • Pages, a pager, and a free list — MySQL Part 2
  • A B+tree that splits and supports range scans — MySQL Part 3
  • MVCC with read views and version chains — MySQL Part 4
  • A write-ahead log surviving kill -9 — MySQL Part 6
  • A SQL parser, planner and executor — this part
  • Window functions with correct frame semantics — chapter 82
  • Recursive CTEs as a fixpoint loop — chapter 83

Extensions, in order of value

  1. A cost model. Estimate rows per plan and choose between hash join and nested loop by cost rather than by a hard rule. Then make the estimate wrong and watch it choose badly — MySQL chapter 78, felt rather than read.
  2. EXPLAIN. Print the physical plan tree with estimated and actual row counts. Twenty lines, and it makes every optimization you add visible.
  3. Subqueries and semijoins. Implement EXISTS both naively and as a semijoin, and measure the difference.
  4. Predicate pushdown. Move filters below joins in the logical plan and watch the row counts through the pipeline collapse.
  5. A wire protocol. Accept queries over a socket, and you have a database server.
why both capstones together matter
Plenty of engineers have written a toy parser. Fewer have written a B+tree that splits correctly. Almost nobody has connected the two and watched a SELECT descend their own index, read their own pages, resolve their own MVCC visibility, and come back through their own iterator pipeline.

That path — SQL text to bytes on disk and back — is the thing this entire curriculum is about. Once you have walked it in code you wrote, database behaviour stops being a set of rules to recall and becomes something you reason about from first principles.

You have finished the module

84 chapters, from Codd's 1970 paper to a working SQL engine. What you should now be able to do:

  • Derive SQL behaviour from evaluation order and three-valued logic, rather than remembering rules.
  • Recognize fan-out, the NOT IN trap, and the RANGE frame default on sight — the three highest-cost SQL bugs.
  • Write window functions, recursive CTEs and keyset pagination fluently.
  • Explain why a query is slow by naming the mechanism, not by guessing.
  • Point at a SQL engine you wrote that parses, plans and executes.

The Postgres module is the natural next step — it takes the same depth to a different architecture, and its planner chapters connect directly to the physical plans you just built.