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

в 12:41, , рубрики: c++, orm, sqlite

Всем привет. Пишу на Хабре впервые, не судите строго. Хочу поделиться своим опытом поиска универсальной 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>. Для такого случая есть вторая версия функции getget_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, если falseROLLBACK. Этот метод гарантирует, что вы не забудете вызвать функцию окончания транзакции (как 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'

Более подробно можно прочитать здесь. Контрибутинг приветствуется как и критика.

Автор: fnc12

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js