Раздел 10 · Databases

Своя СУБД на Go · урок 5: свой SQL, лексер и парсер

senior~170 мин

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

Своя СУБД на Go · урок 5: свой SQL, лексер и парсер

Кому это. К этому уроку у тебя собрана вся нижняя половина движка на Go. В уроке 1 появились page.Page и disk.Manager, в уроке 2 над диском встал буферный пул под мьютексом, в уроке 3 выросла куча, в уроке 4 к ней добавился B+Tree. Получилось индексированное хранилище ключей. Но звать h.Insert и tree.Search руками это ещё не СУБД. СУБД понимает язык.

Зачем это на Go. Сегодня начинается верхняя половина движка: обработка запросов. Между строкой SELECT name FROM users WHERE id = 42 и исполнением стоит фронтенд: лексер режет строку на токены, парсер собирает дерево. После четырёх уроков с байтами, пинами и мьютексами этот слой выглядит отпуском: ни диска, ни горутин, только строки и деревья. Зато здесь Go показывает три приёма, которых в нижних слоях не было. Закрытое множество вариантов узла (в Go нет размеченных объединений, их собирают из интерфейса). Разница между байтом и символом в строке. И паника как законный ранний выход из глубокой рекурсии, которая наружу всё равно выходит обычным error.

Важно про код. Как и прежде, ты пишешь реализацию с нуля, листинги приведены целиком, а где сокращены, это сказано прямо. Весь урок живёт в одном пакете ourdb/sql: файлы token.go, errors.go, lexer.go, ast.go, parser.go, printer.go и тест lesson05_sql_test.go. Пакет называется sql, как и стандартный database/sql, но конфликта нет: импортируем мы его по полному пути "ourdb/sql", а если когда-нибудь понадобятся оба, один получит псевдоним. Сторонних модулей по-прежнему ноль, только стандартная библиотека Go 1.23.

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

┌────────────────────────────────────────────────┐
│  Бинарный протокол + TCP-сервер                  │  урок 11
├────────────────────────────────────────────────┤
│  Исполнитель (Volcano) + EXPLAIN                 │  урок 6
│  Планировщик, binder (семантический анализ)      │  урок 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
│  disk.Manager (страницы поверх файла)             │  урок 1
└────────────────────────────────────────────────┘

Заметь важное: фронтенд не трогает нижние слои совсем. Пакет sql не импортирует ни heap, ни btree, ни bufferpool. Единственная связь с хранилищем это тип колонки tuple.ColumnType: фронтенд и куча должны одинаково понимать, что такое int, text и bool. Связать дерево с реальными таблицами это работа binder в уроке 6. Поэтому весь сегодняшний код это чистые функции: вход строка или дерево, выход дерево или строка, плюс error, если не сложилось.

Граф импортов Go это проверяет за тебя. Если однажды рука потянется вызвать из парсера каталог таблиц, компилятор напомнит про направление зависимостей: engine импортирует sql, а обратная стрелка дала бы цикл импортов, которого Go не терпит.

Что мы парсим

Парсер начинается не с кода, а с грамматики. Мы берём небольшое подмножество SQL, которого хватает на весь воркшоп. Записывается грамматика в EBNF, нотации, которая один в один ложится на парсер рекурсивного спуска. Грамматика общая для всех портов ourdb, она та же, что в TS-треке.

statement   = createTable | insert | select | update | delete ;

createTable = "CREATE" "TABLE" ident "(" columnDef { "," columnDef } ")" ;
columnDef   = ident type [ "PRIMARY" "KEY" | "NOT" "NULL" ] ;
type        = "INT" | "TEXT" | "BOOL" ;

insert      = "INSERT" "INTO" ident [ "(" ident { "," ident } ")" ]
              "VALUES" "(" literal { "," literal } ")" ;

select      = "SELECT" ( "*" | projection )
              "FROM" ident [ join ]
              [ "WHERE" expr ]
              [ "ORDER" "BY" ident [ "ASC" | "DESC" ] ]
              [ "LIMIT" number ] ;
projection  = expr [ "AS" ident ] { "," expr [ "AS" ident ] } ;
join        = ( "INNER" | "LEFT" ) "JOIN" ident "ON" expr ;

update      = "UPDATE" ident "SET" assignment { "," assignment } [ "WHERE" expr ] ;
delete      = "DELETE" "FROM" ident [ "WHERE" expr ] ;
assignment  = ident "=" expr ;

expr        = orExpr ;
orExpr      = andExpr { "OR" andExpr } ;
andExpr     = cmpExpr { "AND" cmpExpr } ;
cmpExpr     = addExpr [ ( "=" | "<>" | "<" | "<=" | ">" | ">=" | "LIKE" ) addExpr ] ;
addExpr     = mulExpr { ( "+" | "-" ) mulExpr } ;
mulExpr     = primary { ( "*" | "/" ) primary } ;
primary     = literal | column | "(" expr ")" | "NOT" primary ;
column      = [ ident "." ] ident ;
literal     = number | string | "TRUE" | "FALSE" | "NULL" ;

Первые шесть правил это операторы, каждый начинается с ключевого слова: CREATE, INSERT, SELECT, UPDATE, DELETE. Это и есть план парсера: посмотреть на первый токен и вызвать нужную функцию. Вторая половина, правила выражений от orExpr до primary, кодирует приоритеты операторов. Писать шесть почти одинаковых функций скучно, поэтому выражения мы разберём одной функцией с таблицей приоритетов, это Pratt-парсер. Если рекурсивный спуск тебе в новинку, сначала загляни в основы парсеров: там та же идея на игрушечной грамматике.

Транзакционные команды (BEGIN, COMMIT, ROLLBACK) и EXPLAIN это отдельные верхнеуровневые формы. Они появятся, когда им будет что обслуживать: EXPLAIN в уроке 6 вместе с планом, транзакции в уроках 7 и 8. Ключевые слова для них лексер узнаёт уже сегодня (так проще не переделывать таблицу потом), но Parse пока разбирает пять операторов работы с данными и отдаёт ровно один Statement.

Граница Go: паника внутри, error снаружи

Прежде чем писать код, договоримся, как этот слой обходится с ошибками. В нижних уроках правило было простым: каждая функция, которая может не сложиться, возвращает последним значением error, а вызывающий проверяет его сразу. disk.Manager.ReadPage, pool.FetchPage, h.Insert все так устроены. Почему бы и парсеру не жить по тому же правилу?

Посмотри, как устроен рекурсивный спуск. selectStmt зовёт expr, expr зовёт primary, primary в скобках снова зовёт expr. Сбой может случиться на любой глубине: primary глубоко внутри expr внутри selectStmt вдруг видит FROM там, где ждал выражение. Если каждая функция возвращает (Expr, error), то каждая строка парсера обрастает проверкой: прочитал ключевое слово, проверил ошибку, прочитал имя, проверил ошибку, разобрал выражение, проверил ошибку. В selectStmt таких шагов больше десяти. Логика “читаем токен за токеном” тонет в обвязке, хотя на каждый if err != nil ответ один и тот же: бросить всё и вернуть ошибку наверх.

Go даёт для этого механизм, о котором в уроке про ошибки сказано “только для неустранимых ситуаций”: panic и recover. Правило верное для границ пакета, и у него есть известное исключение, описанное прямо в Effective Go: пакет может внутри себя бросать панику со своим типом ошибки как ранний выход из глубокой рекурсии, если на своей границе ловит её через recover и превращает в обычный error. Effective Go показывает это на парсере регулярных выражений, README порта сравнивает приём со стандартным go/parser. Мы делаем так же:

  • внутри пакета sql метод fail бросает panic(*Error), и паника пролетает сквозь все рекурсивные вызовы до самого верха;
  • на границе Parse запускает разбор через помощник catch, который в defer ловит панику, и если это наш *Error, кладёт его в возвращаемый error;
  • чужая паника (выход за срез, разыменование nil, то есть баг) пробрасывается дальше как есть: прятать баг под видом синтаксической ошибки нельзя.

Снаружи Tokenize, Parse и Print это обычные функции Go. Вызывающий видит (Statement, error) и никогда не узнает, что внутри была паника. Это та же прагматика, что изменяемый накопитель внутри чистой функции: локальный приём не портит контракт, потому что Parse(s) на одну строку всегда возвращает одно и то же.

