Pull to refresh

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

Reading time8 min
Views18K


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

Ссылка на репозиторий.
Tags:
Hubs:
+9
Comments12

Articles

Change theme settings