Раздел 10 · Databases

Своя СУБД на TS · урок 6: анализ, план, исполнитель, EXPLAIN

senior~230 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Своя СУБД на TS · урок 6: анализ, план, исполнитель, EXPLAIN

Кому это. К этому уроку у тебя собрана вся нижняя половина движка и фронтенд. В уроке 3 выросла куча с seq scan, в уроке 4 к ней добавился B+Tree с точечным и диапазонным поиском, в уроке 5 появился фронтенд: строка SQL режется на токены и собирается в дерево. Но дерево пока висит в воздухе. Оно знает имя name, но не знает, есть ли такая колонка в users. Оно говорит SELECT, но не вернёт ни строки.

Зачем. Сегодня мы замыкаем путь запроса. Дерево из урока 5 пройдёт три фазы и превратится в строки. Сначала binder сверит имена с каталогом и проверит типы. Потом планировщик построит план и выберет метод доступа: seq scan из урока 3 или index scan по B+Tree из урока 4. И наконец исполнитель прогонит план и вернёт результат. К концу урока движок ответит на SELECT ... WHERE ... из встроенного REPL, а EXPLAIN напечатает выбранный план тем самым принтером, что мы написали в уроке 5.

Важно про код. Урок большой: это вся верхняя половина движка разом. Но ни одна фаза не возится с байтами. Binder, планировщик и вычислитель выражений это снова чистые функции над деревьями, как фронтенд в уроке 5. Грязь только в каталоге (он заводит файлы куч и индексов) и в операторах исполнителя (они зовут heap.insertTuple и btree.rangeScan из прошлых уроков). Пиши по файлам, прогоняй тесты после каждого, и поиграй с виджетом планировщика: увидеть, как один и тот же запрос то идёт по индексу, то сканирует всю кучу, полезнее, чем читать про это.

Мы будем пользоваться ts-pattern для исчерпывающего разбора операторов и типов. Если в твоём пакете её ещё нет, поставь: pnpm add ts-pattern.

Где мы в движке

┌────────────────────────────────────────────────┐
│  Бинарный протокол + TCP-сервер                  │  урок 10
├────────────────────────────────────────────────┤
│  Исполнитель (Volcano) + EXPLAIN                 │  урок 6  ← мы здесь
│  Планировщик (метод доступа, диапазоны)          │  урок 6  ← мы здесь
│  Binder (семантический анализ, типы)             │  урок 6  ← мы здесь
│  Каталог таблиц (в памяти)                       │  урок 6  ← мы здесь
│  Парсер SQL (рекурсивный спуск + Pratt)          │  урок 5
│  Лексер (строка в токены)                        │  урок 5
├────────────────────────────────────────────────┤
│  Vacuum                                          │  урок 9
│  MVCC (видимость, снимки)                        │  урок 8
│  Блокировки (2PL), менеджер транзакций           │  уроки 7, 8
│  WAL (журнал, redo, recovery)                    │  урок 7
├────────────────────────────────────────────────┤
│  B+Tree индекс                                   │  урок 4
│  Куча (slotted pages, seq scan)                  │  урок 3
├────────────────────────────────────────────────┤
│  Буферный пул (кэш страниц, вытеснение)           │  урок 2
│  DiskManager (страницы поверх файла)              │  урок 1
└────────────────────────────────────────────────┘

Сегодня закрываем сразу четыре слоя. Заметь, чего тут пока нет: транзакций, WAL, блокировок, MVCC. Движок урока 6 однопользовательский и без долговечности: одна команда за раз, каждая вставка сразу на диске через буферный пул. Поля xmin/xmax в кортеже из урока 3 мы заполняем фиксированным значением и игнорируем. Это сознательное упрощение: сначала пусть движок научится отвечать на запросы, а транзакции и параллелизм навесим в уроках 7 и 8 поверх готового исполнителя.

Конвейер запроса

Путь команды через движок выглядит так:

строка SQL
   │  parseCommand (урок 5 + EXPLAIN)
   ▼
  AST (Statement)
   │  bindStatement (binder): имена ▶ индексы, проверка типов
   ▼
  LogicalPlan (что делаем)
   │  plan (планировщик): выбор метода доступа
   ▼
  дерево операторов Volcano (как делаем)
   │  open / next / close (исполнитель)
   ▼
  строки результата

