Очередная Reflection Library и ORM для C++

от автора

Сразу же предупрежу о велосипедности выдаемого здесь на обозрение. Если прочтение заголовка вызывает лишь с трудом подавляемый возглас «Твою мать, только не новый таксон ORM!», то лучше наверное воздержаться от дальнейшего чтения, дабы не повышать уровень агрессии в космологическом бульоне, в котором мы плаваем. Виной появлению данной статьи явилось то, что в кои-то веки выдался у меня отпуск, в течение которого решил я попробовать себя на поприще написания блогопостов по околохабровской тематике, и предлагаемая тема мне показалась вполне для этого подходящей. Кроме того, здесь я надесь получить конструктивную критику, и возможно понять чего же еще с этим можно сделать этакого интересного. В конце будет ссылка на github-репозиторий, в котором можно посмотреть код.

Для чего нужна еще одна ORM-библиотека

При разработке 3-tier приложений с разделенными слоями представления (Presentation tier), бизнес-логики (Logic tier) и хранения данных (Data tier) неизменно возникает проблема огранизации взаимодействия компонентов приложения на стыке этих слоев. Традиционно интерфейс к реляционным базам данных предоставляется на основе языка SQL-запросов, но его использование напрямую из уровня бизнес-логики обычно сопряжено с рядом проблем, часть из которых легко решается применением ORM (Object-relational mapping):

  • Необходимость представления сущностей в двух формах: объектно-ориентированной и реляционной
  • Необходимость преобразования между этими двумя формами
  • Подверженность ошибкам при ручном написании SQL-запросов (частично может решаться использованием различных lint-утилит и плагинов к современным IDE)

Наличие такого простого решения этих проблем привело к появлению изобилия различных реализаций ORM на любой вкус и цвет (список есть на википедии). Несмотря на обилие существующих решений, всегда найдутся извращенцы «гурманы» (автор из их числа), вкусы которых невозможно удовлетворить существующим ассортиментом. А как же иначе, это же ширпотреб, а наш проект слишком уникален, и существующие решения нам просто не подходят (это сарказм, подпись К.О.).

Наверное подобные максималистичные мысли руководили и мной, когда пару лет назад я взялся за написание ORM под свои нужды. Вкратце все-таки опишу, что было не так с теми ORM, которые я пробовал и что хотелось в них исправить.

  1. Во-первых это потребность в статической типизации, которая бы позволяла отлавливать большую часть ошибок при написании запросов к СУБД еще во время компиляции, а следовательно значительно ускорила бы скорость разработки.
    Условие для реализации: это должен быть разумный компромис между уровнем проверки запросов, временем компиляции (что в случае C++ сопряжено также с отзывчивостью IDE) и читабельности кода.
  2. Во-вторых это гибкость, возможность писать произвольные (в разумных пределах) запросы. На практике этот пункт сводится к возможности написания СУПО (создать-удалить-получить-обновить) запросов с произвольными WHERE-подвыражениями и возможности выполнения кросс-табличных запросов.
  3. Далее следует поддержка СУБД различных поставщиков на уровне «программа должна продолжать корректно работать при перескакивании с одной СУБД на другую».
  4. Возможность переиспользования рефлексии ORM для других нужд (сериализации, script-binding, фабрик отвязанных от реализации и пр.). Что уж говорить, чаще всего рефлексия в существующих решениях «прибита гвоздями» к ORM.
  5. Все-таки не хочется зависеть от генераторов кода а-ля Qt moc, protoc, thrift. Поэтому попытаемся обойтись только средствами шаблонов C++ и препроцессора C.

Собственно реализация

Рассмотрим ее на «игрушечном» примере из учебника SQL. Имеем 2 таблицы: Customer и Booking, относящиеся друг другу связью один ко многим.

В коде объявление классов в заголовке выглядит следующим образом:

// Объявление реляционных объектов struct Customer : public Object {     uint64_t id;     String first_name;     String second_name;     Nullable<String> middle_name;     Nullable<DateTime> birthday;     bool news_subscription;      META_INFO_DECLARE(Customer) };  struct Booking : public Object {     uint64_t id;     uint64_t customer_id;     String title;     uint64_t price;     double quantity;      META_INFO_DECLARE(Booking) }; 

