Нова SQLite ORM для C++

Всім привіт. Пишу на Хабре вперше, не судіть строго. Хочу поділитися своїм досвідом пошуку універсальної SQLite ORM бібліотеки на С++ і моєю новою розробкою власної бібліотеки для роботи з SQLite на C++ sqlite_orm.
Коли я шукав ORM'ку я відштовхувався від декількох ключових пунктів:
  • бібліотека повинна мати як CRUD, так і не CRUD
  • повинні бути гнучкі умови WHERE, а не тупо
    WHERE id = ?
  • повинен бути функціонал міграцій (синхронізації схеми) на випадок оновлень додатка
  • фічі начебто ORDER BY і LIMIT теж повинні бути
  • серіалізація класів не повинна бути написана в своїх класах. Це дуже важливий пункт для мене з тих пір, як я познайомився з Java і з Android-розробкою зокрема. Android-розробники намагаються дотримуватися принципу єдиної відповідальної (single responsibility principle), що дуже важливо якщо додаток зібрано з різною купи бібліотек і модулів, які можуть змінюватися з плином часу. І тому найпопулярніша на github SQLite ORM'ка на С++ hiberlite мене не влаштувала способом серіалізації — клас моделі повинен мати статичну функцію
    serialize
    з кодом безпосередній серіалізації. Я шукав такий модуль, від якого би не залежав код моєї моделі даних. Адже у мене може бути кілька сериализаторов (JSON, XML, SQLite), і по-хорошому кожен повинен додаватися до моделі даних, але ніяк її не міняти, а інакше вийде каша в коді моделі.
  • — код в стилі стандартної бібліотеки — останнім часом цей тренд набирає популярність (взагалі мене вразила ця бібліотека
  • підтримка як БД на файловій системі, так і в пам'яті
  • залишати можливість іменувати таблиці і колонки розробнику незалежно від назв класів і їх полів на випадок якщо все-таки потрібно залізь в БД через SQLite-клієнт щоб все було очевидно
  • транзакції
також
hiberlite
є ще купа різних бібліотек, але вони чомусь мають невеликий функціонал. Інакше кажучи, працюючи з ними вийде розробнику все одно доведеться писати код прямого підключення до БД за допомогою
libsqlite3
, тоді навіщо така ORM'ка взагалі потрібна?
Здається, я затягнув з набранням, перейду безпосередньо до можливостей, які надає бібліотека sqlite_orm.
1) CRUD
Приклад:
struct User{
int id;
std::string firstName;
std::string lastName;
int birthDate;
std::shared_ptr<std::string> imageUrl; 
int typeId;
};

struct UserType {
int id;
std::string name;
};

Два класи, значить дві таблиці.
Взаємодія відбувається через об'єкт
storage
, який являє собою сервіс-об'єкт з інтерфейсом до БД.
storage
створюється функцією
make_storage
. При створенні вказується схема.
using namespace sqlite_orm;
auto storage = make_storage("db.sqlite",
make_table("users",
make_column("id",
&User::id,
autoincrement(),
primary_key()),
make_column("first_name",
&User::firstName),
make_column("last_name",
&User::lastName),
make_column("birth_date",
&User::birthDate),
make_column("image_url",
&User::imageUrl),
make_column("type_id",
&User::typeId)),
make_table("user_types",
make_column("id",
&UserType::id,
autoincrement(),
primary_key()),
make_column("name",
&UserType::name,
default_value("name_placeholder"))));

Зверніть увагу, що модель даних "не в курсі" про сховище. Також ім'я колонки і ім'я поля класу не залежать один від одного ніяк. Це дозволяє писати код кемел-кейсом, наприклад, а схему БД через підкреслення як це роблю я.
make_storage
перший параметр ім'я файлу, потім йдуть таблиці. Для створення таблиці вказуємо ім'я таблиці (воно ніяк не пов'язано з класом, бо якщо зробити автоматичне іменування, то реалізація буде не дуже: слід використовувати
typeid(T).name()
, яка повертає не завжди чітке ім'я, а швидше системне ім'я, або хитрувати з макросами, що я в цілому не схвалюю), потім вказуємо колонки. Для створення однієї колонки потрібно мінімум два параметри: ім'я колонки і посилання на полі класу. За цим посиланням визначиться тип колонки та адреса для присвоювання надалі. Також можна навздогін додати
AUTOINCREMENT
та/або
PRIMARY KEY
,
DEFAULT
.
Тепер можна посилати запити в БД через виклики функцій об'єкта
storage
. Наприклад, давайте створимо користувача і зробимо
INSERT
.
User user{-1, "Jonh", "Doe", 664416000, std::make_shared<std::string>("url_to_heaven"), 3 };