Лексер, кстати, паникой не пользуется. Он плоский: один цикл без рекурсии, и ошибка в нём возникает в двух местах. Там обычный return nil, err честнее и короче.

Конвейер из трёх чистых функций

Весь урок это три функции в конвейере:

строка SQL ── Tokenize ──▶ []Token ── Parse ──▶ Statement ── Print ──▶ строка SQL
                  │                      │
                  └──── error ───────────┴──── (*sql.Error со смещением)

Tokenize режет строку на токены, Parse собирает из токенов дерево (и сам зовёт Tokenize), Print печатает дерево обратно в SQL. Третья функция нужна, чтобы проверять парсер (round-trip), а в уроке 6 ею печатаются имена вычисляемых колонок, в уроке 7 через неё DDL попадает в журнал. Поехали снизу вверх.

Токены

Токен это распознанная лексема с пометкой вида и позицией. Позиция (Offset) нужна не парсеру, а пользователю: по ней покажем, где сломался запрос.

// sql/token.go

// Package sql это фронтенд диалекта (урок 5): лексер, парсер рекурсивным спуском плюс Pratt
// для выражений, AST и pretty-printer (SPEC.md, раздел "Грамматика SQL").
package sql

// TokenType это вид токена.
type TokenType string

const (
	TokKeyword   TokenType = "keyword"
	TokIdent     TokenType = "ident"
	TokNumber    TokenType = "number"
	TokString    TokenType = "string"
	TokOperator  TokenType = "operator"
	TokLParen    TokenType = "lparen"
	TokRParen    TokenType = "rparen"
	TokComma     TokenType = "comma"
	TokDot       TokenType = "dot"
	TokSemicolon TokenType = "semicolon"
	TokEOF       TokenType = "eof"
)

// Token это лексема: для keyword в верхнем регистре, для string распознанное значение.
type Token struct {
	Type   TokenType
	Value  string
	Offset int
}

// Keywords это ключевые слова диалекта (сравнение без учёта регистра).
var Keywords = map[string]bool{
	"SELECT": true, "FROM": true, "WHERE": true, "INSERT": true, "INTO": true, "VALUES": true,
	"CREATE": true, "TABLE": true, "UPDATE": true, "SET": true, "DELETE": true, "EXPLAIN": true,
	"BEGIN": true, "COMMIT": true, "ROLLBACK": true, "ORDER": true, "BY": true, "ASC": true,
	"DESC": true, "LIMIT": true, "AS": true, "INNER": true, "LEFT": true, "JOIN": true, "ON": true,
	"AND": true, "OR": true, "NOT": true, "LIKE": true, "TRUE": true, "FALSE": true, "NULL": true,
	"INT": true, "TEXT": true, "BOOL": true, "PRIMARY": true, "KEY": true,
}

Здесь два решения, которые стоит проговорить.

Вид токена это строковый тип, а не iota. Классический Go-рефлекс для перечисления это type TokenType int и константы через iota. Но число в выводе теста ничего не говорит: want 3, got 7 придётся расшифровывать по исходнику. Строковый TokenType печатается сам собой, %v выдаст keyword, и тест может сравнить string(tok.Type) с "keyword" без таблицы перевода. Цена одна: сравнение строк дороже сравнения чисел, но лексер не горячий путь движка, запрос разбирается один раз. Отдельный именованный тип при этом сохраняет типобезопасность: функция, ждущая TokenType, не примет случайную строку из переменной без явного приведения.

Множество ключевых слов это map[string]bool. В Go нет встроенного типа множества, и его собирают из map двумя способами: map[string]struct{} (ноль байт на значение) или map[string]bool. Мы берём второй ради одной строки в лексере: Keywords[upper] для отсутствующего ключа вернёт нулевое значение false, поэтому проверка “это ключевое слово?” пишется одним выражением без _, ok :=. Три десятка лишних байтов на всю программу того стоят.

Остальное как в TS-треке. keyword хранится в верхнем регистре, поэтому select, Select и SELECT дают один токен. ident это имя таблицы или колонки, оно хранится как есть. number и string несут распознанное значение. eof это часовой конца потока: парсеру удобнее, когда поток всегда заканчивается явным токеном, и peek никогда не выходит за срез.

Ошибки с позицией: *sql.Error

Прежде чем писать лексер, заведём ему способ ругаться. Когда разбор спотыкается, недостаточно вернуть errors.New("syntax error"). Пользователь хочет видеть, где именно.

// sql/errors.go
package sql

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

// Error это ошибка разбора SQL. Offset это байтовое смещение в исходнике; Error() показывает
// строку, колонку (в символах) и стрелку под местом ошибки.
type Error struct {
	Msg    string
	Offset int
}

func (e *Error) Error() string { return e.Msg }

// newError собирает сообщение с позицией и стрелкой `^`.
func newError(source string, offset int, format string, args ...any) *Error {
	msg := fmt.Sprintf(format, args...)
	clamped := min(max(offset, 0), len(source))
	lineStart := strings.LastIndexByte(source[:clamped], '\n') + 1
	line := strings.Count(source[:clamped], "\n") + 1
	lineEnd := strings.IndexByte(source[lineStart:], '\n')
	if lineEnd < 0 {
		lineEnd = len(source)
	} else {
		lineEnd += lineStart
	}
	col := utf8.RuneCountInString(source[lineStart:clamped])
	rendered := fmt.Sprintf("%s (строка %d, колонка %d)\n%s\n%s^",
		msg, line, col+1, source[lineStart:lineEnd], strings.Repeat(" ", col))
	return &Error{Msg: rendered, Offset: offset}
}

Error это обычная структура с методом Error() string, а значит, она удовлетворяет встроенному интерфейсу error без всякого объявления. Метод висит на указателе *Error, поэтому ошибкой считается именно *Error, и тест будет доставать её через errors.As с переменной типа *sql.Error. Подробно этот приём разобран в уроке про кастомные ошибки.

Сообщение собирается один раз, в момент создания, и кладётся в Msg уже со стрелкой. Можно было бы хранить исходник и рисовать стрелку лениво в Error(), но тогда каждая ошибка тащила бы за собой весь текст запроса. Разбор падает редко, а сообщение нужно почти всегда, поэтому считаем сразу.

Логика позиции короткая, потому что пакет strings делает всю чёрную работу. strings.LastIndexByte(source[:clamped], '\n') + 1 находит начало текущей строки: последний перевод строки до места ошибки плюс один (а если перевода нет, -1 + 1 даёт ноль, начало текста). strings.Count считает переводы строки до ошибки, это номер строки. strings.IndexByte от начала строки ищет её конец. clamped страхует от смещения за пределами текста: встроенные min и max появились в Go 1.21, и с ними зажим в диапазон пишется одним выражением.

Байт или символ

Вот место, где Go заставляет подумать о том, о чём TS-трек молчит. Строка в Go это последовательность байтов в UTF-8, и len(s), s[i] и срезы s[a:b] работают с байтами. Буква В в UTF-8 занимает два байта, а @ один. Подробно это разобрано в уроке про строки, байты и руны.

Поэтому у ошибки две разные позиции:

  • Offset в байтах. Это индекс в source, по нему код режет строку: source[:clamped] и source[lineStart:lineEnd] обязаны получать байтовые индексы, иначе срез разрежет букву пополам.
  • Колонка в символах. Это то, что видит человек. utf8.RuneCountInString(source[lineStart:clamped]) считает руны от начала строки до ошибки, и ровно столько пробелов встаёт перед стрелкой.

Проверим на запросе с кириллицей в строковой константе:

неожиданный символ "@" (строка 1, колонка 37)
SELECT * FROM t WHERE name = 'Вася' @
                                    ^

Смещение @ в байтах равно 40: четыре буквы Вася дали восемь байтов вместо четырёх. А колонка 37, и стрелка стоит точно под @. Если бы мы рисовали стрелку по байтам, она уехала бы на четыре позиции вправо. У TS-порта и смещение, и колонка в единицах UTF-16, поэтому там такой развилки нет; README Go-порта отмечает это расхождение отдельно.

На обычном запросе сообщение выглядит так:

ожидалось выражение, встретилось ключевое слово FROM (строка 1, колонка 8)
SELECT FROM t
       ^

Видно сразу: после SELECT ждали выражение, а наткнулись на FROM. Эта мелочь отличает игрушку от инструмента, которым приятно пользоваться.

Лексер

Теперь сам лексер. Это один проход по строке с курсором pos. На каждом шаге смотрим текущий байт и решаем, какой токен начинается.

// sql/lexer.go
package sql

import (
	"strconv"
	"strings"
	"unicode/utf8"
)

var punctuation = map[byte]TokenType{'(': TokLParen, ')': TokRParen, ',': TokComma, '.': TokDot, ';': TokSemicolon}

// Tokenize режет строку SQL на плоский поток токенов. Строки в одинарных кавычках с `''`
// как экранированной кавычкой, числа это неотрицательные целые, `<=`, `>=`, `<>` ловятся
// заглядыванием на символ вперёд.
func Tokenize(source string) ([]Token, error) {
	var tokens []Token
	pos := 0
	for pos < len(source) {
		c := source[pos]
		start := pos
		switch {
		case c == ' ' || c == '\t' || c == '\n' || c == '\r':
			pos++
		case isDigit(c):
			for pos < len(source) && isDigit(source[pos]) {
				pos++
			}
			tokens = append(tokens, Token{TokNumber, source[start:pos], start})
		case c == '\'':
			var value strings.Builder
			for pos++; ; {
				if pos >= len(source) {
					return nil, newError(source, start, "незакрытая строковая константа")
				}
				if source[pos] == '\'' {
					if pos+1 < len(source) && source[pos+1] == '\'' {
						value.WriteByte('\'')
						pos += 2
						continue
					}
					pos++
					break
				}
				value.WriteByte(source[pos])
				pos++
			}
			tokens = append(tokens, Token{TokString, value.String(), start})
		case isIdentStart(c):
			for pos < len(source) && (isIdentStart(source[pos]) || isDigit(source[pos])) {
				pos++
			}
			word := source[start:pos]
			if upper := strings.ToUpper(word); Keywords[upper] {
				tokens = append(tokens, Token{TokKeyword, upper, start})
			} else {
				tokens = append(tokens, Token{TokIdent, word, start})
			}
		default:
			if op := readOperator(source, pos); op != "" {
				tokens = append(tokens, Token{TokOperator, op, start})
				pos += len(op)
			} else if typ, ok := punctuation[c]; ok {
				tokens = append(tokens, Token{typ, string(c), start})
				pos++
			} else {
				r, _ := utf8.DecodeRuneInString(source[pos:])
				return nil, newError(source, start, "неожиданный символ %s", strconv.Quote(string(r)))
			}
		}
	}
	return append(tokens, Token{TokEOF, "", pos}), nil
}

Цикл устроен как switch без выражения: Go проверяет ветки case сверху вниз и берёт первую истинную, это короче лестницы if/else if. Пройдём по веткам.

Пробел. Сдвинули курсор, пошли дальше. Никакого continue не нужно: в Go ветка switch не проваливается в следующую, и после неё цикл просто идёт на новый круг.

Цифра. Ели цифры подряд и собрали number. Значение токена это срез source[start:pos], и он не копирует байты: подстрока в Go делит память с исходной строкой. Числа у нас только неотрицательные целые; отрицательные это вопрос синтаксиса выражений, и про них есть упражнение. Литерал в число превратит уже парсер, и там нас ждёт проверка на переполнение int64.

Кавычка. Открыли строковую константу. Внутри строки одинарную кавычку экранируют удвоением: 'it''s ok' это строка it's ok. Поэтому, встретив кавычку внутри, смотрим на следующий байт: если это тоже кавычка, кладём одну в значение и перешагиваем обе; иначе строка закончилась. Значение копится в strings.Builder, потому что строку собираем по байту, а склейка через += на каждом шаге копировала бы весь накопленный текст заново.

Две тонкости этой ветки стоит увидеть глазами. Первая: for pos++; ; { ... } это цикл без условия с одним начальным действием, он перешагивает открывающую кавычку и дальше крутится, пока его не прервут изнутри. Вторая: break внутри него. В Go break внутри switch выходит из switch, а не из цикла, и на этом регулярно спотыкаются. Здесь ловушки нет: ближайшая к break конструкция это внутренний for, а не внешний switch, поэтому break закрывает строковый цикл, и мы идём дописывать токен. Если строка не закрылась до конца ввода, возвращаем ошибку, указывая на открывающую кавычку (start), а не на конец текста: так человек сразу видит, какая из кавычек осталась без пары.

Буква или подчёркивание. Ели слово, перевели в верхний регистр и проверили по Keywords. Есть в таблице, значит keyword; нет, значит ident с исходным регистром. Ключевые слова и имена не путаются, потому что решает одна проверка по таблице. Обрати внимание на форму if upper := strings.ToUpper(word); Keywords[upper]: переменная upper живёт только внутри if и else, снаружи её нет.

Всё остальное. Сначала пробуем оператор, потом пунктуацию через map punctuation (снова typ, ok :=, потому что здесь нулевое значение "" для отсутствующего ключа было бы ложным видом токена). Ни то ни другое не подошло, значит символ неожиданный. В конце дописываем eof, и его смещение это pos, длина текста: стрелка ошибки “встретилось конец запроса” встанет сразу за последним символом.

Почему байтовый лексер не ломает UTF-8

Лексер смотрит на source[pos], то есть на байт, а не на символ. Казалось бы, на кириллице он должен развалиться. Не развалится, и причина в устройстве UTF-8: все байты многобайтного символа больше 0x7F, а все символы синтаксиса SQL (буквы латиницы, цифры, кавычка, скобки, операторы) это ASCII, меньше 0x80. Байт из середины буквы я никогда не совпадёт с ' или (. Поэтому:

  • внутри строковой константы байты кириллицы копируются в strings.Builder как есть и собираются обратно в ту же букву;
  • вне строки байт кириллицы не проходит ни одну ветку и падает в default.

В последнем случае и пригодился utf8.DecodeRuneInString. Он декодирует целую руну, начиная с pos, и сообщение показывает букву целиком:

неожиданный символ "и" (строка 1, колонка 8)
SELECT имя FROM t
       ^

Возьми мы string(c) от одного байта, в сообщении оказался бы обломок буквы, невалидный UTF-8, а strconv.Quote напечатал бы его как "\xd0". Имена в нашем диалекте только латиницей, это ограничение грамматики, а не лексера: расширить isIdentStart до unicode.IsLetter над рунами можно, но тогда лексеру придётся шагать по рунам, а не по байтам.

Операторы и классификаторы

// sql/lexer.go (продолжение)

func readOperator(source string, pos int) string {
	if pair := source[pos:min(pos+2, len(source))]; pair == "<=" || pair == ">=" || pair == "<>" {
		return pair
	}
	if strings.IndexByte("=<>+-*/", source[pos]) >= 0 {
		return source[pos : pos+1]
	}
	return ""
}

func isDigit(c byte) bool      { return c >= '0' && c <= '9' }
func isIdentStart(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' }

readOperator показывает классическую ловушку лексера: правило самого длинного совпадения. Сначала пробуем пару (<=, >=, <>), потом одиночный символ. Перепутаешь порядок, и <= распадётся на < и =.

Срез source[pos:min(pos+2, len(source))] берёт два байта, а у последнего символа строки один: min не даёт выйти за конец, и паники выхода за границы не будет. Пустая строка "" вместо “нет оператора” это идиома Go для функции, у которой нулевое значение результата никогда не бывает правильным ответом: настоящий оператор пустым не бывает, поэтому отдельный флаг ok не нужен. isIdentStart полагается на приоритеты операторов Go: && крепче ||, поэтому скобки вокруг диапазонов не нужны, gofmt их и не просит.

Прогоним лексер на запросе из начала урока:

keyword  "SELECT" @0
ident    "name" @7
keyword  "FROM" @12
ident    "users" @17
keyword  "WHERE" @23
ident    "id"   @29
operator "="    @32
number   "42"   @34
eof      ""     @36

Лексер готов. Это первая из трёх функций: строка зашла, []Token или error вышли.

AST: закрытые интерфейсы

Парсер собирает AST, и вот где Go заметнее всего расходится с TS. В TypeScript узел выражения это размеченное объединение: type Expr = LiteralExpr | ColumnExpr | BinaryExpr | UnaryExpr, и поле kind говорит, какой это вариант. В Go размеченных объединений нет. Есть интерфейсы, и из них собирают то же самое.

Код ниже сокращён: настоящий ast.go уже содержит верхнеуровневую форму Command и узлы Explain и Txn для уроков 6-8. Их мы добавим, когда до них дойдёт дело.

// sql/ast.go (без Command, Explain и Txn: они появятся в уроке 6)
package sql

import "ourdb/tuple"

// -------- выражения --------

// Expr это узел выражения: *Literal, *Column, *Binary или *Unary.
type Expr interface{ expr() }

// LiteralKind это вид литерала.
type LiteralKind string

const (
	LitNumber LiteralKind = "number"
	LitString LiteralKind = "string"
	LitBool   LiteralKind = "bool"
	LitNull   LiteralKind = "null"
)

// Literal это литерал; значимо только поле его вида.
type Literal struct {
	Kind LiteralKind
	Int  int64
	Str  string
	Bool bool
}

// Column это ссылка на колонку, Table пуст без квалификатора.
type Column struct {
	Table string
	Name  string
}

// Binary это бинарный оператор: = <> < <= > >= + - * / AND OR LIKE.
type Binary struct {
	Op          string
	Left, Right Expr
}

// Unary это NOT.
type Unary struct {
	Operand Expr
}

func (*Literal) expr() {}
func (*Column) expr()  {}
func (*Binary) expr()  {}
func (*Unary) expr()   {}

Метод-маркер закрывает множество

Expr interface{ expr() } это интерфейс с одним методом, и метод этот ничего не делает. Его смысл в имени: expr пишется со строчной буквы, значит, он не экспортируется. Реализовать такой интерфейс может только тип из пакета sql: чужой пакет не в силах объявить у своего типа метод sql.expr. Получается закрытое множество из ровно четырёх вариантов, *Literal, *Column, *Binary и *Unary, и добавить пятый можно, только отредактировав ast.go.

Зачем закрывать? Чтобы Expr нельзя было собрать из чего попало. Без маркера, с пустым interface{} (или any), в поле Where легально положить 42 или "hello", и компилятор промолчит. С маркером Where Expr примет только узел выражения. Этот же приём ты встретишь в стандартной библиотеке: так устроены узлы в go/ast. Как работают интерфейсы и any, разобрано в уроке про интерфейсы.

Чего маркер не даёт, так это проверки полноты. В TS switch (expr.kind) без одной ветки компилятор подсветит, если ты попросишь. В Go switch e := e.(type) без ветки *Unary скомпилируется молча, а узел просто не попадёт ни в одну ветку. Поэтому у каждого switch по узлам есть явный путь после всех case: принтер в этом уроке вернёт там пустую строку, binder в уроке 6 вернёт ошибку unknown expression. Если хочется проверки на этапе сборки, для неё есть сторонние линтеры, но воркшоп обходится стандартными go vet и тестами.

Указатели, а не значения

Методы-маркеры объявлены на указателях: func (*Literal) expr(). Это значит, что Expr реализует *Literal, а не Literal. Почему так?

  • Узлы деревьев живут на куче и делятся. Binary держит два поддерева. Будь узлы значениями, каждое присваивание копировало бы поддерево целиком.
  • Сравнение деревьев в тестах. reflect.DeepEqual идёт по указателям вглубь и сравнивает то, на что они указывают, поэтому &sql.Binary{...} из теста равен &sql.Binary{...} из парсера, хотя адреса разные.
  • Единообразие. Все варианты указатели, и type switch всегда пишется как case *Binary:. Смешай значения и указатели, и одна забытая звёздочка в case молча пропустит узел.

Но Literal при этом используется и как значение: Insert.Values это []Literal, а не []*Literal. В списке VALUES стоят только литералы, никакой полиморфизм там не нужен, и срез значений лежит в памяти одним куском, без лишних указателей. Когда литерал становится частью выражения, парсер берёт его адрес: lit := p.literal(); return &lit.

Литерал: одна структура вместо четырёх

Обрати внимание, что Literal это не четыре типа под общим интерфейсом, а одна структура с полем Kind и тремя полями под значения. Комментарий честно предупреждает: “значимо только поле его вида”. Literal{Kind: LitNumber, Int: 42} держит пустые Str и Bool, и их надо просто не читать.

Это компромисс. Отдельные типы NumberLit, StringLit и так далее дали бы строгость (у строкового литерала физически нет поля Int), но каждый потребитель литерала превратился бы во вложенный type switch. Литерал же везде обрабатывается одинаково: switch l.Kind, четыре ветки. Плоская структура сравнима через ==, копируется без выделений памяти, и её нулевое значение Literal{} безвредно. Для узла, у которого нет детей и нет поведения, это разумная цена.

Число хранится в int64. У TS-эталона литерал это bigint без границ, а в Go-порте int колонки это ровно восемь байтов int64 (так записано в контракте байтов из урока 3). Литерал больше int64 поэтому не заворачивается молча, а становится ошибкой разбора. Это мы увидим в парсере.

Операторы

// sql/ast.go (продолжение)

// -------- части SELECT --------

// SelectItem это `*` (Star) или выражение с необязательным алиасом.
type SelectItem struct {
	Star  bool
	Expr  Expr
	Alias string
}

// Join это INNER или LEFT JOIN.
type Join struct {
	Type  string // "inner" | "left"
	Table string
	On    Expr
}

// OrderBy это ORDER BY колонка ASC|DESC.
type OrderBy struct {
	Column    string
	Direction string // "asc" | "desc"
}

// -------- операторы --------

// Statement это *CreateTable, *Insert, *Select, *Update или *Delete.
type Statement interface{ statement() }

// ColumnDef это колонка в CREATE TABLE. Constraint: "", "primaryKey" или "notNull".
type ColumnDef struct {
	Name       string
	Type       tuple.ColumnType
	Constraint string
}

type CreateTable struct {
	Table   string
	Columns []ColumnDef
}

// Insert без списка колонок держит Columns = nil.
type Insert struct {
	Table   string
	Columns []string
	Values  []Literal
}

type Select struct {
	Items   []SelectItem
	From    string
	Join    *Join
	Where   Expr
	OrderBy *OrderBy
	Limit   *int
}

type Assignment struct {
	Column string
	Value  Expr
}

type Update struct {
	Table       string
	Assignments []Assignment
	Where       Expr
}

type Delete struct {
	Table string
	Where Expr
}

func (*CreateTable) statement() {}
func (*Insert) statement()      {}
func (*Select) statement()      {}
func (*Update) statement()      {}
func (*Delete) statement()      {}

Statement закрыт тем же приёмом, что и Expr, своим маркером statement(). Два разных маркера нужны, чтобы множества не смешивались: *Select это оператор, но не выражение, и положить его в Where нельзя.

Самое интересное здесь в том, как Go выражает “необязательное”. В TS-эталоне это where?: Expr, поле, которого может не быть. В Go у каждого поля структуры всегда есть значение, поэтому отсутствие кодируется нулевым значением, и для каждого поля оно своё:

  • Where Expr это интерфейс, его нулевое значение nil. Нет WHERE, значит s.Where == nil, и принтер с исполнителем проверяют ровно это.
  • Join *Join и OrderBy *OrderBy это указатели, nil значит “нет соединения” и “нет сортировки”. Структура без указателя не могла бы отличить “сортировки нет” от “сортировка по пустому имени”.
  • Limit *int указатель на число. Тут иначе нельзя: LIMIT 0 это законный запрос, и нулевое значение int не отличило бы его от отсутствия LIMIT. Указатель различает “нет” (nil) и “ноль” (&0). Про указатели как способ сказать “значения может не быть” подробно в уроке про указатели.
  • Columns []string срез, и комментарий фиксирует контракт: без списка колонок Columns = nil. Не пустой срез []string{}, а именно nil. Для len и range разницы нет, но reflect.DeepEqual различает nil и пустой срез, и round-trip тест это заметит. Парсер поэтому заводит срез только через append, когда колонки действительно есть.
  • Alias string и Constraint string обходятся пустой строкой: пустой алиас и пустое ограничение не бывают осмысленными, нулевое значение тут и значит “нет”.

Тип колонки tuple.ColumnType пришёл из урока 3: фронтенд и хранилище говорят о типах одним языком, и binder в уроке 6 не будет ничего переводить.

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

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

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