Что такое подготовленный запрос на самом деле
Когда вы отдаёте SQLite строку с SQL, движку приходится проделать немалую работу ещё до того, как хоть одна строка результата сдвинется с места: разобрать текст на токены, распарсить, проверить наличие таблиц и колонок, построить план выполнения и скомпилировать его в байт-код для виртуальной машины SQLite. И только после этого запрос реально выполняется.
Подготовленный запрос (prepared statement) — это и есть результат остановки на стадии «скомпилировано в байт-код», который вы сохраняете и держите под рукой. В скомпилированной программе остаются «дырки» — плейсхолдеры, куда позже подставятся реальные значения. Одну и ту же программу можно прогонять много раз с разными значениями, причём безопасно — даже если данные пришли из недоверенного источника.
Удобная аналогия: либо каждый раз вслух зачитывать рецепт повару, либо один раз его выучить и в день готовки просто называть ингредиенты.
Жизненный цикл: prepare, bind, step, finalize
Любая обёртка над SQLite в любом языке программирования прячет под капотом одни и те же четыре вызова C-API. Названия стоит запомнить, даже если вы никогда не напишете ни строчки на C, — этот словарь встречается в сообщениях об ошибках и в документации:
sqlite3_prepare_v2— скомпилировать строку SQL в дескриптор подготовленного запроса.sqlite3_bind_*— подставить значения в плейсхолдеры (под каждый тип своя функция).sqlite3_step— запустить программу. ДляSELECTвызывается в цикле и выдаёт строки одну за другой. ДляINSERT/UPDATE/DELETEдостаточно одного вызова.sqlite3_finalize— освободить скомпилированную программу, когда она больше не нужна.
Между прогонами есть ещё sqlite3_reset — он перематывает уже отработавший запрос в начало, чтобы можно было заново забиндить параметры и выполнить его без повторной компиляции.
Плейсхолдеры в SQL
Внутри SQL-строки каждое подставляемое значение помечается плейсхолдером, а не вклеивается в текст напрямую. SQLite поддерживает несколько форм записи:
-- Анонимные, позиционные:
INSERT INTO users (name, email) VALUES (?, ?);
-- Нумерованные:
INSERT INTO users (name, email) VALUES (?1, ?2);
-- Именованные:
INSERT INTO users (name, email) VALUES (:name, :email);
INSERT INTO users (name, email) VALUES (@name, @email);
INSERT INTO users (name, email) VALUES ($name, $email);
? чаще всего встречается в коде на уровне драйвера. Именованные плейсхолдеры (:name) читаются лучше, когда параметров несколько или одно и то же значение используется не один раз. Выберите один стиль в рамках проекта и придерживайтесь его.
А вот чего точно делать не стоит — собирать запрос конкатенацией строк:
-- НЕ ДЕЛАЙТЕ ТАК:
"INSERT INTO users (name) VALUES ('" + user_input + "')"
Это прямой путь к SQL-инъекциям, и заодно вы лишаетесь того самого переиспользования байт-кода, о котором речь пойдёт ниже.
Разбор на чистом SQL
Чтобы увидеть механику prepare/bind/step без участия хост-языка, посмотрим аналог на тех средствах, которые SQLite предоставляет сам по себе. Создадим таблицу и вставим строку, используя плейсхолдер в стиле параметров, который заполним литералом:
В реальном приложении значения не вшивают в запрос напрямую — вместо этого INSERT prepare-ится один раз с плейсхолдерами ?, ?, а дальше для каждого пользователя просто делаются bind пары имя/email и step. Скомпилированный байткод при этом одинаков для всех вызовов; меняются только привязанные значения.
Переиспользование подготовленного запроса (выигрыш в производительности)
Вот паттерн, который позволяет реализовать ваш драйвер. Это псевдокод — в каждом языке синтаксис чуть свой, — но общая форма везде одинаковая:
-- готовится один раз:
INSERT INTO users (name, email) VALUES (?, ?);
-- затем в цикле:
-- bind(1, name)
-- bind(2, email)
-- step()
-- reset()
Когда вы готовите запрос, SQL разбирается и компилируется один раз. На каждой итерации выполняется только байт-код, а значения просто кладутся в нужные слоты. Для массовых вставок (скажем, импорт 100 000 строк) это работает в разы быстрее, чем гонять 100 000 отдельно разбираемых запросов — нередко на порядок, особенно если обернуть всё в одну транзакцию.
Типичная ошибка: люди пишут цикл и вызывают prepare внутри него. Так весь смысл подготовленных запросов теряется. Готовьте запрос снаружи цикла, а bind и step делайте внутри.
Почему так безопаснее
Параметры в bind — это не строки, которые подставляются в SQL. Это значения, которые передаются в байт-код через типизированные слоты: для целых чисел, для текста, для blob. SQLite никогда не разбирает их повторно как SQL, поэтому никакое значение в принципе не способно изменить структуру запроса.
Сравните:
-- Уязвимо. Если user_input равен: '); DROP TABLE users;--
-- запрос становится разрушительным.
"SELECT * FROM users WHERE name = '" + user_input + "'"
-- Безопасно. user_input передаётся как значение TEXT и всегда
-- сравнивается как строка, что бы в нём ни содержалось.
SELECT * FROM users WHERE name = ?;
Второй вариант безопасен, даже если user_input равен '); DROP TABLE users;--. SQLite послушно поищет пользователя с именем, точно равным этой (странной) строке, не найдёт ни одного и вернёт ноль строк. Структура запроса при этом не меняется — что бы ни лежало в значении.
К теме SQL-инъекций мы ещё вернёмся в отдельной статье, но суть такая: подготовленные запросы — это не одна из защит от SQL-инъекций, а та самая защита.
Запросы, возвращающие строки
Для SELECT функция step отдаёт по одной строке за вызов. Драйвер обычно крутится в цикле, пока не получит «готово»:
В реальном коде драйвер взял бы этот SELECT, заменил 2.00 на ? через prepare, подставил пороговое значение через bind и в цикле вызывал step, считывая по одной строке за раз. Когда строки закончатся, step сообщит о завершении, и драйвер либо вызовет reset (чтобы запустить тот же запрос с новым порогом), либо finalize.
Не забывайте про finalize
Подготовленный запрос — это небольшой блок памяти внутри SQLite. Если их не освобождать, утекает память, а главное — удерживается внутренняя блокировка БД, которая может стопорить других писателей. В каждом драйвере есть способ почистить ресурсы автоматически: context manager в Python, блок using в C#, RAII в C++ — и пользоваться ими стоит обязательно:
- В Python модуль
sqlite3финализирует курсор при сборке мусора, но явныйcursor.close()всё же чище. - В better-sqlite3 (Node)
Statementфинализируется при сборке мусора; держать долгоживущие подготовленные запросы здесь нормально. - В чистом C
sqlite3_finalizeприходится вызывать вручную. Забыли — получили реальный баг.
Правило простое: если вы его подготовили — кто-то должен его финализировать.
Когда вам это, возможно, и не понадобится
Напрямую вызывать sqlite3_prepare_v2 приходится редко. Высокоуровневые драйверы сами разворачивают connection.execute("SELECT ... WHERE id = ?", (42,)) в связку prepare/bind/step/finalize. Понимать жизненный цикл всё равно полезно — вот почему:
- Вы сразу поймёте, в чём дело, когда увидите ошибки вроде "statement is busy" или "cannot operate on a finalized statement".
- Будете знать, что в горячем цикле вставок имеет смысл закэшировать долгоживущий подготовленный запрос.
- Параметризованные запросы станут рефлексом — даже когда конкатенация строк выглядит соблазнительно.
ORM и построители запросов идут ещё дальше: они и SQL соберут, и подготовленными запросами поуправляют, и отдадут вам уже типизированные результаты. А под капотом — всё те же четыре вызова.
Дальше: привязка параметров
Про плейсхолдеры мы пока говорили в общих чертах. В следующий раз разберём bind подробно: позиционные и именованные параметры, обработку типов, NULL и мелкие подводные камни, которые всплывают, когда в запросы начинают улетать реальные данные приложения.
Часто задаваемые вопросы
Что такое prepared statement в SQLite?
Это SQL-запрос, который уже распарсен, скомпилирован и превращён в переиспользуемую байт-код программу, но с плейсхолдерами (? или :name) на месте значений. Сами значения вы привязываете отдельно — в момент выполнения. В SQLite за это отвечают функции sqlite3_prepare_v2, sqlite3_bind_*, sqlite3_step и sqlite3_finalize.
Зачем вообще использовать подготовленные запросы в SQLite?
Две причины: безопасность и скорость. Привязанные параметры физически не могут смешаться с SQL-синтаксисом, поэтому SQL-инъекция становится невозможной. Плюс, если один и тот же запрос выполняется многократно — например, вставка 10 000 строк, — то однократный prepare и повторный bind избавляют от парсинга на каждой итерации, и разница в скорости вполне ощутимая.
Чем prepared statement отличается от обычного запроса?
Обычный вызов sqlite3_exec парсит и выполняет SQL за один проход, подставляя значения как текст прямо в строку запроса. Prepared statement разделяет компиляцию и выполнение: вы один раз делаете prepare, затем bind для типизированных значений в плейсхолдерах, step для прохода по результатам и reset, чтобы прогнать запрос снова. Все высокоуровневые драйверы (sqlite3 в Python, better-sqlite3 и т. д.) под капотом работают именно через prepared statements.