auto insertedId = storage.insert(user);
cout << "insertedId = " << insertedId << endl; 
user.id = insertedId;

Зараз ми надіслали
INSERT INTO users(first_name, last_name, birth_date, image_url, type_id) VALUES('Jonh', 'Doe', 664416000, 'url_to_heaven', 3)
.
Перший аргумент -1 який ми вказали при створенні об'єкта користувача це id. Він ігнорується при створенні, так як id це PRIMARY KEY колонка.
sqlite_orm
ігнорує PRIMARY KEY колонку при INSERT'е і повертає id тільки що створеного об'єкта. Тому після INSERT'а ми робимо
user.id = insertedId;
— після цього користувач повноцінний і може бути використаний далі в коді.
Щоб отримати цього ж користувача використовується функція
get
:
try{
auto user = storage.get<User>(insertedId);
cout << "user = " << user.firstName << " " << user.lastName << endl;
}catch(sqlite_orm::not_found_exception) {
cout << "user not found with id " << insertedId << endl;
}catch(...){
cout << "unknown exeption" << endl;
}

get
повертає об'єкт класу
User
(який ми передали в якості параметра шаблону). У разі якщо користувач з таким id викидається виключення
sqlite_orm::not_found_exception
. Такий інтерфейс з виключенням може бути незручний. Причина цього в тому, що в С++ просто об'єкт не може бути занулений як це може бути зроблено в Java, C# або Objective-C. як зануляемого типу можна використовувати
std::shared_ptr<T>
. Для такого випадку є друга версія функції
get
get_no_throw
:
if(auto user = storage.get_no_throw<User>(insertedId)){
cout << "user = " << user->firstName << " " << user->lastName << endl;
}else{
cout << "no user with id " << insertedId << endl;
}

Тут user
std::shared_ptr<User>
та може бути дорівнює
nullptr
, а може і зберігати в собі.
Далі ми можемо захотіти зробити
UPDATE
користувача. Для цього ми змінимо поля які хочемо змінити і викличемо функцію
update
:
user.firstName = "Nicholas";
user.imageUrl = "https://cdn1.iconfinder.com/data/icons/man-icon-set/100/man_icon-21-512.png"
storage.update(user);

Працює це так: викликається
UPDATE users SET ...всі значення колонок без primary key... WHERE id = %значення поля, яке пов'язане з колонкою, у якій primary key%
.
Все просто. Зверніть увагу, що немає ніяких proxy-об'єктів для взаємодії з сховище — сховище приймає і повертає "чисті" об'єкти моделей. Це спрощує роботу і знижує поріг входження.
Видалення об'єкта з id реалізовано ось так:
storage.remove<User>(insertedId);

Тут потрібно явно вказувати тип як параметр шаблону, так як нізвідки його вгадати компілятору.
На цьому CRUD закінчується. Але цим не обмежується функціонал. CRUD-функції
sqlite_orm
— це функції, які працюють тільки з об'єктами у яких є одна колонка з
PRIMARY KEY
. Також є не-CRUD функції.
Наприклад, давайте зробимо
SELECT * FROM users
.
auto allUsers = storage.get_all<User>();
cout << "allUsers (" << allUsers.size() << "):" << endl;
for(auto &user : allUsers) {
cout << storage.dump(user) << endl;
}

Мінлива
allUsers
має тип
std::vector<User>
. Зверніть увагу на функцію
dump
— вона приймає об'єкт класу, який пов'язаний з сховищем, і повертає інформацію про нього в json-стилі у вигляді
std::string
. Наприклад "{ id: '1', first_name: 'Jonh', last_name: 'Doe', birth_date: '664416000', image_url: '0x10090c3d8', type_id: '3' }".
Але цього мало. ORM-бібліотеку не можна вважати повноцінною без умов WHERE. Тому в
sqlite_orm
вони теж є, при цьому дуже потужні.
Вище згадана функція
get_all
може приймати в якості аргументу результат функції
where
з умовами. Наприклад, давайте виберемо користувачів, у яких id менше 10. Запит повинен мати такий вигляд:
SELECT * FROM users WHERE id < 10
. У коді це виглядає так:
auto idLesserThan10 = storage.get_all<User>(where(lesser_than(&User::id, 10)));

Або виберемо користувачів у яких поле firstName не одно "John". Запит —
SELECT * FROM users WHERE first_name != 'John'

