Ошибки, которые не случились: C++ и compile‑time проверка SQL-запросов

от автора

Предыстория

Ключевые слова 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 на этапе компиляции строится на следующих шагах:

  1. Схема базы данных. Описываем структуру БД в статическом файле (например, XML, JSON или SQL DDL) с таблицами, полями и их типами.

  2. Встраивание схемы. Содержимое схемы включается в исходники. В будущем C++26 для этого может использоваться директива #embed, которая автоматически встраивает файл как массив байт:

    constexpr const char data[] = { #embed "test.json", 0};

    В C++20/23 можно использовать, например, скрипт‑генератор и преобразовать содержимое файла в строковый литералconstexpr std::string_view.

  3. Парсинг схемы на этапе компиляции. Специальная consteval ‑функция (или constexpr ‑функция, выполняемая при компиляции) читает string_view схемы и анализирует ее. В результате компиляции формируется константная модель БД: таблицы преобразуются в constexpr структуры или массивы, столбцы — в constexpr значения. Получаем в коде C++ типобезопасное описание схемы (наподобие «отображения» из имен в типы полей).

  4. 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *