Всем привет. Пишу на Хабре впервые, не судите строго. Хочу поделиться своим опытом поиска универсальной 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'
Более подробно можно прочитать здесь. Контрибутинг приветствуется как и критика.
EDIT 1
В последнем коммите добавлена возможность "сырого" select'а нескольких колонок в вектор из туплов (кортежей). Например:
// `SELECT first_name, last_name FROM users WHERE id > 250 ORDER BY id`
auto partialSelect = storage.select(columns(&User::firstName, &User::lastName),
where(greater_than(&User::id, 250)),
order_by(&User::id));
cout << "partialSelect count = " << partialSelect.size() << endl;
for(auto &t : partialSelect) {
auto &firstName = std::get<0>(t);
auto &lastName = std::get<1>(t);
cout << firstName << " " << lastName << endl;
}
EDIT 2
В последнем коммите добавлена поддержка LIMIT
и OFFSET
. Всего есть три варианта использования LIMIT
и OFFSET
:
- LIMIT %limit%
- LIMIT %limit% OFFSET %offset%
- LIMIT %offset%, %limit%
Примеры:
// `SELECT first_name, last_name FROM users WHERE id > 250 ORDER BY id LIMIT 5`
auto limited5 = storage.get_all<User>(where(greater_than(&User::id, 250)),
order_by(&User::id),
limit(5));
cout << "limited5 count = " << limited5.size() << endl;
for(auto &user : limited5) {
cout << storage.dump(user) << endl;
}
// `SELECT first_name, last_name FROM users WHERE id > 250 ORDER BY id LIMIT 5, 10`
auto limited5comma10 = storage.get_all<User>(where(greater_than(&User::id, 250)),
order_by(&User::id),
limit(5, 10));
cout << "limited5comma10 count = " << limited5comma10.size() << endl;
for(auto &user : limited5comma10) {
cout << storage.dump(user) << endl;
}
// `SELECT first_name, last_name FROM users WHERE id > 250 ORDER BY id LIMIT 5 OFFSET 10`
auto limit5offset10 = storage.get_all<User>(where(greater_than(&User::id, 250)),
order_by(&User::id),
limit(5, offset(10)));
cout << "limit5offset10 count = " << limit5offset10.size() << endl;
for(auto &user : limit5offset10) {
cout << storage.dump(user) << endl;
}
Пожалуйста, не забывайте о том, что LIMIT 5, 10
и LIMIT 5 OFFSET 10
имеют разный смысл. Если точно, то LIMIT 5, 10
это LIMIT 10 OFFSET 5
.