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