Сразу же предупрежу о велосипедности выдаемого здесь на обозрение. Если прочтение заголовка вызывает лишь с трудом подавляемый возглас «Твою мать, только не новый таксон 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, тем более, что библиотека позволяет описывать и «неплоские» структуры (с подструктурами и массивами).

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

Комментарии (12)


  1. knagaev
    28.04.2016 11:56

    Даже как-то не важно что в статье — за КДПВ пять! :)


    1. alien007
      28.04.2016 12:18

      Чего только не найдешь по запросу «wooden bicycle» в Google Картинки


  1. teleavtomatika
    28.04.2016 15:29
    -2

    Я как гляну на ваши С++ после C# мне аж плохо становится. Как же вы себя мучаете. Здесь каждая строчка — боль.


    1. AxisPod
      28.04.2016 16:00

      Ну C++ программеры как фанаты серии игр (Demon|Dark) Souls. Хоть и боль, зато какое удовольствие. Сам перешел сейчас на C#, пока не могу найти времени и дописать IoC-контейнер для C++, не хватает пока резолва списка сервисов и внедрения в свойства. Да и в отличии от C# еще добавлена поддержка динамической загрузки библиотек из коробки, как на C# не катит. Там знатный разврат с шаблонами, но когда оно работает, да еще и при этом на этапе компиляции, удовольствие знатное.


      1. alien007
        28.04.2016 16:42

        Я тоже попеременно переключаюсь с C++ на C# и обратно. И таки да — полностью с Вами согласен, все C++ программисты мазохисты. Но однажды вкусив сего удовольствия забыть его невозможно. Сразу вспоминается фраза из видео про Гитлера о C++17: «And the worst thing is… I still like C++».


      1. teleavtomatika
        29.04.2016 01:29

        Да и в отличии от C# еще добавлена поддержка динамической загрузки библиотек из коробки, как на C# не катит.

        Почему вы считаете, что в C# нет возможности динамически загрузить библиотеку из коробки?

        Там знатный разврат с шаблонами, но когда оно работает, да еще и при этом на этапе компиляции, удовольствие знатное.

        А в чем польза от IoC в режиме компиляции? Зачем мне заморачиваться с контейнерами и разрешениями ссылок в режиме компиляции, если они уже на стадии компиляции известны и определены и могут быть использованы без IoC контейнера?


        1. AxisPod
          29.04.2016 05:45

          > Почему вы считаете, что в C# нет возможности динамически загрузить библиотеку из коробки?
          А я не говорил, что нет. Просто в C# я к примеру связывал тот же Autofac и MEF, чтобы не писать велосипедов, здесь решил сделать всё сразу.

          > А в чем польза от IoC в режиме компиляции?
          Ну я грубо выразился, на деле часть кода раскрутки зависимостей генерируется в compile-time. У меня разделение на Каталог и собственно сам Контейнер, в каталогах большая часть в compile-time, в контейнере уже рантайм. А с другой стороны затем же, зачем и в C#, как бы регистрация сущностей в большинстве случаев не динамическая и все зависимости известны на этапе компиляции, так зачем в C# используют IoC-контейнеры?


    1. oYASo
      29.04.2016 01:57
      +2

      А тут, знаете ли, как с чистокровным гуманитарием, который смотрит на неопределенный интеграл или ряд Тейлора — в каждой строчке боль.


  1. snizovtsev
    28.04.2016 18:45

    Упомяну, что по теме есть еще такая штука как odb. Судя по описанию сделана хорошо. Но лицензия — GPL + коммерческая.


    1. alien007
      28.04.2016 20:00

      Мда. лицензия отпугивает. Из хороших современных ORM недавно наткнулся еще на Sqlpp1. Сейчас активно развивается сообществом.


      1. GamePad64
        28.04.2016 21:03

        Ещё есть такая библиотечка, SOCI. Это не ORM, но на ней удобно написать свою.


        1. alien007
          29.04.2016 09:49

          Честно говоря сомнительное удобство — использовать надстройку, чтобы написать еще одну надстройку. Там могут быть очень тонкие различия в реализации казалось бы одних и тех же операций разными вендорами СУБД, так что при приведении к общему знаменателю мы рано или поздно упремся в ограничения нижестоящей надстройки.
          Но мне нравится, как у них сделана расширяемость пользовательскими типами, попробую что-нибудь подобное сделать.