Три фазы и две формы плана между ними. Логический план говорит, что делаем (прочитать таблицу users, отфильтровать по предикату, спроецировать колонки). Физический план это дерево операторов, которое говорит как (взять index scan по users__id, навесить фильтр, навесить проекцию).

Под всем этим лежит каталог: он знает, какие таблицы есть, какие у них колонки и где их файлы. С него и начнём, потому что без таблиц нечего связывать и нечего исполнять. Дальше пойдём снизу вверх по конвейеру.

Связанное представление

Прежде чем писать binder, опишем его выход: типы связанного плана. Это зеркало AST из урока 5, но с двумя отличиями. Колонки уже резолвлены в индексы (не имя name, а номер колонки), и каждое выражение несёт свой тип. Положи это в src/engine/bound.ts.

// src/engine/bound.ts
import type { ColumnType } from '~/schema.ts';
import type { BinaryOp } from '~/sql/ast.ts';
import type { Value } from '~/tuple.ts';

/** Тип выражения. 'null' это литерал NULL, совместимый с любой колонкой. */
export type BoundType = ColumnType | 'null';

export interface BoundColumn {
  readonly kind: 'column';
  readonly index: number;
  readonly type: ColumnType;
}

export interface BoundLiteral {
  readonly kind: 'literal';
  readonly value: Value;
  readonly type: BoundType;
}

export interface BoundBinary {
  readonly kind: 'binary';
  readonly op: BinaryOp;
  readonly left: BoundExpr;
  readonly right: BoundExpr;
  readonly type: BoundType;
}

export interface BoundUnary {
  readonly kind: 'unary';
  readonly op: 'NOT';
  readonly operand: BoundExpr;
  readonly type: 'bool';
}

export type BoundExpr = BoundColumn | BoundLiteral | BoundBinary | BoundUnary;

/** Одна выходная колонка проекции: вычисляемое выражение и имя в результате. */
export interface BoundOutput {
  readonly expr: BoundExpr;
  readonly name: string;
}

/** Одно присваивание UPDATE: индекс колонки и новое значение. */
export interface BoundAssignment {
  readonly index: number;
  readonly value: BoundExpr;
}

export type LogicalPlan =
  | { readonly kind: 'scan'; readonly table: string; readonly filter?: BoundExpr }
  | {
      readonly kind: 'project';
      readonly source: Extract<LogicalPlan, { kind: 'scan' }>;
      readonly outputs: readonly BoundOutput[];
    }
  | { readonly kind: 'insert'; readonly table: string; readonly values: readonly Value[] }
  | { readonly kind: 'delete'; readonly table: string; readonly filter?: BoundExpr }
  | {
      readonly kind: 'update';
      readonly table: string;
      readonly assignments: readonly BoundAssignment[];
      readonly filter?: BoundExpr;
    };

Сравни BoundColumn с ColumnExpr из урока 5. Там было имя и необязательный квалификатор таблицы, тут уже index: number, готовый номер колонки в схеме. Эту замену делает binder, и она важна для скорости: исполнитель вычисляет выражение для каждой строки, и искать колонку по имени на каждой строке было бы расточительно. BoundType это тип колонки плюс особый 'null': литерал NULL совместим с любым типом, поэтому ему нужен отдельный ярлык.

LogicalPlan это пять форм. scan с необязательным фильтром, project над сканом, и три DML-формы. Заметь, что project.source это именно scan, не любой план: в нашем диалекте SELECT это всегда проекция над одним сканом таблицы. Это узкое представление поймает в типах попытку собрать бессмысленное дерево.

Заодно заведём ошибки движка. Положи в src/engine/errors.ts.

// src/engine/errors.ts

export class EngineError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'EngineError';
  }
}

/**
 * Нарушение уникальности первичного ключа: INSERT или UPDATE дублирует ключ. Конфликт
 * ищется по куче (видимым строкам), а не по самому индексу. Почему именно так, разберём
 * в операторах Insert и Update.
 */
export class UniquenessError extends EngineError {
  constructor(message: string) {
    super(message);
    this.name = 'UniquenessError';
  }
}

