Чергова 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, тим більше, що бібліотека дозволяє описувати і «неплоские» структури (з підструктурами і масивами).

Посилання репозиторій.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.