auto notJohn = storage.get_all<User>(where(is_not_equal(&User::firstName, "John"))); 

Більш того можна "підмішувати" оператори
&&
,
||
та
!
(для більшої наочності краще використовувати буквені версії цих операторів
and
,
or
та
not
).
auto notJohn2 = storage.get_all<User>(where not is_equal(&User::firstName, "John")));

notJohn2
еквівалентний
notJohn
.
І ще приклад зі зчепленими умовами:
auto id5and7 = storage.get_all<User>(where(lesser_or_equal(&User::id, 7) and greater_or_equal(&User::id, 5) and not is_equal(&User::id, 6)));

Це ми реалізували запит
SELECT * FROM users WHERE where id >= 5 and id <= 7 and not id = 6
.
Чи
SELECT * FROM users WHERE id = 10 or id = 16
:
auto id10or16 = storage.get_all<User>(where(is_equal(&User::id, 10) or is_equal(&User::id, 16)));

Так можна "склеювати" будь-які комбінації умов. Більш того, можна вказувати пріоритет умов за допомогою дужок як в "сирих запитах" в SQLite. Наприклад ці два запиту відрізняються возвращаемыми результатами:
auto cuteConditions = storage.get_all<User>(where((is_equal(&User::firstName, "John") or is_equal(&User::firstName, "Alex")) and is_equal(&User::id, 4)));
cuteConditions = storage.get_all<User>(where(is_equal(&User::firstName, "John") or (is_equal(&User::firstName, "Alex") and is_equal(&User::id, 4)))); 

У першому умови
WHERE (first_name = 'John' or first_name = 'Alex') and id = 4
, у другому —
WHERE first_name = 'John' or (first_name = 'Alex' and id = 4)
.
ця Магія працює за рахунок того, що в С++ дужки мають таку ж функцію явного визначення пріоритету операцій. Плюс сама
sqlite_orm
є лише зручним фронтэндом для роботи з SQLite C++, вона (бібліотека) сама не виконує запити, а тільки трансформує їх у текст і відправляє движку sqlite3.
Також є оператор
IN
:
auto evenLesserTen10 = storage.get_all<User>(where in(&User::id, {2, 4, 6, 8, 10})));

Вийшло
SELECT * FROM users WHERE id IN (2, 4, 6, 8, 10)
. Або ось для рядків:
auto doesAndWhites = storage.get_all<User>(where in(&User::lastName, {"Doe", "White"}))); 

Тут ми відправили запит
SELECT * FROM users WHERE last_name IN ("Doe", "White")
в БД.
Функція
in
приймає два аргументи: вказівник на поле класу і вектор/список ініціалізації. Тип вмісту вектора/списку ініціалізації той же самий, що і у поля вказівник на який ми передали в якості першого параметра.
Опції умов
is_equal
,
is_not_equal
,
greater_than
,
greater_or_equal
,
lesser_than
,
lesser_or_equal
приймають по два аргументи. Аргументами можуть бути як покажчики на поля класів, так і константи/змінні. Покажчики на поля парсятся в запит імена колонок, а літерали як є, тільки рядки ще обзаводяться лапками по краях.
У вас може виникнути питання: а що якщо я передам в умова вказівник на поле класу, яке не вказано ні в одній колонці? В такому випадку буде викинуто виняток
std::runtime_error
з пояснювальним текстом. Те ж саме буде, якщо ви вкажете тип, який не прив'язаний до сховища.
до Речі, умови
WHERE
можна використовувати в запитах
DELETE
. Для цього є функція
remove_all
. Наприклад, давайте видалимо всіх користувачів, у яких id менше 100:
storage.remove_all<User>(where(lesser_than(&User::id, 100)));

Всі приклади вище оперують повноцінними об'єктами. А що якщо ми хочеш викликати
SELECT
однієї колонки? Таке теж є:
auto allIds = storage.select(&User::id); 

Це ми викликали
SELECT id FROM users
.
allIds
має тип
std::vector<decltype(User::id)>
або
std::vector < int>
.
Можна додати умови:
auto doeIds = storage.select(&User::id, where(is_equal(&User::lastName, "Doe"))); 

Як ви вже здогадалися це
SELECT id FROM users WHERE last_name = 'Doe'
.
Варіантів може бути багато. Наприклад, можна запросити всі прізвища, де id менше, ніж 300:
auto allLastNames = storage.select(&User::lastName, where(lesser_than(&User::id, 300))); 

ORDER BY
ORM ні ORM без упорядкування.
ORDER BY
використовується у багатьох проектах, і
sqlite_orm
має інтерфейс для нього.
найпростіший приклад — давайте виберемо користувачів впорядкованих по id:
auto orderedUsers = storage.get_all<User>(order_by(&User::id));

Це перетворюється в
SELECT * FROM users ORDER BY id
. Або давайте змішаємо
where
та
order_by
:
SELECT * FROM users WHERE id < 250 ORDER BY first_name

auto orderedUsers2 = storage.get_all<User>(where(lesser_than(&User::id, 250)), order_by(&User::firstName));

Також можна вказувати явно
ASC
та
DESC
. Наприклад:
SELECT * FROM users WHERE id > 100 ORDER BY first_name ASC
:
auto orderedUsers3 = storage.get_all<User>(where(greater_than(&User::id, 100)), order_by(asc(&User::firstName)));

Або ось:
auto orderedUsers4 = storage.get_all<User>(order_by(desc(&User::id)));

Вийшло
SELECT * FROM users ORDER BY id DESC
.
І, звичайно ж, просто
select
так само працює з
order_by
:
auto orderedFirstNames = storage.select(&User::firstName, order_by(desc(&User::id)));

Вийшло
SELECT first_name FROM users ORDER BY ID DESC
.
Міграції
Міграцій як таких у бібліотеці немає, зате є функція
sync_schema
. Виклик цієї функції запитує у БД нинішню схему, порівнює з тією, яка вказувалася при створенні сховища, і якщо щось не збігається, править її. При цьому збереження вже наявних даних цей виклик не гарантує. Він тільки гарантує, що схема стане ідентичною (або буде викинуте
std::runtime_error
. Докладніше про те, за якими правилами проходить синхронізація схеми можна дізнатися на сторінці репозиторію на github.
Транзакції
В бібліотеці є два варіанти реалізації транзакцій: явний і неявний. Явний передбачає прямий виклик функцій
begin_transaction
та
commit
або
rollback
. Приклад:
auto secondUser = storage.get<User>(2);

storage.begin_transaction();
secondUser.typeId = 3;
storage.update(secondUser);
storage.rollback(); // або storage.commit();

secondUser = storage.get<decltype(secondUser)>(secondUser.id);
assert(secondUser.typeId != 3);

Другий спосіб трохи хитріше. Спочатку код:
storage.transaction([&] () mutable { 
auto secondUser = storage.get<User>(2);
secondUser.typeId = 1;
storage.update(secondUser);
auto gottaRollback = bool(rand() % 2);
if(gottaRollback){ // тупо умова для тіста
return false; // виходить з лямбды і викликає ROLLBACK
}
return true; // виходить з лямбды і викликає COMMIT
});

Функція
transaction
викликає
BEGIN TRANSACTION
відразу і приймає лямбда-вираз в якості аргументу, яке повертає
bool
. Якщо повернути
true
, то буде виконаний
COMMIT
, якщо
false
ROLLBACK
. Цей метод гарантує, що ви не забудете викликати функцію закінчення транзакції (як
std::lock_guard
в мьютексом в стандартній бібліотеці).
Також є агрегатні функції
AVG
,
MAX
,
MIN
,
COUNT
,
GROUP_CONCAT
:
auto averageId = storage.avg(&User::id); // 'SELECT AVG(id) FROM users'
auto averageBirthDate = storage.avg(&User::birthDate); // 'SELECT AVG(birth_date) FROM users'
auto usersCount = storage.count<User>(); // 'SELECT COUNT(*) FROM users'
auto countId = storage.count(&User::id); // 'SELECT COUNT(id) FROM users'
auto countImageUrl = storage.count(&User::imageUrl); // 'SELECT COUNT(image_url) FROM users'
auto concatedUserId = storage.group_concat(&User::id); // 'SELECT GROUP_CONCAT(id) FROM users'
auto concatedUserIdWithDashes = storage.group_concat(&User::id, "---"); // 'SELECT GROUP_CONCAT(id "---") FROM users'
auto maxId = storage.max(&User::id); // 'SELECT MAX(id) FROM users'
auto maxFirstName = storage.max(&User::firstName); // 'SELECT MAX(first_name) FROM users'
auto minId = storage.min(&User::id); // 'SELECT MIN(id) FROM users'
auto minLastName = storage.min(&User::lastName); // 'SELECT MIN(last_name) FROM users'

Більш докладно можна прочитати тут. Контрибутинг вітається як і критика.
Джерело: Хабрахабр

0 коментарів

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