Сразу же предупрежу о велосипедности выдаемого здесь на обозрение. Если прочтение заголовка вызывает лишь с трудом подавляемый возглас «Твою мать, только не новый таксон ORM!», то лучше наверное воздержаться от дальнейшего чтения, дабы не повышать уровень агрессии в космологическом бульоне, в котором мы плаваем. Виной появлению данной статьи явилось то, что в кои-то веки выдался у меня отпуск, в течение которого решил я попробовать себя на поприще написания блогопостов по околохабровской тематике, и предлагаемая тема мне показалась вполне для этого подходящей. Кроме того, здесь я надесь получить конструктивную критику, и возможно понять чего же еще с этим можно сделать этакого интересного. В конце будет ссылка на github-репозиторий, в котором можно посмотреть код.
Для чего нужна еще одна ORM-библиотека
При разработке 3-tier приложений с разделенными слоями представления (Presentation tier), бизнес-логики (Logic tier) и хранения данных (Data tier) неизменно возникает проблема огранизации взаимодействия компонентов приложения на стыке этих слоев. Традиционно интерфейс к реляционным базам данных предоставляется на основе языка SQL-запросов, но его использование напрямую из уровня бизнес-логики обычно сопряжено с рядом проблем, часть из которых легко решается применением ORM (Object-relational mapping):
- Необходимость представления сущностей в двух формах: объектно-ориентированной и реляционной
- Необходимость преобразования между этими двумя формами
- Подверженность ошибкам при ручном написании SQL-запросов (частично может решаться использованием различных lint-утилит и плагинов к современным IDE)
Наличие такого простого решения этих проблем привело к появлению изобилия различных реализаций ORM на любой вкус и цвет (список есть на википедии). Несмотря на обилие существующих решений, всегда найдутся извращенцы «гурманы» (автор из их числа), вкусы которых невозможно удовлетворить существующим ассортиментом. А как же иначе, это же ширпотреб, а наш проект слишком уникален, и существующие решения нам просто не подходят (это сарказм, подпись К.О.).
Наверное подобные максималистичные мысли руководили и мной, когда пару лет назад я взялся за написание ORM под свои нужды. Вкратце все-таки опишу, что было не так с теми ORM, которые я пробовал и что хотелось в них исправить.
- Во-первых это потребность в статической типизации, которая бы позволяла отлавливать большую часть ошибок при написании запросов к СУБД еще во время компиляции, а следовательно значительно ускорила бы скорость разработки.
Условие для реализации: это должен быть разумный компромис между уровнем проверки запросов, временем компиляции (что в случае C++ сопряжено также с отзывчивостью IDE) и читабельности кода. - Во-вторых это гибкость, возможность писать произвольные (в разумных пределах) запросы. На практике этот пункт сводится к возможности написания СУПО (создать-удалить-получить-обновить) запросов с произвольными WHERE-подвыражениями и возможности выполнения кросс-табличных запросов.
- Далее следует поддержка СУБД различных поставщиков на уровне «программа должна продолжать корректно работать при перескакивании с одной СУБД на другую».
- Возможность переиспользования рефлексии ORM для других нужд (сериализации, script-binding, фабрик отвязанных от реализации и пр.). Что уж говорить, чаще всего рефлексия в существующих решениях «прибита гвоздями» к ORM.
- Все-таки не хочется зависеть от генераторов кода а-ля 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/
Добавить комментарий