EngineError это базовая ошибка анализа, планирования и исполнения, отдельная от SqlError фронтенда (та была про синтаксис, эта про смысл). UniquenessError наследует её: PRIMARY KEY с дублем это ошибка движка, и тест будет ловить именно её.

Binder: AST в логический план

Binder это чистая функция: на входе AST и доступ к каталогу, на выходе LogicalPlan или брошенная EngineError. Он делает три вещи: резолвит имена (таблицы и колонки) по каталогу, проверяет типы (сравнение требует одинаковый базовый тип, арифметика только над int, AND/OR/NOT булевы), и проверяет ограничения (арность INSERT, NOT NULL, PRIMARY KEY).

Что нужно binder-у от каталога, опишем интерфейсом. Так binder не зависит от конкретного каталога, он зависит от способности разрешить таблицу в её метаданные. Конкретный каталог мы напишем в конце урока и подключим к этому интерфейсу.

// src/engine/binder.ts
import { match, P } from 'ts-pattern';

import type {
  BoundAssignment,
  BoundExpr,
  BoundOutput,
  BoundType,
  LogicalPlan,
} from '~/engine/bound.ts';
import { EngineError } from '~/engine/errors.ts';
import type { Schema } from '~/schema.ts';
import type { BinaryOp, ColumnExpr, Expr, Literal, SelectItem, Statement } from '~/sql/ast.ts';
import { printExpr } from '~/sql/printer.ts';
import type { Value } from '~/tuple.ts';

/** Метаданные таблицы для binder-а: схема плюс индексы обязательных колонок. */
export interface TableMeta {
  readonly schema: Schema;
  /** Индексы колонок NOT NULL и PRIMARY KEY (в Schema ограничений нет). */
  readonly required: ReadonlySet<number>;
}

/** Доступ binder-а к каталогу: разрешение таблицы в её метаданные. */
export interface BindCatalog {
  getTable(table: string): TableMeta | undefined;
}

export function bindStatement(statement: Statement, catalog: BindCatalog): LogicalPlan {
  switch (statement.kind) {
    case 'select': {
      return bindSelect(statement, catalog);
    }
    case 'insert': {
      return bindInsert(statement, catalog);
    }
    case 'update': {
      return bindUpdate(statement, catalog);
    }
    case 'delete': {
      return bindDelete(statement, catalog);
    }
    case 'createTable': {
      throw new EngineError('CREATE TABLE handled before binding');
    }
  }
}

bindStatement это диспетчер по kind, ровно как prettyPrint в уроке 5. CREATE TABLE сюда не доходит: его обрабатывает каталог напрямую, до анализа, потому что связывать там нечего (таблицы ещё нет). Дальше разбор выражений, это рекурсивная и самая важная часть.

// src/engine/binder.ts (продолжение)

/** Связать выражение по схеме: резолв колонок в индексы, проверка типов. */
export function bindExpr(expr: Expr, schema: Schema, table: string): BoundExpr {
  switch (expr.kind) {
    case 'literal': {
      return bindLiteral(expr.literal);
    }
    case 'column': {
      return bindColumn(expr, schema, table);
    }
    case 'unary': {
      const operand = bindExpr(expr.operand, schema, table);
      requireType(operand, 'bool', 'NOT');
      return { kind: 'unary', op: 'NOT', operand, type: 'bool' };
    }
    case 'binary': {
      return bindBinary(
        expr.op,
        bindExpr(expr.left, schema, table),
        bindExpr(expr.right, schema, table),
      );
    }
  }
}

function bindBinary(op: BinaryOp, left: BoundExpr, right: BoundExpr): BoundExpr {
  return match(op)
    .with(P.union('AND', 'OR'), (logical): BoundExpr => {
      requireType(left, 'bool', logical);
      requireType(right, 'bool', logical);
      return { kind: 'binary', op: logical, left, right, type: 'bool' };
    })
    .with('LIKE', (like): BoundExpr => {
      requireType(left, 'text', like);
      requireType(right, 'text', like);
      return { kind: 'binary', op: like, left, right, type: 'bool' };
    })
    .with(P.union('=', '<>', '<', '<=', '>', '>='), (cmp): BoundExpr => {
      requireComparable(cmp, left, right);
      return { kind: 'binary', op: cmp, left, right, type: 'bool' };
    })
    .with(P.union('+', '-', '*', '/'), (arith): BoundExpr => {
      requireType(left, 'int', arith);
      requireType(right, 'int', arith);
      return { kind: 'binary', op: arith, left, right, type: 'int' };
    })
    .exhaustive();
}

