Как стать автором
Обновить

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

Время на прочтение12 мин
Количество просмотров24K

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

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


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:


  1. LIMIT %limit%
  2. LIMIT %limit% OFFSET %offset%
  3. 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.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 26: ↑25 и ↓1+24
Комментарии57

Публикации

Истории

Работа

Программист C++
109 вакансий
QT разработчик
4 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань