
Предыстория
Ключевые слова 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
— это математика. Пора понять, что это язык мышления компилятора»
Ссылка на полный проект с тестовыми примерами

Косинцев Артём
Инженер-программист