bindExpr спускается по дереву и на каждом узле проставляет тип. Литерал несёт свой тип сразу. Колонка получает тип из схемы. У NOT операнд обязан быть булевым, результат булев. bindBinary это сердце проверки типов, и тут видно, зачем ts-pattern: четыре группы операторов, у каждой свои правила. Логические AND/OR требуют булевы операнды и дают bool. LIKE требует текст. Сравнения требуют сравнимости (об этом ниже) и дают bool. Арифметика требует int и даёт int. .exhaustive() гарантирует на этапе компиляции, что мы покрыли все операторы из BinaryOp: добавишь новый оператор в урок 5, и binder не соберётся, пока ты его тут не обработаешь.

Дальше резолв имён и проверка совместимости.

// src/engine/binder.ts (продолжение)

function bindColumn(column: ColumnExpr, schema: Schema, table: string): BoundExpr {
  if (column.table !== undefined && column.table !== table) {
    throw new EngineError(
      `неизвестная таблица "${column.table}" в ссылке "${column.table}.${column.name}"`,
    );
  }
  const index = resolveColumn(column.name, schema);
  return { kind: 'column', index, type: schema.columns[index]!.type };
}

function resolveColumn(name: string, schema: Schema): number {
  const index = schema.columns.findIndex((column) => column.name === name);
  if (index === -1) {
    throw new EngineError(`неизвестная колонка "${name}"`);
  }
  return index;
}

function requireType(expr: BoundExpr, type: BoundType, context: string): void {
  if (expr.type !== type && expr.type !== 'null') {
    throw new EngineError(`${context} требует ${type}, получено ${expr.type}`);
  }
}

function requireComparable(op: BinaryOp, left: BoundExpr, right: BoundExpr): void {
  if (left.type === 'null' || right.type === 'null') {
    return;
  }
  if (left.type !== right.type) {
    throw new EngineError(
      `${op} требует операнды одного типа, получено ${left.type} и ${right.type}`,
    );
  }
}

bindColumn делает две вещи. Если у колонки есть квалификатор (users.id), он сверяется с таблицей из FROM: чужой квалификатор это ошибка, а не молчаливое игнорирование. Потом имя резолвится в индекс через resolveColumn, и сюда же подставляется тип из схемы. requireType пропускает null как совместимый с любым типом (литерал NULL уместен где угодно), requireComparable разрешает сравнивать только значения одного типа, но NULL сравним с чем угодно (даст NULL при вычислении, об этом в следующем разделе).

Теперь операторы. SELECT это самый интересный случай, потому что тут мы честно отвергаем то, что парсер принимает, но исполнитель ещё не умеет.

// src/engine/binder.ts (продолжение)

function bindSelect(
  statement: Extract<Statement, { kind: 'select' }>,
  catalog: BindCatalog,
): LogicalPlan {
  if (statement.join) {
    throw new EngineError('JOIN это упражнение урока 6');
  }
  if (statement.orderBy) {
    throw new EngineError('ORDER BY это упражнение урока 6');
  }
  if (statement.limit !== undefined) {
    throw new EngineError('LIMIT это упражнение урока 6');
  }

  const schema = requireTable(catalog, statement.from).schema;
  const filter = bindFilter(statement.where, schema, statement.from);
  const outputs = isStar(statement.items)
    ? allColumns(schema)
    : statement.items.map((item) => bindOutput(item, schema, statement.from));

  const source =
    filter === undefined
      ? ({ kind: 'scan', table: statement.from } as const)
      : ({ kind: 'scan', table: statement.from, filter } as const);
  return { kind: 'project', source, outputs };
}

function bindFilter(where: Expr | undefined, schema: Schema, table: string): BoundExpr | undefined {
  if (where === undefined) {
    return undefined;
  }
  const filter = bindExpr(where, schema, table);
  if (filter.type !== 'bool' && filter.type !== 'null') {
    throw new EngineError(`условие WHERE должно быть булевым, получено ${filter.type}`);
  }
  return filter;
}