Как видим, такие классы наследуются от общего предка Object (зачем быть оригинальными?), и помимо объявления методов содержит макрос META_INFO_DECLARE. Этот метод просто добавляет объявление перегруженных и переопределенных методов Object. Некоторые поля объявлены через обертку Nullable, как не сложно догадаться, такие поля могут принимать специальное значение NULL. Также все поля-столбцы должны быть публичными.

Определение классов получается несколько более монструозным:

 STRUCT_INFO_BEGIN(Customer)     FIELD(Customer, id)     FIELD(Customer, first_name)     FIELD(Customer, second_name)     FIELD(Customer, middle_name)     FIELD(Customer, birthday)     FIELD(Customer, news_subscription, false) STRUCT_INFO_END(Customer)  REFLECTIBLE_F(Customer)  META_INFO(Customer)  DEFINE_STORABLE(Customer,                 PRIMARY_KEY(COL(Customer::id)),                 CHECK(COL(Customer::birthday), COL(Customer::birthday) < DateTime(1998, January, 1))                 )  STRUCT_INFO_BEGIN(Booking)     FIELD(Booking, id)     FIELD(Booking, customer_id)     FIELD(Booking, title, "noname")     FIELD(Booking, price)     FIELD(Booking, quantity) STRUCT_INFO_END(Booking)  REFLECTIBLE_F(Booking)  META_INFO(Booking)  DEFINE_STORABLE(Booking,                 PRIMARY_KEY(COL(Booking::id)),                 INDEX(COL(Booking::customer_id)),                 // N-to-1 relation                 REFERENCES(COL(Booking::customer_id), COL(Customer::id))                 ) 

Блок STRUCT_INFO_BEGIN…STRUCT_INFO_END создает определения дескрипторов рефлексии полей класса. Макрос REFLECTIBLE_F создает описатель класса для полей (есть еще REFLECTIBLE_M, REFLECTIBLE_FM для создания описателей классов поддерживающих рефлексию методов, но пост не об этом). Макрос META_INFO создает определения перегруженных методов Object. И наконец, самый интересный для нас макрос DEFINE_STORABLE создает определение реляционной таблицы на основе рефлексии класса и объявленных ограничений (constraints), обеспечивающих целостность нашей схемы. В частности, проверяется связь один ко многим между таблицами и проверка на поле birthday (просто для примера, мы хотим обслуживать только совершеннолетних клиентов). Создание необходимых таблиц в базе выполняется просто:

    SqlTransaction transaction;     Storable<Customer>::createSchema(transaction);     Storable<Booking>::createSchema(transaction);     transaction.commit(); 

SqlTransaction, как не трудно догадаться, обеспечивает изоляцию и атомарность выполняемых операций, а также захватывает подключение к базе (может быть несколько именованных подключений к разным СУБД, или параллелизация запросов к одной СУБД — Connection Pooling). В связи с этим следует избегать рекурсивного инстантиирования транзакций — можно получить Dead Lock. Все запросы должны выполняться в контексте какой-то транзакции.

Запросы

Примеры запросов

INSERT

Это самый простой тип запросов. Просто подготавливаем наш объект и вызываем метод insertOne на него:

    SqlTransaction transaction;     Storable<Customer> customer;     customer.init();     customer.first_name = "Ivan";     customer.second_name = "Ivanov";     customer.insertOne(transaction);      Storable<Booking> booking;     booking.customer_id = customer.id;     booking.price = 1000;     booking.quantity = 2.0;     booking.insertOne(transaction);     transaction.commit(); 

Можно также одной командой добавить в базу несколько записей (Batch Insert). В этом случае запрос будет подготавливаться всего один раз:

    Array<Customer> customers;     // заполнение массива клиентов      SqlTransaction transaction;     Storable<Customer>::insertAll(transaction, customers);     transaction.commit(); 

SELECT

Получение данных из базы в общем случае выполняется следующим образом:

    const int itemsOnPage = 10;     Storable<Booking> booking;      SqlResultSet resultSet = booking.select().innerJoin<Customer>()             .where(COL(Customer::id) == COL(Booking::customer_id) &&                    COL(Customer::second_name) == String("Ivanov"))             .offset(page * itemsOnPage).limit(itemsOnPage)             .orderAsc(COL(Customer::second_name), COL(Customer::first_name))             .orderDesc(COL(Booking::id)).exec(transaction);          // Forward iteration     for (auto& row : resultSet)     {         std::cout << "Booking id: " << booking.id << ", title: " << booking.title << std::endl;     } 

В данном случае происходит постраничный вывод всех заказов Ивановых. Альтернативный вариант — получение всех
записей таблицы списком:

    auto customers = Storable<Customer>::fetchAll(transaction,         COL(Customer::birthday) == db::null);      for (auto& customer : customers)     {         std::cout << customer.first_name << " " << customer.second_name << std::endl;     } 

UPDATE

Один из сценариев: обновление записи только что полученной из базы по primary key:

    Storable<Customer> customer;     auto resultSet = customer.select()             .where(COL(Customer::birthday) == db::null)             .exec(transaction);     for (auto row : resultSet)     {         customer.birthday = DateTime::now();         customer.updateOne(transaction);     }     transaction.commit(); 

Альтернативно можно сформировать запрос вручную:

    Storable<Booking> booking;     booking.update()             .ref<Customer>()             .set(COL(Booking::title) = "All sold out",                  COL(Booking::price) = 0)             .where(COL(Booking::customer_id) == COL(Customer::id) &&                    COL(Booking::title) == String("noname") &&                    COL(Customer::first_name) == String("Ivanov"))             .exec(transaction);     transaction.commit(); 

DELETE

Аналогично с update-запросом можно удалить запись по primary key:

    Storable<Customer> customer;     auto resultSet = customer.select()             .where(COL(Customer::birthday) == db::null)             .exec(transaction);     for (auto row : resultSet)     {         customer.removeOne(transaction);     }     transaction.commit(); 

Либо через запрос:

    Storable<Booking> booking;     booking.remove()             .ref<Customer>()             .where(COL(Booking::customer_id) == COL(Customer::id) &&                    COL(Customer::second_name) == String("Ivanov"))             .exec(transaction);     transaction.commit(); 

Основное, на что нужно обратить внимание, подзапрос where представляет собой C++ выражение, на основе которого строится абстрактное синтаксическое дерево (AST). Далее это дерево трансформируется в SQL-выражение определенного синтаксиса. Благодаря этому как раз и обеспечивается статическая типизация о которой я упоминал в начале. Также промежуточная форма запроса в виде AST позволяет нам унифицировано описывать запрос независимо от поставщика СУБД, на это мне пришлось затратить некоторое количество усилий. В текущей версии реализована поддержка PostgreSQL, SQLite3 и MariaDB. На ванильном MySQL тоже в принципе должно завестись, но эта СУБД иначе обрабатывает некоторые типы данных, соответственно часть тестов на ней проваливается.

Что еще

Можно описывать пользовательские хранимые процедуры и использовать их в запросах. Сейчас ORM поддерживает некоторые встроенные функции СУБД из коробки (upper, lower, ltrim, rtrim, random, abs, coalesce и т.д.), но можно определить и свои. Вот так, например, описывается функция strftime в SQLite:

namespace sqlite {     inline ExpressionNodeFunctionCall<String> strftime(const String& fmt, const ExpressionNode<DateTime>& dt)     {         return ExpressionNodeFunctionCall<String>("strftime", fmt, dt);     } } 

Кроме того, реализацией ORM не ограничивается возможное применение рефлексии. Похоже, что правильную рефлексию мы еще не скоро получим в C++ (правильная рефлексия должна быть статической, т.е. обеспечиваться на уровне компилятора, а не библиотеки), поэтому можно попытаться использовать данную рализацию для сериализации и интеграции со скриптовыми движками. Но об этом я, может быть, напишу в другой раз, если у кого-то будет интерес.

Чего нет

Основной недочет в модуле SQL — у меня так и не получилось сделать поддержку агрегированных запросов (count, max, min) и группировки (group by). Также, список поддерживаемых СУБД достаточно скуден. Возможно, в будущем сделаю поддержку SQL Server через ODBC.
Кроме того, есть мысли по интеграции с mongodb, тем более, что библиотека позволяет описывать и «неплоские» структуры (с подструктурами и массивами).

Ссылка на репозиторий.

ссылка на оригинал статьи https://habrahabr.ru/post/282660/


Комментарии

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

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