
Предыстория
Ключевые слова constexpr/consteval в С++ живут уже не первый год, но для многих по‑прежнему остаются чем‑то неяснымиили чересчур академичными. По старой памяти их роль ограничивается чем‑то вроде «вычислить факториал в compile‑time», «сделать генератор чисел» или, в лучшем случае, «записать пару if constexpr для метапрограммирования». Словом, игрушка для шаблонных фокусов, но не инструмент, способный менять архитектурную парадигму.
Однако C++ постепенно эволюционирует. С выходом новых стандартов (C++20, C++23 и предстоящего C++26) и с расширением constexpr‑совместимости в стандартной библиотеке, — пространство применения constexpr и consteval стремительно выходит за рамки тривиальных вычислений. Мы получаем возможность работать с полноценными структурами данных, парсить текст, реагировать на ошибки осмысленно и строго уже в момент компиляции, а не где‑то в CI или, что хуже, в runtime. Именно здесь возникает новое мышление: если что‑то может быть проверено до запуска — оно обязано быть проверено до запуска.
В этой статье мы посмотрим, как можно реализовать полную compile‑time валидацию SQL‑запросов на основе схемы базы данных, встраиваемой прямо в код. Без магии, без рантайма, без сторонних тулов. Только стандартный C++ и ваша структура БД. Валидация таблиц, столбцов, типов аргументов и их количества — всё на compile‑time.
Представьте, если бы компилятор сам указывал «такой таблицы нет», «несуществующий столбец», «несовместимые типы» — до запуска программы. Такой подход полностью устраняет «сюрпризы» во время исполнения и исключает класс ошибок, связанных с генерацией SQL во время работы программы. Ваша программа даже не соберётся.
Ссылка на полный проект с тестовыми примерами
Архитектура решения
Архитектура механизма проверки SQL на этапе компиляции строится на следующих шагах:
-
Схема базы данных. Описываем структуру БД в статическом файле (например, XML, JSON или SQL DDL) с таблицами, полями и их типами.
-
Встраивание схемы. Содержимое схемы включается в исходники. В будущем C++26 для этого может использоваться директива #embed, которая автоматически встраивает файл как массив байт:
constexpr const char data[] = { #embed "test.json", 0};В C++20/23 можно использовать, например, скрипт‑генератор и преобразовать содержимое файла в строковый литерал
constexpr std::string_view. -
Парсинг схемы на этапе компиляции. Специальная
consteval‑функция (илиconstexpr‑функция, выполняемая при компиляции) читаетstring_viewсхемы и анализирует ее. В результате компиляции формируется константная модель БД: таблицы преобразуются в constexpr структуры или массивы, столбцы — в constexpr значения. Получаем в коде C++ типобезопасное описание схемы (наподобие «отображения» из имен в типы полей). -
SQL‑валидатор на этапе компиляции. Любой SQL‑запрос в коде оформляется, например, как строковый литерал с передачей аргументов полей. С помощью consteval ‑функций мы разбираем этот запрос на лексемы (SELECT, FROM, WHERE и т. д.), сверяем упоминаемые таблицы и поля с ранее построенной схемой. Если находится несоответствие (не та таблица, не тот столбец, несовместимые типы), генерируем
static_assertс подробным сообщением. Если проверка проходит, компиляция продолжается.
Благодаря таким метаданным мы имеем все имена таблиц/столбцов и их типы. Эти структуры определяются в коде (сгенерированы из внешнего описания) и имеют только constexpr‑данные. В схеме нет дублей: исходная информация хранится только в одном месте (схема базы данных) и преобразуется в C++‑типы автоматически. Таким образом, уже к моменту валидации запросов компилятор «знает», какие таблицы существуют и какие поля в них есть.
Схема описывается несколькими простыми структурами:
Column — описывает столбец: содержит имя ( std::string_view ) и тип поля.
Table — описывает таблицу: содержит имя таблицы и массив std::array<Column> полей.
Schema — список всех таблиц в базе.
constexpr size_t MAX_COLUMNS = 100; constexpr size_t MAX_TABLES = 100; struct Table { enum ColumnType : int { BOOLEAN, DATETIME, DOUBLE, ENUM, HASHTABLE, IDENTIFIER, INT64, INTEGER, MONEY, STRING, UUID, }; struct Column { std::string_view mName; ColumnType mType; bool mIsNotNull = false; }; std::string_view mName; std::array< Column, MAX_COLUMNS > mColumns{}; size_t Column_count = 0; }; struct Schema { std::array< Table, MAX_TABLES > mTables{}; size_t mTableCount = 0; };
Здесь вынужден сделать уточнение, что столько кривой вариант работы через std::array — вынужденная необходимость С++23, которая уже в С++26 позволит оперировать векторами и иными контейнерами с динамической памятью.
Для того, чтобы перетащить актуальную версию схемы базы данных (для примера это будет XML‑файл, описывающий схему базы данных в формате, характерном для внутреннего DSL), я набросал небольшую кастомную команду в cmake:
add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/generated_dicx.hpp COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR} COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/embed_dicx.py ${CMAKE_SOURCE_DIR}/db.dicx ${CMAKE_BINARY_DIR}/generated_dicx.hpp DEPENDS ${CMAKE_SOURCE_DIR}/db.dicx )
Которая запускает python‑скрипт по копированию файла схемы в хэдер‑файлик, который затем будет подключен к проекту для парсинга в нашу структуру. Суть скрипта — предоставить нам при компиляции актуальный вид БД в constexpr виде:
constexpr std::string_view dicx_xml = R"DICX(<?xml version="1.0" encoding="UTF-8" ?> <table name="Company" responsible="Косинцев А.В."> <comment>Таблица для компаний</comment> <column index_in_pk="0" is_pk_part="1" name="Uuid"> <comment>Uuid компании</comment> <format> <type>UUID</type> <not_null>true</not_null> </format> </column> </table> <!-- другие столбцы и таблицы... --> )DICX";
В C++26 это будет решено директивой #embed, без внешних скриптов, но для поддержания работоспособности примера в С++23 сойдет и текущий способ.
Это небольшое достижение позволит нам перейти к парсингу dicx_xml.
Для предоставления общего интерфейса парсера используется паттерн CRTP (Curiously Recurring Template Pattern). Так, наш шаблонный класс BaseParserCRTP — это наиболее простой способ заиметь constexpr-поле класса, инициализируемое наследником, который сам определит, как распарсить свою схему базы данных. Сам же BaseParserCRTP реализует общую логику получения готовой структуры Schema, ее таблиц и их количества.
template< typename Derived > class BaseParserCRTP { protected: static inline constexpr Schema mSchema = Derived::Parse(); public: consteval static size_t TableCount() { return mSchema.mTableCount; } consteval static const Table* GetTable( std::string_view name ) { for( auto const& t : mSchema.mTables ) if( t.mName == name ) return &t; return nullptr; } };
Для своего XML‑варианта я определю своего наследника с нужным парсером, и именно он получит на обработку нашу сгенерированную версию схемы БД:
#include "base_parser.hpp" #include "generated_dicx.hpp" #include <string_view> class DicxParser : public BaseParserCRTP< DicxParser > { public: consteval static Schema Parse(); private: using ColumnType = Table::ColumnType; static consteval size_t FindTag( std::string_view xml, std::string_view tag, size_t start = 0 ); static consteval std::string_view ExtractAttr( std::string_view tag, std::string_view key ); static consteval ColumnType ParseColumnType( std::string_view s ); }; inline consteval Schema DicxParser::Parse() { Schema r; size_t pos = 0; while( true ) { auto tbl_pos = FindTag( dicx_xml, "<table ", pos ); if( tbl_pos == dicx_xml.size() ) break; auto end_tag = FindTag( dicx_xml, ">", tbl_pos ); auto tag_str = dicx_xml.substr( tbl_pos, end_tag - tbl_pos ); auto name = ExtractAttr( tag_str, "name=" ); auto& table = r.mTables[ r.mTableCount++ ]; table.mName = name; size_t col_pos = end_tag; auto table_close = FindTag( dicx_xml, "</table>", end_tag ); while( true ) { auto cpos = FindTag( dicx_xml, "<column ", col_pos ); if( cpos >= table_close ) break; auto cend = FindTag( dicx_xml, ">", cpos ); auto ctag = dicx_xml.substr( cpos, cend - cpos ); auto col_name = ExtractAttr( ctag, "name=" ); auto& Column = table.mColumns[ table.Column_count++ ]; Column.mName = col_name; auto tpos = FindTag( dicx_xml, "<type>", cend ); auto tend = FindTag( dicx_xml, "</type>", tpos ); auto type_name = utils::Trim( dicx_xml.substr( tpos + 6, tend - tpos - 6 ) ); Column.mType = ParseColumnType( type_name ); auto nn_pos = FindTag( dicx_xml, "<not_null>", tpos ); if( nn_pos < table_close && nn_pos < FindTag( dicx_xml, "</not_null>", nn_pos ) ) { auto nn_end = FindTag( dicx_xml, "</not_null>", nn_pos ); Column.mIsNotNull = dicx_xml.substr( nn_pos + 10, nn_end - nn_pos - 10 ) == "true"; } col_pos = tend; } pos = table_close; } return r; } inline consteval DicxParser::ColumnType DicxParser::ParseColumnType( std::string_view s ) { if( s == "UUID" ) return ColumnType::UUID; if( s == "STRING" ) return ColumnType::STRING; if( s == "INTEGER" ) return ColumnType::INTEGER; if( s == "DOUBLE" ) return ColumnType::DOUBLE; if( s == "BOOLEAN" ) return ColumnType::BOOLEAN; if( s == "DATETIME" ) return ColumnType::DATETIME; if( s == "ENUM" ) return ColumnType::ENUM; if( s == "HASHTABLE" ) return ColumnType::HASHTABLE; if( s == "IDENTIFIER" ) return ColumnType::IDENTIFIER; if( s == "INT64" ) return ColumnType::INT64; if( s == "MONEY" ) return ColumnType::MONEY; throw "Unknown Column type: " __FILE__; }
DicxParser наследуется от BaseParserCRTP и знает формат нашего.dicx XML (таблицы, столбцы, типы). За счёт CRTP весь парсинг происходит без виртуальных вызовов.
После того как схема представлена в constexpr‑структурах, любой SQL‑запрос можно проверить компилятором. Это может быть простой рекурсивный разбор (например, только SELECT‑FROM‑WHERE без сложных JOIN), выполненный в consteval функции.
При этом собственным критерием удобства был формат написания запросов, по типу таких:
SQL::Select< "SELECT Folder, Id FROM CompanyFolderCounters" >(); SQL::Insert< "INSERT INTO Company (Description, Uuid, Name, Rating)" > ( description, uuid, name, rate );
Это позволило бы оставить гибкость SQL‑языка, и при этом не потерять аргументы полей.
Наш SqlChecker крайне прост и его методы параметризуются ровно тем способом, пример использования которого вы видите выше. Разумеется, представленный вариант крайне примитивен и служит целью показать возможность использования С++ для валидации SQL‑запросов.
template< typename T > concept DBParser = std::derived_from< T, BaseParserCRTP< T > >; template< DBParser Parser > class SqlChecker { public: virtual ~SqlChecker() = default; consteval SqlChecker() = default; consteval static inline size_t TableCount(); template< FixedString SQL, typename... Args > consteval void CheckInsert() const; template< FixedString SQL > consteval void ValidateSelect() const; private: consteval static Table const* GetTable( std::string_view table_name ) { return Parser::GetTable( table_name ); } };
Из‑за ограничений С++23 на constexpr‑контейнеры, пришлось, не дожидаясь 26 года, сообразить FixedString, который бы не содержал ничего лишнего, и полностью умещался в constexpr контекст:
template< size_t N > struct FixedString { char mData[ N ]{}; constexpr FixedString( const char ( &str )[ N ] ) { for( size_t i = 0; i < N; ++i ) mData[ i ] = str[ i ]; } constexpr operator std::string_view() const { return { mData, N - 1 }; } };
Иначе вы бы сразу столкнулись с сообщением вроде:
“Type ‘std::string_view’ is not a structural type because it has a non‑static data member that is not public.”
Мой простенький метод валидации вычитки из базы данных выглядит так:
template< FixedString SQL > consteval void ValidateSelect() const { constexpr std::string_view sql = SQL; if constexpr( constexpr bool is_select = sql.starts_with( "SELECT" ); !is_select ) static_assert( is_select, "It's not SELECT query" ); constexpr auto from = sql.find( "FROM" ); if( constexpr bool is_from = from != std::string_view::npos; !is_from ) static_assert( is_from, "FROM not found" ); constexpr auto columns = sql.substr( 7, from - 7 ); if constexpr( utils::Trim( columns ) == "*" ) { constexpr auto tbl = utils::Trim( sql.substr( from + 5 ) ); static_assert( GetTable( tbl ), "Unknown table" ); } else { constexpr auto tbl = utils::Trim( sql.substr( from + 5 ) ); constexpr auto sd = GetTable( utils::Trim( tbl ) ); if constexpr( constexpr bool is_table_present = sd; !is_table_present ) static_assert( is_table_present, "Unknown table" ); constexpr auto column_result = utils::Split( columns, ',' ); constexpr auto column_tokens = column_result.first; constexpr size_t column_count = column_result.second; // Проверка одного аргумента по соответствию в БД auto test_one = [ & ]< size_t I >() consteval { constexpr auto name = utils::Trim( column_tokens[ I ] ); constexpr bool ok = [ & ] { for( size_t j = 0; j < sd->Column_count; ++j ) if( sd->mColumns[ j ].mName == name ) return true; return false; }(); if constexpr( !ok ) { static_assert( ok, "Unknown column" ); } }; [ & ]< size_t... Is >( std::index_sequence< Is... > ) consteval { ( test_one.template operator()< Is >(), ... ); }( std::make_index_sequence< column_count >{} ); } }
Здесь целая эпопея из слова constexpr, но таковы требования языка к тому, чтобы убедиться в compile‑time принадлежности той или иной переменной. Отдельная боль для column_result в том, что декомпозиция пока что не может быть constexpr.
Облегчение здесь может быть в том, что ограничения и минусы стилистики языка имеют решение в грядущем стандарте, а значит, можно надеяться на более лаконичную и меньшую по количеству костылей версию данной задумки.
Ради справедливости приложу версию Insert валидации, но полную версию всего здесь описанного можно будет глянуть и потыкать ЗДЕСЬ:
template< FixedString SQL, typename... Args > consteval void CheckInsert() const { constexpr std::string_view sqlv = SQL; // Проверка, что запрос начинается с INSERT if constexpr( !sqlv.starts_with( "INSERT" ) ) { static_assert( false, "It's not INSERT query" ); } // Нахождение позиции "INTO" и скобок constexpr auto into_pos = sqlv.find( "INTO" ); if constexpr( into_pos == std::string_view::npos ) { static_assert( false, "INTO not found in INSERT query" ); } else { // Извлечение списка полей между скобками constexpr auto paren1 = sqlv.find( '(', into_pos ); constexpr std::string_view table = utils::Trim( sqlv.substr( into_pos + 4, paren1 - ( into_pos + 4 ) ) ); constexpr auto paren2 = sqlv.find( ')', paren1 ); constexpr std::string_view Columns_list = sqlv.substr( paren1 + 1, paren2 - ( paren1 + 1 ) ); constexpr auto split_result = utils::Split( Columns_list, ',' ); constexpr auto columns = split_result.first; constexpr size_t fcount = split_result.second; // Получаем дескриптор таблицы по имени constexpr auto sd = GetTable( table ); if constexpr( !sd ) { static_assert( false, "Unknown table" ); } // Проверка, что количество аргументов совпадает с количеством полей if constexpr( fcount != sizeof...( Args ) ) { static_assert( false, "Column count is not equal to argument count" ); } else { constexpr auto trimmed_Columns = [ & ]() consteval { std::array< std::string_view, sizeof...( Args ) > out{}; for( size_t i = 0; i < sizeof...( Args ); ++i ) { out[ i ] = utils::Trim( columns[ i ] ); } return out; }(); // Проверка одного аргумента по соответствию в БД auto test_one = [ & ]< typename T, size_t I >() consteval { constexpr std::string_view column_name = trimmed_Columns[ I ]; // 1) Сначала проверяем, существует ли такое поле в sd->Columns constexpr bool column_exists = [ & ]() consteval -> bool { for( size_t j = 0; j < sd->Column_count; ++j ) { if( sd->mColumns[ j ].mName == column_name ) { return true; } } return false; }(); if constexpr( !column_exists ) { static_assert( false, "Unknown column name" ); } else { // 2) Теперь безопасно можем найди индекс поля (поскольку он точно есть) constexpr size_t found_idx = [ & ]() consteval { for( size_t j = 0; j < sd->Column_count; ++j ) { if( sd->mColumns[ j ].mName == column_name ) { return j; } } return size_t( 42 ); }(); // 3) Проверяем тип T на совпадение с типом поля using U = std::decay_t< T >; constexpr auto f_type = sd->mColumns[ found_idx ].mType; if constexpr( f_type == Table::INTEGER ) { static_assert( std::is_same_v< U, int >, "Type mismatch: expected INTEGER" ); } else if constexpr( f_type == Table::DOUBLE ) { static_assert( std::is_same_v< U, double >, "Type mismatch: expected DOUBLE" ); } else if constexpr( f_type == Table::STRING ) { static_assert( std::is_same_v< U, std::string >, "Type mismatch: expected STRING" ); } else if constexpr( f_type == Table::UUID ) { static_assert( std::is_same_v< U, Uuid >, "Type mismatch: expected UUID" ); } else if constexpr( constexpr bool is_Column_not_found = true ) { static_assert( false && sd->mColumns[ found_idx ].mType, "Type mismatch: unknown column type in table" ); } } }; [ & ]< size_t... Is >( std::index_sequence< Is... > ) consteval { ( test_one.template operator()< typename std::tuple_element< Is, std::tuple< Args... > >::type, Is >(), ... ); }( std::make_index_sequence< fcount >{} ); } } }
В отличие от SELECT, валидация INSERT требует строгой проверки количества и порядка значений — в точном соответствии с порядком полей, а также передаваемых типов аргументов — Uuid — значит — Uuid.
После этих двух примеров становиться очевидным, что в constexpr пока что не получается пройтись по количеству типов аргументов иначе, как через шаблоны, инстанцируемые на этапе компиляции, но да не будет это помехой.
Однако, цель достигнута и работа нашего SqlChecker'a примерно такая:
1) Лексический анализ: строковый литерал запроса разбивается на токены (ключевые слова, имена таблиц/полей, литералы, операторы).
2) Проверка синтаксиса: анализируем структуру запросов (простая грамматика SELECT). Если синтаксис не соответствует поддерживаемому подмножеству SQL, вызываем static_assert.
3) Проверка таблиц/столбцов: для каждого упомянутого имени таблицы ищем соответствующую таблицу в constexpr‑схеме. Аналогично проверяем поля и их типы: например, убеждаемся, что литерал конвертируется в тип поля (например, не сравниваем число с текстом). При несовместимости типов — static_assert(«Type mismatch:…»).
Примитивный валидатор для SQL‑запросов готов, и теперь осталось его красиво обернуть:
#include "dicx_parser.hpp" #include "sql_checker.hpp" /// Обертка для работы с БД struct SQL { template< FixedString Query, typename... Args > constexpr static void Insert( Args const&... args ) { static_assert( mChecker.TableCount() > 0, "No tables loaded" ); mChecker.CheckInsert< Query, Args... >(); // Здесь обычная вставка // Вызывается только если compile-time проверка прошла успешно } template< FixedString Query > consteval static void Select() { static_assert( mChecker.TableCount() > 0, "No tables loaded" ); mChecker.ValidateSelect< Query >(); // Здесь обычная вставка // Вызывается только если compile-time проверка прошла успешно } private: inline static constexpr SqlChecker< DicxParser > mChecker; };
Данная структура скрывает детали реализации и позволяет выполнять вставку напрямую через передачу запроса и аргументов, валидируя запрос любой сложности внутри.
Ошибки, отловленные во время компиляции
Uuid uuid; std::string description = "description", name = "name"; double rate = 4.6; int int_uuid; // Случайно передадим вместо Uuid-идентификатора - простое целое число SQL::Insert< "INSERT INTO Company (Description, Uuid, Name, Rating)" > ( description, int_uuid, name, rate ); // ВЫВОД: error: In template: static assertion failed due to requirement // 'std::is_same_v<int, Uuid>': Type mismatch: expected UUID // Передадим меньшее количество аргументов, чем полей на вставку SQL::Insert< "INSERT INTO Company (Description, Uuid, Name, Rating)" > ( description, name, rate ); // ВЫВОД: error: In template: static assertion failed: // Column count is not equal to argument count // Случайно ошибемся в названии столбца Uuid SQL::Insert< "INSERT INTO Company (Description, UUid, Name, Rating)" > ( description, uuid, name, rate ); // ВЫВОД: error: In template: static assertion failed: Unknown column name // Если такой таблицы нет, то: SQL::Select< "SELECT Id FROM Car" >(); // ВЫВОД: Unknown table
И, разумеется, где-то я лукавлю, поскольку иногда сообщения могут выглядеть больше, стопкой варнингов или не подсвечивать конкретное место, оставляя гадать, какое же это поле.. Но это то малое, что уже можно использовать сейчас, и расширить и углубить в перспективе. Не раз и не два мы попадались на ошибки соответствия полей, аргументов и их текстового нейминга, а также банальных опечаток.
Однако, нужно отдавать отчет, что использовать constexpr‑парсинга означает более сложную сборку и потенциально более долгий этап компиляции (особенно при больших схемах и сложных запросах). Имеет смысл поддерживать подмножества SQL, достаточные для основных операций. Тем не менее переотправка большей части логики в компилятор — оправданная плата за отсутствие неожиданных ошибок в рантайме.
Расширения и будущее
Новый стандарт C++26 продолжит расширять возможности такого решения. Директива #embed позволит включать файлы схемы или дамп БД прямо в код без внешних скриптов. Расширение constexpr ‑контейнеров позволят использовать динамическую память и умные указатели в constexpr‑контексте. Это означает, что скоро не придется ограничивать число таблиц/столбцов жёсткими массивами — можно будет применять std::vector, std::string.
Помимо этого, возможно сочетание сгенерированного описания схемы с рефлексией в C++26: можно автоматически генерировать классы/типы по описанию таблиц или загружать новые версии схем, не меняя основной код. Тем не менее, текущий вариант в виде интеграции с системами сборки (например, CMake) не в меньше степени гарантирует, что при изменении базы данных создаётся обновлённый заголовок/ресурс, тогда как компиляция проверяет целостность.
Заключение
«Ошибки, предсказанные компилятором, — это ошибки, которые никогда не случились»
Ключевые слова constexpr и consteval — это не только средства для предвычислений, но и полноценный инструмент для чего угодно на этапе компиляции. Применив их к работе с базой данных, мы переносим всю ответственность за корректность SQL‑запросов в компилятор. Проект собирается лишь если все запросы совпадают со схемой. Это устраняет класс ошибок, связанных с базой данных, ещё до запуска приложения. С приходом C++26 эта модель станет ещё мощнее, но уже сейчас это хорошее подспорье для того, чтобы обезопасить рантайм, не усложняя стилистику использования SQL. Поэтому, можно смело сказать:
«Мы привыкли к тому, что
constexpr— это математика. Пора понять, что это язык мышления компилятора»
Ссылка на полный проект с тестовыми примерами
Косинцев Артём
Инженер-программист
ссылка на оригинал статьи https://habr.com/ru/articles/929052/
Добавить комментарий