function bindOutput(item: SelectItem, schema: Schema, table: string): BoundOutput {
  if (item.kind === 'star') {
    throw new EngineError('звёздочка не сочетается с другими колонками');
  }
  const expr = bindExpr(item.expr, schema, table);
  const name = item.alias ?? (item.expr.kind === 'column' ? item.expr.name : printExpr(item.expr));
  return { expr, name };
}

function allColumns(schema: Schema): BoundOutput[] {
  return schema.columns.map((column, index) => ({
    expr: { kind: 'column', index, type: column.type },
    name: column.name,
  }));
}

function isStar(items: readonly SelectItem[]): boolean {
  return items.length === 1 && items[0]?.kind === 'star';
}

function requireTable(catalog: BindCatalog, table: string): TableMeta {
  const meta = catalog.getTable(table);
  if (meta === undefined) {
    throw new EngineError(`неизвестная таблица "${table}"`);
  }
  return meta;
}

JOIN, ORDER BY и LIMIT парсер из урока 5 разобрал в AST, но binder их отвергает: исполнить их пока нечем. Это нормальная стадия роста движка, и это упражнения этого урока. bindFilter связывает WHERE и требует, чтобы он был булевым: WHERE id без сравнения это ошибка, а не правда по ненулю, как в C. bindOutput вычисляет имя выходной колонки: алиас, если он есть, иначе имя колонки, иначе печать выражения (age + 1 станет колонкой с именем age + 1, для чего и нужен printExpr из урока 5). Звёздочка через allColumns разворачивается во все колонки таблицы по порядку.

Остались INSERT, UPDATE, DELETE.

// src/engine/binder.ts (продолжение)

function bindInsert(
  statement: Extract<Statement, { kind: 'insert' }>,
  catalog: BindCatalog,
): LogicalPlan {
  const meta = requireTable(catalog, statement.table);
  const schema = meta.schema;
  const targetIndices = statement.columns
    ? statement.columns.map((name) => resolveColumn(name, schema))
    : schema.columns.map((_column, index) => index);

  if (statement.values.length !== targetIndices.length) {
    throw new EngineError(
      `INSERT ожидает ${targetIndices.length} значений, получил ${statement.values.length}`,
    );
  }

  const values: Value[] = Array.from({ length: schema.size }, () => null);
  const provided = new Set<number>();
  for (const [position, columnIndex] of targetIndices.entries()) {
    const column = schema.columns[columnIndex]!;
    const literal = statement.values[position]!;
    checkAssignable(column.name, column.type, meta.required.has(columnIndex), literalType(literal));
    values[columnIndex] = literalValue(literal);
    provided.add(columnIndex);
  }

  for (const [index, column] of schema.columns.entries()) {
    if (!provided.has(index) && meta.required.has(index)) {
      throw new EngineError(`колонка "${column.name}" не может быть NULL`);
    }
  }

  return { kind: 'insert', table: statement.table, values };
}

function bindUpdate(
  statement: Extract<Statement, { kind: 'update' }>,
  catalog: BindCatalog,
): LogicalPlan {
  const meta = requireTable(catalog, statement.table);
  const schema = meta.schema;
  const assignments: BoundAssignment[] = statement.assignments.map((assignment) => {
    const index = resolveColumn(assignment.column, schema);
    const column = schema.columns[index]!;
    const value = bindExpr(assignment.value, schema, statement.table);
    checkAssignable(column.name, column.type, meta.required.has(index), value.type);
    return { index, value };
  });

  const filter = bindFilter(statement.where, schema, statement.table);
  return filter === undefined
    ? { kind: 'update', table: statement.table, assignments }
    : { kind: 'update', table: statement.table, assignments, filter };
}

function bindDelete(
  statement: Extract<Statement, { kind: 'delete' }>,
  catalog: BindCatalog,
): LogicalPlan {
  const schema = requireTable(catalog, statement.table).schema;
  const filter = bindFilter(statement.where, schema, statement.table);
  return filter === undefined
    ? { kind: 'delete', table: statement.table }
    : { kind: 'delete', table: statement.table, filter };
}

