Своя СУБД на Rust · урок 6: анализ, план, исполнитель, EXPLAIN
Кому это. К этому уроку у тебя собрана вся нижняя половина движка и фронтенд. В уроке 3 выросла куча с seq scan как итератором HeapScan, в уроке 4 к ней добавился B+Tree с range_scan, в уроке 5 появился фронтенд: строка SQL режется на токены и собирается в дерево из enum-ов, а ошибка разбора приходит как Error::Sql в Result. Но дерево пока висит в воздухе. Оно знает имя name, но не знает, есть ли такая колонка в users.
Зачем именно Rust. Сегодня замыкаем путь запроса: дерево пройдёт три фазы и превратится в строки. Binder сверит имена с каталогом, планировщик выберет seq scan или index scan, исполнитель прогонит план. На верхней половине Rust показывает себя с трёх сторон. Связанное дерево и план это enum-ы, и match проверяет полноту за тебя. Операторы Volcano это трейт-объекты Box<dyn Operator + 'a>, которые не копируют план, а заимствуют его, и компилятор следит, чтобы план жил дольше дерева операторов. А close из классической модели исчезает совсем: его работу делает Drop. И ещё одна деталь, которую в JS было не выразить дёшево: арифметика над i64 через checked_add и его братьев, так что переполнение это ошибка движка, а не тихий перенос через край.
Важно про код. Урок большой: это вся верхняя половина движка разом. Но ни одна фаза не возится с байтами. Binder, планировщик и вычислитель это чистые функции над деревьями. Грязь только в каталоге (он заводит файлы куч и индексов) и в DML-операторах (они зовут heap.insert_tuple и btree.insert из прошлых уроков). Крейт тот же ourdb, только std, edition 2021, rust-version = "1.84". Сегодня в нём появляется модуль engine из шести файлов: catalog.rs, binder.rs, eval.rs, operators.rs, planner.rs, executor.rs и mod.rs с Database.
Одна оговорка про листинги. Эталонный крейт examples/our-db/rust это движок целиком, с журналом, транзакциями, блокировками и vacuum. В его операторах уже есть поля vis: Visibility (снимок MVCC) и write: WriteCtx (локи и журнал), а Database ведёт транзакции. Здесь показана версия урока 6: без этих полей, с физическим удалением из урока 3. Каждое место, где эталон богаче, я отмечаю прямо в тексте, и ты увидишь, куда в уроках 7, 8 и 9 вставятся новые детали. Сигнатуры и имена функций совпадают с эталоном.
Где мы в движке
┌────────────────────────────────────────────────┐
│ Бинарный протокол + TCP-сервер │ урок 11
├────────────────────────────────────────────────┤
│ Исполнитель (Volcano) + EXPLAIN │ урок 6 ← мы здесь
│ Планировщик (метод доступа, диапазоны) │ урок 6 ← мы здесь
│ Binder (семантический анализ, типы) │ урок 6 ← мы здесь
│ Каталог таблиц (в памяти) │ урок 6 ← мы здесь
│ Парсер SQL (рекурсивный спуск + Pratt) │ урок 5
│ Лексер (строка в токены) │ урок 5
├────────────────────────────────────────────────┤
│ Vacuum │ урок 10
│ MVCC (видимость, снимки) │ уроки 8, 9
│ Блокировки (2PL), менеджер транзакций │ уроки 8, 9
│ WAL (журнал, redo, recovery) │ урок 7
├────────────────────────────────────────────────┤
│ B+Tree индекс │ урок 4
│ Куча (slotted pages, seq scan) │ урок 3
├────────────────────────────────────────────────┤
│ Буферный пул (кэш страниц, вытеснение) │ урок 2
│ DiskManager (страницы поверх файла) │ урок 1
└────────────────────────────────────────────────┘
Сегодня закрываем четыре слоя. Заметь, чего тут пока нет: транзакций, WAL, блокировок, MVCC. Движок урока 6 однопользовательский и без долговечности: одна команда за раз, каждая вставка сразу ложится в страницы буферного пула. Поля xmin/xmax в кортеже из урока 3 мы заполняем фиксированным ENGINE_XID и игнорируем, как и в TS- и Effect-треке. Сначала пусть движок научится отвечать на запросы, а транзакции, журнал и параллелизм навесим в уроках 7, 8 и 9 поверх готового исполнителя.
Конвейер запроса
строка SQL
│ parse_command (урок 5 + EXPLAIN), Result<Command>
▼
AST (Command)
│ bind_select / bind_insert / ... : имена ▶ индексы, проверка типов
▼
LogicalPlan (что делаем)
│ plan_select / plan_dml: выбор метода доступа
▼
Box<dyn Operator + 'a> (как делаем)
│ while let Some(row) = operator.next()? (исполнитель)
▼
ExecResult
Три фазы и две формы плана. Логический план говорит, что делаем. Физический план это дерево операторов, корень которого исполнитель дёргает за next(), пока тот не скажет None. Под всем этим лежит каталог: он знает таблицы, их колонки и файлы.
Если ты шёл по TS-треку, конвейер тот же. Разница в том, как фазы общаются с ошибками и временем жизни данных, и с ошибок мы начнём.
Ошибки движка: тот же Result
В TS-треке binder и вычислитель бросали EngineError. В Effect-треке они тоже бросали, а граница engineTry ловила бросок в канал ошибок. В Rust никакой границы нет: ошибка это просто значение Err, которое ? поднимает на уровень выше. Ни один слой не может её «уронить» мимо сигнатуры, потому что Result в сигнатуре и есть канал ошибок. Если ты помнишь урок про обработку ошибок, здесь ровно та же механика, только на масштабе целого движка.
Варианты ошибок у нас уже есть: ourdb::Error завели в уроке 1 и с тех пор расширяли. Движку нужны два:
// src/error.rs (фрагмент)
#[derive(Debug)]
pub enum Error {
/// Ошибка ввода-вывода (диск, сокет).
Io(io::Error),
/// Ошибка разбора SQL: сообщение уже с позицией и стрелкой, offset в символах.
Sql { message: String, offset: usize },
/// Ошибка анализа, планирования или исполнения.
Engine(String),
/// Нарушение уникальности PRIMARY KEY.
Uniqueness(String),
// ... Serialization, LockWait, Deadlock появятся в уроках 8 и 9
/// Нарушен формат байтов или контракт API (RangeError/TypeError эталона).
Invalid(String),
}
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn engine(message: impl Into<String>) -> Error {
Error::Engine(message.into())
}
Error::Engine несёт человеческое сообщение: «неизвестная таблица», «не тот тип», «деление на ноль». Error::Uniqueness отдельный вариант, потому что тестам и, позже, сетевому протоколу важно отличить нарушение PRIMARY KEY от прочих ошибок (в уроке 11 они получат разные SQLSTATE-коды через Error::sqlstate()). Помощник engine принимает impl Into<String>, поэтому годится и &'static str, и String из format!: engine("division by zero") и engine(format!("неизвестная колонка \"{name}\"")) читаются одинаково.
Заметь ещё одно отличие от Effect. Там ошибки нижних слоёв (IoError, BufferPoolError) и ошибки движка жили в объединении типов, и подпись оператора перечисляла их все. Здесь один enum на весь крейт, и From<io::Error> for Error из урока 1 заворачивает ошибку диска в Error::Io прямо на ?. Подпись короче, цена в том, что тип не говорит, какие варианты реально возможны в данной функции. Для учебного движка это честный размен.
Связанное представление
Опишем выход binder-а: типы связанного плана. Это зеркало AST из урока 5, но колонки уже резолвлены в индексы, и каждое выражение несёт тип. Положи в src/engine/binder.rs, в начало файла.
// src/engine/binder.rs
use std::fmt;
use crate::engine::catalog::{Catalog, TableHandle};
use crate::error::{engine, Result};
use crate::sql::{print_expr, BinaryOp, Delete, Expr, Insert, Literal, Select, SelectItem, Update};
use crate::tuple::{ColumnType, Schema, Value};
/// Тип выражения. Null это литерал NULL, совместимый с любой колонкой.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BoundType {
Column(ColumnType),
Null,
}
impl fmt::Display for BoundType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BoundType::Column(ty) => f.write_str(ty.name()),
BoundType::Null => f.write_str("null"),
}
}
}
const INT: BoundType = BoundType::Column(ColumnType::Int);
const TEXT: BoundType = BoundType::Column(ColumnType::Text);
const BOOL: BoundType = BoundType::Column(ColumnType::Bool);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BoundExpr {
Column {
index: usize,
ty: ColumnType,
},
Literal {
value: Value,
ty: BoundType,
},
Binary {
op: BinaryOp,
left: Box<BoundExpr>,
right: Box<BoundExpr>,
ty: BoundType,
},
Not(Box<BoundExpr>),
}
impl BoundExpr {
pub fn ty(&self) -> BoundType {
match self {
BoundExpr::Column { ty, .. } => BoundType::Column(*ty),
BoundExpr::Literal { ty, .. } | BoundExpr::Binary { ty, .. } => *ty,
BoundExpr::Not(_) => BOOL,
}
}
pub fn literal(value: Value, ty: BoundType) -> BoundExpr {
BoundExpr::Literal { value, ty }
}
}
Разберём, что тут от Rust.
BoundType это не строковый литерал 'int' | 'null', как в TS, а enum с вложенным ColumnType из урока 3. Он Copy: два байта, которые дешевле копировать, чем заимствовать. Display нужен ради сообщений об ошибках: format!("{context} требует {ty}, получено {actual}") печатает int или null без ручного match в каждом месте.
Три константы INT, TEXT, BOOL это просто сокращения. Так как BoundType реализует PartialEq, их можно сравнивать через == (ty != BOOL), а match по ним не нужен.
BoundExpr это рекурсивный тип, и рекурсия идёт через Box<BoundExpr>. Без Box размер BoundExpr был бы бесконечным: вариант Binary содержал бы два BoundExpr, каждый из которых может быть Binary. Box кладёт ребёнка в кучу и оставляет в узле указатель фиксированного размера. Тот же приём ты видел в AST урока 5, и это прямое продолжение урока про умные указатели.
#[derive(Clone, Debug, PartialEq, Eq)] на всём связанном представлении это подарок тестам: assert_eq! сравнивает целые деревья, и при расхождении Debug печатает оба. В TS для такого сравнения нужен был toEqual с глубоким обходом, здесь его пишет компилятор.
Дальше выход проекции, присваивание и сам логический план.
/// Выходная колонка проекции: выражение и имя в результате.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoundOutput {
pub expr: BoundExpr,
pub name: String,
}
/// Присваивание UPDATE: индекс колонки и новое значение.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoundAssignment {
pub index: usize,
pub value: BoundExpr,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogicalScan {
pub table: String,
pub filter: Option<BoundExpr>,
}
/// Намерение запроса после анализа; планировщик переводит его в операторы Volcano.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogicalPlan {
Project {
source: LogicalScan,
outputs: Vec<BoundOutput>,
},
Insert {
table: String,
values: Vec<Value>,
},
Delete(LogicalScan),
Update {
scan: LogicalScan,
assignments: Vec<BoundAssignment>,
},
}
LogicalScan вынесен в отдельную структуру, и это решение аккуратнее TS-версии. Там project ссылался на Extract<LogicalPlan, { kind: 'scan' }>, то есть на вариант того же объединения. Здесь скан это самостоятельный тип, который входит в три варианта плана (Project, Delete, Update), и планировщик строит скан одной функцией build_scan(&LogicalScan, ...), не разбирая план заново. А Insert скана не имеет вовсе: в нём уже готовые значения, собранные binder-ом.
Binder
Binder превращает AST в LogicalPlan: резолвит таблицу и имена колонок в индексы по каталогу, проверяет типы, разворачивает *, проверяет арность и NOT NULL у INSERT. JOIN, ORDER BY и LIMIT парсятся, но binder отвергает их как упражнения. Каталог мы напишем ниже, пока binder-у от него нужен один метод get(name) -> Option<&TableHandle>, а от TableHandle поля schema и required.
SELECT
pub fn bind_select(statement: &Select, catalog: &Catalog) -> Result<LogicalPlan> {
if statement.join.is_some() {
return Err(engine("JOIN это упражнение урока 6"));
}
if statement.order_by.is_some() {
return Err(engine("ORDER BY это упражнение урока 6"));
}
if statement.limit.is_some() {
return Err(engine("LIMIT это упражнение урока 6"));
}
let schema = &require_table(catalog, &statement.from)?.schema;
let filter = bind_filter(statement.where_.as_ref(), schema, &statement.from)?;
let outputs = if let [SelectItem::Star] = statement.items.as_slice() {
all_columns(schema)
} else {
statement
.items
.iter()
.map(|item| bind_output(item, schema, &statement.from))
.collect::<Result<_>>()?
};
Ok(LogicalPlan::Project {
source: LogicalScan {
table: statement.from.clone(),
filter,
},
outputs,
})
}
Две идиомы стоит запомнить, они встретятся в binder-е ещё не раз.
Первая: if let [SelectItem::Star] = statement.items.as_slice(). Это образец среза: он совпадает, только если в списке ровно один элемент и это Star. SELECT * разворачивается во все колонки, а SELECT *, id до этой ветки не дойдёт и упадёт в bind_output с понятной ошибкой. В TS тут была проверка items.length === 1 && items[0].kind === 'star', здесь форма и проверка слиты в одну строку.
Вторая: .map(...).collect::<Result<_>>()?. Каждый bind_output возвращает Result<BoundOutput>, а нам нужен Vec<BoundOutput> или первая ошибка. collect умеет собирать итератор Result<T, E> в Result<Vec<T>, E>: он останавливается на первой ошибке и отдаёт её. Подчёркивание в Result<_> просит компилятор вывести Vec<BoundOutput> из типа поля outputs. Это ровно то, что в уроке про итераторы называлось «FromIterator для Result».
Обрати внимание и на statement.where_.as_ref(). Поле where_: Option<Expr> (подчёркивание, потому что where ключевое слово Rust), а функции нужно Option<&Expr>: заимствовать условие, не забирая его из AST. as_ref превращает &Option<T> в Option<&T> без копии.
INSERT
pub fn bind_insert(statement: &Insert, catalog: &Catalog) -> Result<LogicalPlan> {
let handle = require_table(catalog, &statement.table)?;
let schema = &handle.schema;
let targets: Vec<usize> = match &statement.columns {
Some(names) => names
.iter()
.map(|name| resolve_column(name, schema))
.collect::<Result<_>>()?,
None => (0..schema.len()).collect(),
};
if statement.values.len() != targets.len() {
return Err(engine(format!(
"INSERT ожидает {} значений, получил {}",
targets.len(),
statement.values.len()
)));
}
let mut values = vec![Value::Null; schema.len()];
let mut provided = vec![false; schema.len()];
for (&column_index, literal) in targets.iter().zip(&statement.values) {
let column = &schema.columns()[column_index];
let required = handle.required.contains(&column_index);
check_assignable(&column.name, column.ty, required, literal_type(literal))?;
values[column_index] = literal_value(literal);
provided[column_index] = true;
}
for (index, column) in schema.columns().iter().enumerate() {
if !provided[index] && handle.required.contains(&index) {
return Err(engine(format!(
"колонка \"{}\" не может быть NULL",
column.name
)));
}
}
Ok(LogicalPlan::Insert {
table: statement.table.clone(),
values,
})
}
INSERT INTO t (id) VALUES (2) перечисляет не все колонки. Binder сначала строит targets: номера колонок, в которые пишем. Без списка колонок это 0..schema.len(), диапазон тоже итератор, и collect превращает его в Vec<usize>. Потом заводит строку из одних NULL (vec![Value::Null; schema.len()] клонирует значение нужное число раз) и раскладывает литералы по местам. Колонки, которые не упомянуты, остаются NULL, и второй цикл проверяет, что среди них нет обязательных.
targets.iter().zip(&statement.values) идёт двумя итераторами в ногу. Образец (&column_index, literal) сразу снимает ссылку с usize (он Copy), а литерал оставляет ссылкой, потому что Literal::String владеет строкой и копировать её незачем.
UPDATE и DELETE
pub fn bind_update(statement: &Update, catalog: &Catalog) -> Result<LogicalPlan> {
let handle = require_table(catalog, &statement.table)?;
let schema = &handle.schema;
let assignments = statement
.assignments
.iter()
.map(|assignment| {
let index = resolve_column(&assignment.column, schema)?;
let column = &schema.columns()[index];
let value = bind_expr(&assignment.value, schema, &statement.table)?;
let required = handle.required.contains(&index);
check_assignable(&column.name, column.ty, required, value.ty())?;
Ok(BoundAssignment { index, value })
})
.collect::<Result<_>>()?;
let filter = bind_filter(statement.where_.as_ref(), schema, &statement.table)?;
Ok(LogicalPlan::Update {
scan: LogicalScan {
table: statement.table.clone(),
filter,
},
assignments,
})
}
pub fn bind_delete(statement: &Delete, catalog: &Catalog) -> Result<LogicalPlan> {
let schema = &require_table(catalog, &statement.table)?.schema;
let filter = bind_filter(statement.where_.as_ref(), schema, &statement.table)?;
Ok(LogicalPlan::Delete(LogicalScan {
table: statement.table.clone(),
filter,
}))
}
В bind_update замыкание внутри map само возвращает Result, поэтому ? внутри него работает: ошибка выходит из замыкания, а не из всей функции, и collect::<Result<_>>() подхватывает её. Правая часть присваивания это полноценное выражение (SET id = id + 1000), поэтому она проходит bind_expr, а не превращается в литерал, как у INSERT.
Выражения
Сердце binder-а: рекурсивный обход выражения.
/// Связать выражение по схеме таблицы `table` (квалификатор колонки сверяется с ней).
pub fn bind_expr(expr: &Expr, schema: &Schema, table: &str) -> Result<BoundExpr> {
match expr {
Expr::Literal(literal) => Ok(BoundExpr::literal(
literal_value(literal),
literal_type(literal),
)),
Expr::Column {
table: qualifier,
name,
} => {
if let Some(qualifier) = qualifier.as_deref().filter(|&q| q != table) {
return Err(engine(format!(
"неизвестная таблица \"{qualifier}\" в ссылке \"{qualifier}.{name}\""
)));
}
let index = resolve_column(name, schema)?;
Ok(BoundExpr::Column {
index,
ty: schema.columns()[index].ty,
})
}
Expr::Not(operand) => {
let operand = bind_expr(operand, schema, table)?;
require_type(&operand, BOOL, "NOT")?;
Ok(BoundExpr::Not(Box::new(operand)))
}
Expr::Binary { op, left, right } => bind_binary(
*op,
bind_expr(left, schema, table)?,
bind_expr(right, schema, table)?,
),
}
}
users.name в SQL приходит из парсера как Expr::Column { table: Some("users"), name: "name" }. Строчка с as_deref().filter(...) делает две вещи сразу. as_deref превращает &Option<String> в Option<&str>, а filter оставляет квалификатор, только если он не совпадает с таблицей запроса. Если результат Some, это чужая таблица, и мы отвечаем ошибкой. SELECT users.name FROM users проходит, SELECT wrong.name FROM users падает с неизвестная таблица "wrong".
match здесь исчерпывающий. Если в уроке 5 ты добавишь в Expr новый вариант, например IsNull, компилятор откажется собирать binder, пока ты его не обработаешь. TS-эталон для такой гарантии брал библиотеку ts-pattern, Effect-порт полагался на switch с проверкой never. Здесь гарантия встроена в язык.
fn bind_binary(op: BinaryOp, left: BoundExpr, right: BoundExpr) -> Result<BoundExpr> {
let context = op.as_str();
let ty = match op {
BinaryOp::And | BinaryOp::Or => {
require_type(&left, BOOL, context)?;
require_type(&right, BOOL, context)?;
BOOL
}
BinaryOp::Like => {
require_type(&left, TEXT, context)?;
require_type(&right, TEXT, context)?;
BOOL
}
BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
let (l, r) = (left.ty(), right.ty());
if l != BoundType::Null && r != BoundType::Null && l != r {
return Err(engine(format!(
"{context} требует операнды одного типа, получено {l} и {r}"
)));
}
BOOL
}
BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
require_type(&left, INT, context)?;
require_type(&right, INT, context)?;
INT
}
};
Ok(BoundExpr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
ty,
})
}
Правила типов в одной таблице. AND/OR хотят булевы операнды, LIKE текстовые, сравнения одинаковый базовый тип, арифметика только int. Во всех случаях литерал NULL подходит к чему угодно: id = NULL законно связывается (а при исполнении даст NULL, к этому вернёмся в вычислителе). bind_binary забирает left и right по значению и перекладывает их в Box без копии: детей только что построил рекурсивный bind_expr, больше они никому не нужны.
Фильтр, проекция и помощники
fn bind_filter(where_: Option<&Expr>, schema: &Schema, table: &str) -> Result<Option<BoundExpr>> {
let Some(where_) = where_ else {
return Ok(None);
};
let filter = bind_expr(where_, schema, table)?;
let ty = filter.ty();
if ty != BOOL && ty != BoundType::Null {
return Err(engine(format!(
"условие WHERE должно быть булевым, получено {ty}"
)));
}
Ok(Some(filter))
}
fn bind_output(item: &SelectItem, schema: &Schema, table: &str) -> Result<BoundOutput> {
let SelectItem::Expr { expr, alias } = item else {
return Err(engine("звёздочка не сочетается с другими колонками"));
};
let name = match (alias, expr) {
(Some(alias), _) => alias.clone(),
(None, Expr::Column { name, .. }) => name.clone(),
(None, expr) => print_expr(expr),
};
Ok(BoundOutput {
expr: bind_expr(expr, schema, table)?,
name,
})
}
fn all_columns(schema: &Schema) -> Vec<BoundOutput> {
schema
.columns()
.iter()
.enumerate()
.map(|(index, column)| BoundOutput {
expr: BoundExpr::Column {
index,
ty: column.ty,
},
name: column.name.clone(),
})
.collect()
}
let Some(where_) = where_ else { return Ok(None); }; это let-else: разобрать значение или выйти. Без него тело функции съехало бы на уровень вправо внутрь if let. Тот же приём в bind_output: если элемент списка это Star, а мы пришли сюда, значит * стоит рядом с другими колонками.
Имя выходной колонки выбирается match по кортежу из двух образцов. Алиас побеждает всегда, голая колонка даёт своё имя, а любое другое выражение печатается обратно в SQL принтером из урока 5: SELECT age + 1 FROM users вернёт колонку с именем (age + 1). Принтер пригодился второй раз, ради этого его и писали.
И мелкие помощники, на которые опирается всё выше.
fn require_table<'c>(catalog: &'c Catalog, table: &str) -> Result<&'c TableHandle> {
catalog
.get(table)
.ok_or_else(|| engine(format!("неизвестная таблица \"{table}\"")))
}
fn resolve_column(name: &str, schema: &Schema) -> Result<usize> {
schema
.columns()
.iter()
.position(|column| column.name == name)
.ok_or_else(|| engine(format!("неизвестная колонка \"{name}\"")))
}
fn require_type(expr: &BoundExpr, ty: BoundType, context: &str) -> Result<()> {
let actual = expr.ty();
if actual != ty && actual != BoundType::Null {
return Err(engine(format!("{context} требует {ty}, получено {actual}")));
}
Ok(())
}
fn check_assignable(
name: &str,
column: ColumnType,
required: bool,
value: BoundType,
) -> Result<()> {
match value {
BoundType::Null if required => {
Err(engine(format!("колонка \"{name}\" не может быть NULL")))
}
BoundType::Null => Ok(()),
BoundType::Column(ty) if ty != column => Err(engine(format!(
"колонка \"{name}\" ожидает {column}, получено {ty}"
))),
BoundType::Column(_) => Ok(()),
}
}
fn literal_value(literal: &Literal) -> Value {
match literal {
Literal::Number(number) => Value::Int(*number),
Literal::String(text) => Value::Text(text.clone()),
Literal::Bool(flag) => Value::Bool(*flag),
Literal::Null => Value::Null,
}
}
fn literal_type(literal: &Literal) -> BoundType {
match literal {
Literal::Number(_) => INT,
Literal::String(_) => TEXT,
Literal::Bool(_) => BOOL,
Literal::Null => BoundType::Null,
}
}
В require_table впервые в этом уроке появляется явное время жизни 'c. Сигнатура говорит: возвращённая ссылка на TableHandle живёт столько же, сколько заимствование каталога, и не зависит от строки table. Без аннотации компилятор не смог бы выбрать между двумя входными ссылками и потребовал бы её. Этот же приём, «результат заимствует из одного аргумента, а не из другого», встретится в планировщике на каждом шагу. Подробно правила вывода времён жизни разобраны в уроке про лайфтаймы.
check_assignable это match со охранными условиями: BoundType::Null if required ловит NULL в обязательную колонку раньше, чем общий BoundType::Null. Порядок веток тут значим, и компилятор проверит, что все случаи покрыты.
ok_or_else вместо ok_or: сообщение с format! строится только если колонки нет. На горячем пути, где колонка находится сразу, мы не платим за аллокацию строки.
Вычислитель выражений
Binder дал дерево с типами. Вычислитель берёт его и кортеж и отдаёт Value. Положи в src/engine/eval.rs.
// src/engine/eval.rs
use std::cmp::Ordering;
use crate::engine::binder::BoundExpr;
use crate::error::{engine, Result};
use crate::sql::BinaryOp;
use crate::tuple::{Tuple, Value};
pub fn eval_expr(expr: &BoundExpr, tuple: &Tuple) -> Result<Value> {
match expr {
BoundExpr::Column { index, .. } => {
Ok(tuple.values.get(*index).cloned().unwrap_or(Value::Null))
}
BoundExpr::Literal { value, .. } => Ok(value.clone()),
BoundExpr::Not(operand) => Ok(match eval_expr(operand, tuple)? {
Value::Null => Value::Null,
Value::Bool(flag) => Value::Bool(!flag),
other => return Err(engine(format!("NOT требует bool, получено {other:?}"))),
}),
BoundExpr::Binary {
op, left, right, ..
} => eval_binary(*op, eval_expr(left, tuple)?, eval_expr(right, tuple)?),
}
}
/// Проходит ли строка фильтр: только строго TRUE (NULL и FALSE отсекаются).
pub fn passes_filter(value: &Value) -> bool {
*value == Value::Bool(true)
}
Value из урока 3 это enum из четырёх вариантов: Null, Int(i64), Text(String), Bool(bool). NULL здесь не отдельный «отсутствующий» объект, а полноправный вариант, и match обязан про него помнить.
tuple.values.get(*index).cloned().unwrap_or(Value::Null): binder гарантирует, что индекс в границах схемы, но вычислитель всё равно не индексирует через [], а берёт get, получая Option<&Value>. cloned делает из него Option<Value>. Клонировать приходится, потому что результат вычисления это новое значение, а кортеж остаётся у вызывающего. Для Int и Bool клон бесплатен, для Text это копия строки.
passes_filter пропускает только строго TRUE. И FALSE, и NULL строку отсекают. Отсюда классика SQL: WHERE name = 'alice' не вернёт строку, где name это NULL, и WHERE name <> 'alice' тоже не вернёт.
Трёхзначная логика и checked-арифметика
fn eval_binary(op: BinaryOp, left: Value, right: Value) -> Result<Value> {
use Value::{Bool, Int, Null, Text};
Ok(match (op, left, right) {
(BinaryOp::And, l, r) => match (l, r) {
(Bool(false), _) | (_, Bool(false)) => Bool(false),
(Null, _) | (_, Null) => Null,
_ => Bool(true),
},
(BinaryOp::Or, l, r) => match (l, r) {
(Bool(true), _) | (_, Bool(true)) => Bool(true),
(Null, _) | (_, Null) => Null,
_ => Bool(false),
},
// остальные операторы пропагируют NULL
(_, Null, _) | (_, _, Null) => Null,
(BinaryOp::Like, Text(text), Text(pattern)) => Bool(like_match(&text, &pattern)),
(BinaryOp::Add, Int(l), Int(r)) => Int(l.checked_add(r).ok_or_else(overflow)?),
(BinaryOp::Sub, Int(l), Int(r)) => Int(l.checked_sub(r).ok_or_else(overflow)?),
(BinaryOp::Mul, Int(l), Int(r)) => Int(l.checked_mul(r).ok_or_else(overflow)?),
(BinaryOp::Div, Int(_), Int(0)) => return Err(engine("division by zero")),
(BinaryOp::Div, Int(l), Int(r)) => Int(l.checked_div(r).ok_or_else(overflow)?),
(op, l, r) => {
let ordering = compare_values(&l, &r)?;
Bool(match op {
BinaryOp::Eq => ordering == Ordering::Equal,
BinaryOp::Ne => ordering != Ordering::Equal,
BinaryOp::Lt => ordering == Ordering::Less,
BinaryOp::Le => ordering != Ordering::Greater,
BinaryOp::Gt => ordering == Ordering::Greater,
BinaryOp::Ge => ordering != Ordering::Less,
_ => {
return Err(engine(format!(
"{} не применим к {l:?} и {r:?}",
op.as_str()
)))
}
})
}
})
}
fn overflow() -> crate::error::Error {
engine("integer overflow")
}
Это, пожалуй, самая красивая функция урока, и вся её красота в одном match по тройке (оператор, левое, правое). Ветки читаются как таблица истинности.
AND и OR идут первыми, потому что они не пропагируют NULL слепо. Это трёхзначная логика Клини: FALSE AND NULL это FALSE (неважно, что там неизвестно, ложь уже решила исход), а TRUE AND NULL это NULL. Для OR зеркально. Образец (Bool(false), _) | (_, Bool(false)) через | ловит ложь с любой стороны одной веткой. Если мысленно заменить Value::Bool и Value::Null на Option<bool>, получится ровно таблица Some(false), None, Some(true).
Строка (_, Null, _) | (_, _, Null) => Null закрывает все остальные операторы разом: сравнение с NULL, арифметика с NULL, LIKE с NULL дают NULL. Порядок веток гарантирует, что AND/OR до неё не дойдут.
Арифметика это то место, где Rust честнее и TS, и JS. В эталоне на TS числа колонок были bigint, а ему переполнение неведомо: bigint растёт без предела, и 9223372036854775807 + 1 там просто даёт число за границей int64, которое потом не влезет в восемь байт кортежа. В Rust i64 фиксирован, и у него есть три поведения на переполнение. Обычный + в debug-сборке паникует, в release тихо переносится через край, и i64::MAX + 1 становится i64::MIN. Ни то, ни другое движку не подходит: паника роняет сервер из-за одного запроса, а перенос молча врёт. Поэтому мы берём checked_add, checked_sub, checked_mul, checked_div, которые возвращают Option<i64>: None на переполнении. ok_or_else(overflow)? превращает None в Error::Engine("integer overflow"). Почему перенос вообще возможен и как устроен дополнительный код, разобрано в уроке про целые числа.
С делением два отдельных случая. Деление на ноль ловится явной веткой (BinaryOp::Div, Int(_), Int(0)) раньше общей, иначе checked_div тоже вернул бы None, и пользователь увидел бы «integer overflow» вместо «division by zero». А вот i64::MIN / -1 это настоящее переполнение: результат 2^63 не влезает в i64, и checked_div честно отвечает None. Обычный / тут запаниковал бы даже в release. Само целочисленное деление в Rust усекает к нулю: -7 / 2 это -3, а не -4, ровно как требует SQL и как вёл себя bigint в эталоне.
Последняя ветка ловит все сравнения. compare_values даёт Ordering, и шесть операторов выражаются через него без дублирования. Ветка _ => внутри недостижима после binder-а, но компилятор не знает, что binder уже всё проверил, поэтому мы честно возвращаем ошибку, а не unreachable!(): если когда-нибудь binder ошибётся, движок ответит ошибкой, а не упадёт.
/// Сравнить два ненулевых значения одного типа.
fn compare_values(left: &Value, right: &Value) -> Result<Ordering> {
match (left, right) {
(Value::Int(l), Value::Int(r)) => Ok(l.cmp(r)),
(Value::Text(l), Value::Text(r)) => Ok(l.cmp(r)),
(Value::Bool(l), Value::Bool(r)) => Ok(l.cmp(r)),
_ => Err(engine("cannot compare values of different types")),
}
}
Value не реализует Ord, и это сознательно: сравнивать Int с Text бессмысленно, а derive(Ord) упорядочил бы варианты по порядку объявления и молча разрешил бы 1 < 'a'. Поэтому сравнение явное и падает на разных типах.
LIKE
/// LIKE: `%` это любая последовательность символов, `_` ровно один символ.
fn like_match(text: &str, pattern: &str) -> bool {
let text: Vec<char> = text.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
// классический жадный матчер с откатом к последнему `%`
let (mut t, mut p) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while t < text.len() {
match pattern.get(p) {
Some('%') => {
star = Some((p, t));
p += 1;
}
Some(&c) if c == '_' || c == text[t] => {
t += 1;
p += 1;
}
_ => match star {
Some((star_p, star_t)) => {
p = star_p + 1;
t = star_t + 1;
star = Some((star_p, star_t + 1));
}
None => return false,
},
}
}
pattern[p..].iter().all(|&c| c == '%')
}
TS-эталон переводил шаблон LIKE в регулярное выражение. В Rust без крейтов регулярок нет, и это к лучшему: матчер на двух указателях проще, чем экранирование всех спецсимволов регулярки (тест a.c против abc проверяет как раз то, что точка тут обычный символ). Матчер жадный: на % он запоминает позицию в star, а при несовпадении откатывается туда и пробует съесть на один символ текста больше.
Обрати внимание на первые две строки. str в Rust это UTF-8, и индекс text[t] по строке дал бы байт, а не символ. Шаблон _ж% против ёжик должен совпасть: _ это одна буква ё, хотя в UTF-8 она занимает два байта. Поэтому оба аргумента сначала разворачиваются в Vec<char>, где каждый элемент это один скалярный символ Unicode. Тот же выбор лексер урока 5 сделал для смещений в сообщениях об ошибках.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.