bindInsert делает самую кропотливую работу. Список целевых колонок берётся из запроса (INSERT INTO t (a, b)) или это все колонки по порядку. Проверяется арность (число значений равно числу колонок). Потом строится массив значений длиной во всю схему, инициализированный null, и заполняется по целевым индексам. В конце проходим по всем колонкам и проверяем: если обязательная колонка (NOT NULL или PRIMARY KEY) не была задана, это ошибка. bindUpdate связывает каждое присваивание, проверяя, что новое значение совместимо с типом колонки. bindDelete самый простой: только таблица и фильтр.

И мелочь: проверка присваиваемости и перевод литералов.

// src/engine/binder.ts (продолжение)

function checkAssignable(
  columnName: string,
  columnType: BoundType,
  required: boolean,
  valueType: BoundType,
): void {
  if (valueType === 'null') {
    if (required) {
      throw new EngineError(`колонка "${columnName}" не может быть NULL`);
    }
    return;
  }
  if (valueType !== columnType) {
    throw new EngineError(`колонка "${columnName}" ожидает ${columnType}, получено ${valueType}`);
  }
}

function bindLiteral(literal: Literal): BoundExpr {
  return { kind: 'literal', value: literalValue(literal), type: literalType(literal) };
}

function literalValue(literal: Literal): Value {
  switch (literal.kind) {
    case 'number': {
      return literal.value;
    }
    case 'string': {
      return literal.value;
    }
    case 'bool': {
      return literal.value;
    }
    case 'null': {
      return null;
    }
  }
}

function literalType(literal: Literal): BoundType {
  switch (literal.kind) {
    case 'number': {
      return 'int';
    }
    case 'string': {
      return 'text';
    }
    case 'bool': {
      return 'bool';
    }
    case 'null': {
      return 'null';
    }
  }
}

checkAssignable это правило совместимости значения с колонкой: null разрешён, если колонка не обязательна, иначе проверяем равенство типов. literalValue и literalType переводят литерал из AST в значение слоя данных (bigint, string, boolean, null) и в его тип. Binder готов: AST зашёл, типизированный логический план вышел или брошена осмысленная ошибка.

Вычислитель выражений

Прежде чем планировать, напишем то, что будет считать выражения над конкретными строками во время исполнения. Это нужно фильтру (WHERE), проекции (SELECT age + 1) и присваиваниям (SET x = x + 1). Вычислитель принимает BoundExpr и кортеж, возвращает значение. Положи в src/engine/eval.ts.

// src/engine/eval.ts
import { match, P } from 'ts-pattern';

import type { BoundExpr } from '~/engine/bound.ts';
import { EngineError } from '~/engine/errors.ts';
import type { BinaryOp } from '~/sql/ast.ts';
import type { Tuple, Value } from '~/tuple.ts';

type ComparisonOp = '=' | '<>' | '<' | '<=' | '>' | '>=';
type ArithmeticOp = '+' | '-' | '*' | '/';

export function evalExpr(expr: BoundExpr, tuple: Tuple): Value {
  switch (expr.kind) {
    case 'column': {
      return tuple.values[expr.index] ?? null;
    }
    case 'literal': {
      return expr.value;
    }
    case 'unary': {
      const operand = evalExpr(expr.operand, tuple);
      if (operand === null) {
        return null;
      }
      return !(operand as boolean);
    }
    case 'binary': {
      return evalBinary(expr.op, evalExpr(expr.left, tuple), evalExpr(expr.right, tuple));
    }
  }
}

/** Проходит ли строка фильтр: только строго TRUE (NULL и FALSE отсекаются). */
export function passesFilter(value: Value): boolean {
  return value === true;
}

evalExpr это прямая рекурсия по связанному дереву. Колонка читается из кортежа по индексу (тому самому, что проставил binder). Литерал отдаёт своё значение. NOT пропагирует NULL (если операнд NULL, результат NULL), иначе инвертирует. passesFilter это важная деталь семантики SQL: строка проходит WHERE, только если предикат дал строго TRUE. Ни FALSE, ни NULL не проходят, и это не одно и то же, что мы сейчас увидим.

// src/engine/eval.ts (продолжение)

function evalBinary(op: BinaryOp, left: Value, right: Value): Value {
  return (
    match([op, left, right] as const)
      .with(['AND', P._, P._], () => logicalAnd(left, right))
      .with(['OR', P._, P._], () => logicalOr(left, right))
      // остальные операторы пропагируют NULL: любой NULL-операнд сразу даёт NULL
      .with([P._, P.nullish, P._], () => null)
      .with([P._, P._, P.nullish], () => null)
      .with([P.union('=', '<>', '<', '<=', '>', '>='), P._, P._], ([cmp]) =>
        compareOp(cmp, left, right),
      )
      .with(['LIKE', P._, P._], () => likeMatch(left as string, right as string))
      .with([P.union('+', '-', '*', '/'), P._, P._], ([arith]) =>
        arithmetic(arith, left as bigint, right as bigint),
      )
      .exhaustive()
  );
}

function logicalAnd(left: Value, right: Value): Value {
  if (left === false || right === false) {
    return false;
  }
  if (left === null || right === null) {
    return null;
  }
  return true;
}

function logicalOr(left: Value, right: Value): Value {
  if (left === true || right === true) {
    return true;
  }
  if (left === null || right === null) {
    return null;
  }
  return false;
}

Вот трёхзначная логика SQL, разобранная руками. Любой оператор кроме AND/OR пропагирует NULL: 5 = NULL это не FALSE, а NULL (неизвестно). Поэтому два правила P.nullish стоят сразу после логических: они перехватывают NULL-операнд раньше, чем до него доберутся сравнения и арифметика. А AND и OR особенные: FALSE AND NULL это FALSE (одного FALSE хватает, чтобы знать ответ), но TRUE AND NULL это NULL. Поэтому их таблицы истинности написаны явно, до проверки на NULL. Эта аккуратность не педантизм: именно из-за неё WHERE name = 'alice' не вернёт строку, где name это NULL, и тест это проверит.

// src/engine/eval.ts (продолжение)

function compareOp(op: ComparisonOp, left: Value, right: Value): boolean {
  const ordering = compareValues(left, right);
  return match(op)
    .with('=', () => ordering === 0)
    .with('<>', () => ordering !== 0)
    .with('<', () => ordering < 0)
    .with('<=', () => ordering <= 0)
    .with('>', () => ordering > 0)
    .with('>=', () => ordering >= 0)
    .exhaustive();
}

/** Сравнить два ненулевых значения одного типа: -1, 0 или 1. */
function compareValues(left: Value, right: Value): number {
  if (typeof left === 'bigint' && typeof right === 'bigint') {
    return left < right ? -1 : left > right ? 1 : 0;
  }
  if (typeof left === 'string' && typeof right === 'string') {
    return left < right ? -1 : left > right ? 1 : 0;
  }
  if (typeof left === 'boolean' && typeof right === 'boolean') {
    return Number(left) - Number(right);
  }
  throw new EngineError('cannot compare values of different types');
}

function arithmetic(op: ArithmeticOp, left: bigint, right: bigint): bigint {
  return match(op)
    .with('+', () => left + right)
    .with('-', () => left - right)
    .with('*', () => left * right)
    .with('/', () => {
      if (right === 0n) {
        throw new EngineError('division by zero');
      }
      return left / right;
    })
    .exhaustive();
}

function likeMatch(text: string, pattern: string): boolean {
  let source = '^';
  for (const character of pattern) {
    if (character === '%') {
      source += '.*';
    } else if (character === '_') {
      source += '.';
    } else {
      source += escapeRegex(character);
    }
  }
  source += '$';
  return new RegExp(source, 's').test(text);
}

function escapeRegex(character: string): string {
  return /[.*+?^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
}

Сравнение сводит всё к функции compareValues, дающей знак (-1, 0, 1), а compareOp переводит знак в булев результат под конкретный оператор. Арифметика над bigint: целочисленное деление усекает к нулю (как в SQL), деление на ноль бросает. LIKE переводит SQL-шаблон в регулярное выражение: % это любая последовательность, _ это один символ, остальные символы экранируются. Флаг s нужен, чтобы . совпадал и с переводом строки. Вычислитель готов, теперь его есть кому звать.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.