Новая 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>. Для такого случая есть вторая версия функции 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.

Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 53
  • +3
    а как же… связи?
    • 0

      в нынешней версии библиотеки связей нет. Вообще уже был запрос один добавить foreign key. В целом это сделать несложно, вопрос в другом — нужно ли это во встраиваемой БД. Лично я не использую связи даже при их логическом наличии, так как это создает runtime overhead. Однако спасибо за отзыв — в дальнейших версиях обязательно будет добавлена функция foreing_key со ссылкой на поле другого класса.

      • +1
        Да, понятно, что сделать несложно. Но, в целом, в качестве связей для меня больше важна возможность достать связанные foreign keyем сущности, нежели чем ограничение существования той самой связанной сущности

        Просто я тоже искал «идеальную» ORM для с++, но ничего «годного» не нашел. Вы пытаетесь сделать решение, лучшее, чем существующие, и в этом случае без этих фич никуда. Попробовав, например, hibernate для java или sequelize для node.js вряд ли Вы будете делать запросы руками, которые за Вас может сделать ORM, даже если это просто. Я бы с радостью перевел некоторые свои поделки на с++, как на свой первый и «родной» язык, но пока все ORM, на которые я смотрел имеют только лишь недостатки. Select у Вас выглядит, действительно, очень хорошо, но, согласитесь, достаточно узкая специализация получилась, без join'ов, и, как уже указано leschenko group by и select field1, field2… а не select * (и, кстати, offset'а)
        • 0

          Расскажите, пожалуйста, поподробнее про "возможность достать связанные foreign keyем сущности". Я так понимаю это что-то типа взяли юзера, у него есть поле category_id, достали категорию с этим id?

          • +1

            Причем обычно это можно делать как с помощью +1 запроса, так и через JOIN.

            • +1
              Да, Вы все правильно поняли. Это в том числе работает со связями многие-ко-многим и рекурсивно. Например, получить 10 книг, отсортированных по дате создания, у каждой получить по 3 категории, которые не имеют родителя. Это, разумеется, просто сделать руками, но в sequelize-like синтаксисе будет выглядеть примерно так:
              Book.findAll({
                  limit: 10,
                  order: [['createdAt', 'DESC']],
                  include: [{
                      model: Category,
                      as: 'categories',
                      limit: 3,
                      where: {
                          parentId: null
                      },
                      attributes: ['id', 'title'] //Зачем получать лишнюю информацию из базы?
                  }]
              });
              
              • 0

                В вашем примере есть вложенные сущности, а они не поддерживаются в sqlite_orm просто потому что это усложняет основу системы. Более того, взаимновложенные сущности не скомпилируются в С++ вообще (при условии агрегации).


                А то, о чем вы говорите, реализуется в sqlite_orm вот так:


                struct Book {
                    int id;
                    std::string name;
                    int categoryId;
                    int createdAt;
                };
                
                struct Category {
                    int id;
                    std::string name;
                };
                
                using namespace sqlite_orm;
                auto storage = make_storage("db.sqlite",
                                            make_table("books",
                                                       make_column("id", &Book::id, primary_key()),
                                                       make_column("name", &Book::name),
                                                       make_column("category_id", &Book::categoryId),
                                                       make_column("created_at", &Book::createdAt)),
                                            make_table("categories",
                                                       make_column("id", &Category::id, primary_key()),
                                                       make_column("name", &Category::name)));
                
                //  лимита нет, каюсь, в ближайших коммитах будет
                auto books = storage.get_all<Book>(order_by(&Book::createdAt));
                // теперь вытащим вектор из id категорий
                std::vector<decltype(Book::categoryId)> categoryIds;
                categoryIds.reserve(books.size());
                std::transform(books.begin(); 
                               books.end();
                               std::back_inserter(categoryIds),
                               [](auto &book) {
                                   return book.categoryId;
                               });
                // std::transform может быть неудобен, но это православная stl-реализация функции map
                
                auto categories = storage.get_all<Category>(where(in(&Category::id, categoryIds)));
                // теперь books содержит выбранные книги, а categories - их категории

                Или можно сымитировать сложные запрос:


                auto books = storage.get_all<Book>(order_by(&Book::createdAt));
                auto categories = storage.get_all<Category>(where(in(&Category::id, 
                    storage.select(&Book::categoryId)
                )));
                • +1
                  Вы немного неправильно меня поняли, я имел в виду связь между книгой и категорией многие-ко-многим (+сущность BookCategory с соответствующими айдишниками) и, соответственно, выдирать категории именно для конкретной книги, к которой они привязаны. Для этого случая ваш код еще больше усложнится и будет еще менее поддерживаемый.

                  Я понимаю, что пока Вы такие случаи не рассматриваете, но они встречаются очень часто в реальной жизни. Как ORM для несвязанных между собой таблиц получилось неплохо, и, надеюсь, полностью покрывает ваши нужды ;)
                  • 0

                    расскажите поподробнее. Изначально цель sqlite_orm покрыть бОльшую часть использования sqlite3 в приложениях, а значит ваш кейс особенно интересен. Покажите схему как хотите хранить отношения многие ко многим. Отдельным словарем или как?

                    • +1
                      Я сейчас говорил не о именно sqlite3, просто о БД. Но не думаю, что таких кейсов использования нет в embedded

                      Вы все сами уже ниже описали ;)
                      CREATE TABLE books(id INTEGER PRIMARY KEY, name TEXT);
                      CREATE TABLE categories(id INTEGER PRIMARY KEY, name TEXT)
                      CREATE TABLE books_categories(category_id INTEGER, book_id INTEGER)
                      

                      Ничего хитрее я не имел в виду
                      • 0

                        Понял, в каком финальном виде вы хотите получить данные?

                        • 0
                          В каком-нибудь таком:
                          struct Category;
                          
                          struct Book {
                              int id;
                              std::string name;
                              int createdAt;
                              std::vector<Category> categories;
                          };
                          
                          struct Category {
                              int id;
                              std::string name;
                              std::vector<Book> books;
                          };
                          

                          И что-нибудь типа такого в описании:
                          make_table("books", make_many_to_many_association("categories", &Book::categories, &Category::books, through(&BookCategories)));
                          

                          Тут скорее всего надо еще руками определить `BookCategories` и привязывать не к `&Category::books` а к `&BookCategories::bookId`, но в идеале это делать автоматически.
                          Пишу «типа такого», потому что если бы у меня было четкое представление как это сделать, я бы это давно уже сделал ;)
                          • 0

                            Идея отличная, только структуры схеме не соответствуют. Это создает неочевидности. Я не говорю, что вы что-то говорите неверно, я пытаюсь понять как лучше добавить возможность вложенных сущностей.


                            Вообще при схеме (добавил NOT NULL так как зануляемые значения мапятся в указатели только)


                            CREATE TABLE books(id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL);
                            CREATE TABLE categories(id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL)
                            CREATE TABLE books_categories(category_id INTEGER NOT NULL, book_id INTEGER NOT NULL)

                            Достать все категории конкретной книги можно так:


                            struct Book {
                                int id;
                                std::string name;
                            };
                            
                            struct Category {
                               int id;
                               std::string name;
                            };
                            
                            struct BookCategory {
                               int categoryId;
                               int bookId;
                            };
                            
                            using namespace sqlite_orm;
                            auto storage = make_storage("db.sqlite",
                                                        make_table("books",
                                                                   make_column("id", &Book::id, primary_key()),
                                                                   make_column("name", &Book::name)),
                                                        make_table("categories",
                                                                   make_column("id", &Category::id, primary_key()),
                                                                   make_column("name", &Category::name)),
                                                        make_table("books_categories",
                                                                   make_column("category_id", &BookCategory::categoryId),
                                                                   make_column("book_id", &BookCategory::bookId)));
                            
                            auto book = storage.get<Book>(2);
                            auto categories = storage.get_all<Category>(where(in(&Category::id,
                               storage.select(&BookCategory::categoryId, where(is_equal(&BookCategory::bookId, book.id)))
                            )));
                            • +1

                              Разумеется, так можно. И нужно делать именно так, если ORM не поддерживает вложенные сущности. Но, согласитесь, хочется спихнуть весь этот (можно сказать служебный и практически ненужный) код на ORM


                              структуры схеме не соответствуют

                              Все правильно, они и не должны. Если я хочу добавить transient поле в структуру, я его туда добавлю, но не буду добавлять в make_column. Ну и по-хорошему в структуре пометить их комментарием, что они не сериализуются.


                              Здесь еще тонкий момент со связями не многие-ко-многим. Придется писать в структуре или experimental::optional<T> или, как в вашем решении std::shared_ptr<T>, что, безусловно, снизит читаемость кода

                              • 0
                                Все правильно, они и не должны.

                                Что?
                                sqlite_orm как и любая другая sqlite ORM библиотека отправляет запросы в первую очередь в SQLite. И разработчик работая с ORM или еще каким-угодно фронтэндом БД должен понимать в какие запросы его клиентский код вырождается в результате. Так вот: когда классы соответствуют схеме — это делает код очень читабельным в плане "где и какой sql запрос отправляется". Иначе повышается шанс быдлокодинга, так как новичок может не понимать какие запросы под собой подразумевает вот эта простая и удобная в использовании функция. Именно поэтому последний пример


                                auto categories = storage.get_all<Category>(where(in(&Category::id,
                                   storage.select(&BookCategory::categoryId, where(is_equal(&BookCategory::bookId, book.id)))
                                )));

                                идеально иллюстрирует вложенный запрос:


                                SELECT * FROM categories WHERE id IN (
                                   SELECT category_id FROM books_categories WHERE book_id = ?
                                )

                                Второе: поддержки std::experimental::optional<T> в sqlite_orm нет пока он не выйдет из experimental namespace (ожидается в С++17). Опциональные значения используют std::shared_ptr<T> либо std::unique_ptr<T>. Это неудобно, согласен. Я думал добавить сырые С-указатели, но этого не будет — вместо этого я добавлю трэйты чтобы можно было подключать свои типы полей — собственные строки, собственные опционалы. Там уже разработчик если хочет — добавит С-указатели или Glib::ustring, QString etc.


                                А вообще у меня мысли такие: сделать возможность хранить вложенную сущность (пока только в одном экземпляре) в виде поля типа std::shared_ptr<T> где T это другой класс также связанный с хранилищем. Насчет векторов тут надо подумать. Ваш совет про make_many_to_many_association очень интересен

                                • 0
                                  когда классы соответствуют схеме

                                  Я хотел лишь сказать, что если я захочу добавить в структуру какое-то поле, например, немного надуманное поле shouldHavePassport пользователю, которое не хранится в БД, а вычисляется в триггере или еще где-нибудь потом как user.age >= 14 или еще и учитывает страну, то будет нелогично не добавлять его в структуру. Его стоит указать несериализуемым, да, но добавлять в структуру просто необходимо, в то время как к базе оно может не иметь никакого отношения.


                                  идеально иллюстрирует вложенный запрос

                                  О, спасибо, теперь я понял что мне напомнила ваша ORM. Текстовое представление regexp'ов


                                  Я думал добавить сырые С-указатели, но этого не будет

                                  Правильно, не надо, мы живем в С++1(4|7)


                                  тут надо подумать

                                  Тут думать не передумать. Посмотрите в строну sequelize. К сожалению, не все их решения можно перевести на С++, но как позаимствовать идеи, синтаксис и некоторые фичи — очень полезно будет

                                  • 0

                                    Добавить поля, которые не сериализуются, в структуру не составляет проблем. Это даже подразумевается либой. Просто не создавайте колонку при создании хранилища со ссылкой на это поле и все

                                    • +1

                                      Я так и понял, я не понял к чему тогда относился комментарий


                                      структуры схеме не соответствуют
                                      • 0

                                        Тут другой случай — тут не просто абы-какие поля, о которых хранилище не знает, тут поля, типы которых связаны с хранилищем, но не в том виде, в котором они оформлены внутри структур. Это порождает несоответствие схемы в бд схеме доменной модели в программе и делает код менее читабельным, и, как следствие, менее поддерживаемым. А если у меня просто будет лишнее поле, которое не нужно сериализоваться (хэш имени пользователя, например), то это не породит неясностей, так как ясно, что это приблуда

                                        • +1

                                          А, Вы про то, что в базе написано categoryId, а в структуре category, например?

                                          • 0

                                            да

                                            • 0

                                              В таком случае не знаю, у меня никогда такая трактовка не вызывала какого-то непонимания. Если указан примитивный тип — поле, хранимое в БД; не примитивный тип T — связь, когда на другом конце один объект; std::vector<T> — связь, когда эта сущность имеет много других объектов. В таком случае даже не надо думать, как это хранится в БД — через третью таблицу или через строку с id через запятую, если утрировать. Мы просто знаем, что это сущность связана с другой, а имя поля должно однозначно говорить как и зачем.

                                              • 0

                                                окей, тогда мне следует добавить поддержку этого в sqlite_orm. И если один ко многих ясно как сделать, то вектор устроен хитрее — тут надо умно и очевидно указывать какая таблица выполняет роль словаря (в нашем случае это books_categories)

                                                • 0

                                                  Да, именно. В таком случае Вы сможете в промежуточной таблице также хранить какую-нибудь информацию о этой связи. Это, конечно, подбросит Вам еще "парочку" подводных камней ;)

                                                  • 0

                                                    @igorjan94 я понял, теперь я нахожусь в процессе осознания, что вложенные сущности нужны. Но вот такой вопрос от меня: в случае если есть такая схема (буду писать коротко):
                                                    books ( id:int, name:string, category_id:int )
                                                    categories ( id:int, name:string )
                                                    то подразумевается, что получая книгу, в ней есть вложенная категория с указанным id. То есть, ORM за юзера выполняет каскадный SELECT и достает нужную категорию. Это я понял. Но вот вопрос: а что если ссылки взаимные, то есть у категории есть id книги, которую добавили самой последней в эту категорию, например. Так:
                                                    categories ( id:int, name:string, last_added_book_id:int? )
                                                    Тут может быть такая ситуация, когда книга ссылается на категорию, которая ссылается на эту же книгу, которая ссылается на эту же категорию. В данном случае что делает ORM (например, та сама на js, которую, кажется, вы мне рекомендовали как пример)? Попадает в бесконечную рекурсию?

                                                    • 0

                                                      В данном конкретном случае все немного сложнее. Sequelize попадет в рекурсию на моменте создания сущностей. Точнее по какой-то причине они не "создали таблицы, затем alter table'ом добавили foreign key", а по моему личному ощущению примерно так: "создадим таблицы в порядке, обратному топологической сортировки". Это касается исключительно ограничений на foreign key, при выключении которых все замечательно заводится.


                                                      Если же говорить про сам запрос, то нет, не попадет. Ведь Вы в запросе явно или неявно указываете, что нужно доставать из базы


                                                      Book.findAll({
                                                          include: [{
                                                              model: Category,
                                                              as: 'category',
                                                              include: [{
                                                                  model: Book,
                                                                  as: 'last_added_book',
                                                                  include: [{
                                                                      model: Category,
                                                                      as: 'category'
                                                                  }]
                                                              }]
                                                          }]
                                                      });

                                                      В рекурсию может попасть сериализатор, если сущность хранится как ссылка на объект из базы. Но с этим в sequelize я не встречался, встречался только при использовании кривыми руками java+hibernate+jackson

                • 0

                  А есть пример как такой набор будет выглядеть? У меня сильные подозрения что при вводе лимита ОРМ будет создать подзапрос на каждую книгу.

                  • 0

                    Оказывается, в sequelize нельзя указывать limit при many-to-many(мне не нужно было), хотя может быть в версии 4.0 уже и можно


                    Получившийся запрос(без limit: 3 в категориях)
                    SELECT 
                        `Book`.*,
                        `categories`.`id` AS `categories.id`,
                        `categories`.`name` AS `categories.name`,
                        `categories.BookCategories`.`bookId` AS `categories.BookCategories.bookId`,
                        `categories.BookCategories`.`categoryId` AS `categories.BookCategories.categoryId`
                    FROM
                        (SELECT 
                            `Book`.`id`,
                            `Book`.`title`
                        FROM
                            `Books` AS `Book`
                        WHERE
                            (SELECT 
                                    `BookCategories`.`bookId`
                                FROM
                                    `BookCategories` AS `BookCategories`
                                INNER JOIN `Categories` AS `Category` ON `BookCategories`.`categoryId` = `Category`.`id`
                                WHERE
                                    (`Book`.`id` = `BookCategories`.`bookId`)
                                LIMIT 1) IS NOT NULL
                        ORDER BY `Book`.`createdAt` DESC
                        LIMIT 10) AS `Book`
                            INNER JOIN
                        (`BookCategories` AS `categories.BookCategories`
                        INNER JOIN `Categories` AS `categories` ON `categories`.`id` = `categories.BookCategories`.`categoryId`) ON `Book`.`id` = `categories.BookCategories`.`bookId`
                            AND `categories`.`parentId` IS NULL
                    ORDER BY `Book`.`createdAt` DESC;

                    Вообще, я считаю, что если кто-то использует ORM, то он должен понимать, что в такого вида запросах ORM скорее всего сгенерирует неэффективный код. Но зато это будет работать сразу и как ожидается

                    • 0

                      В many-to-many понятно почему нельзя, я не был уверен что join подзапроса позволит сделать лимит в подзапросе, интересный подход. Спасибо.

        • +1
          Ничего не нашел про JOIN, GROUP BY. Пропустил? Самому делать эти операции используя антипаттенр N+1 очень не красиво.
          Можно ли вытянуть не все колонки, а только заданные? Т.е. могу ли я достать из базы только id,name от юзера? Я вот хочу аватарку хранить в базе, но вытягивать ее для отображения простого списка (где нет аватарки) иногда не хочу.

          Без этого ORM это «неполноценный» ORM.
          • 0

            select присутствует для одной колонки только пока. В ближайших коммитах будет интерфейс для выбора нескольких колонок в std::tuple: auto vectorOfTuples = storage.select(columns(&User::id, &User::firstName));.
            Насчет JOIN и GROUP BY покажите примеры из своей практики чтобы я понял вас лучше. Спасибо

            • +1
              Из практики. Есть список книг и категорий. Книга может быть в нескольких категориях.
              Необходимо:
              1. при отображении списка категорий, отображать количество книг в ней — нужен group by.
              2. отображить список книг, выбранной категории — нужен join.
              Можно обойтись и без этого, но с проседанием производительности и большим расходом памяти.
              • 0

                CREATE TABLE books(id INTEGER PRIMARY KEY, name TEXT);
                CREATE TABLE categories(id INTEGER, name TEXT)
                CREATE TABLE books_categories(category_id INTEGER, book_id INTEGER)


                Вы подразумеваете такую схему? И напишите пожалуйста запрос каким бы вы достали данные для пунктов 1 и 2. Спасибо

                • +2
                  select c.name, case when t.cnt is null then 0 else t.cnt end from categories as c
                  left outer join (
                      select link.category_id as id, count(*) as cnt from books_categories as link
                      group by link.category_id
                  ) as t on c.id = t.id
                  

                  и
                  select book.* from books_categories as link
                  inner join books as book on link.book_id = book.id
                  where link.category_id=@categoryId
                  
                  • 0

                    Очень интересный кейс. Чтобы его воспроизвести в sqlite_orm нужно время. А пока в последнем коммите добавлена возможность выбора нескольких колонок как вы и говорили. В конце статьи добавлен пример

          • +2
            Я бы сделал, чтобы get_all возвращал не вектор, а итератор, который выбирает данные по мере своей работы. С вектором у вас все ваши данные оказываются разом в памяти, а их может быть очень много. Синтаксис использования остался бы тот же самый, если к итератору/курсору begin и end приделать.
            • 0

              разумное замечание. Сейчас я размышляю сделать так: оставить get_all, добавить auto it = storage.iterate<User>().begin();. Итератор будет хранить в себе statement указатель, оператор* будет возвращать объект, оператор++ будет двигать statement вперед, storage.iterate<T>.end() будет возвращать итератор с нулевым statement. При присвоении итератору .end() или при очередном оператор++ когда достигнут конец таблицы итератор закрывает statement. В общем, получится forward iterator полностью stl-совместимый. Тут, конечно, есть косяк, что итератор хранит важную инфу, что не совсем stl-style, но с другой стороны можно будет делать так:


              for(auto &user : storage.iterate<User>()) {
                if(user.id == idBeingSearch) break;
              }

              И после break statement закроется, что по сути и есть то, о чем вы говорите — одномоментно в памяти висит только один юзер, а не все сразу.

              • 0

                пунктом про single responsibility

                • +1
                  Спасибо за развёрнутый ответ с примерами. При чём тут SRP? Pragm'ы это разметка класса для внешнего инструмента автоматически строящего необходимый код для доступа к БД. Тот код который вы пишите руками в своей реализации ORM.
                  • 0

                    я не спорю, что pragma это очень важный и нужный инструмент. То же самое можно сказать даже про макросы. Однако речь не об этом, а о том, что при подключении ORM приходится модифицировать код модели. Вот тут https://habrahabr.ru/post/263027/ очень хорошо описано почему так делать не надо

                    • +1
                      Не обязательно. ODB pragma language

                      struct User{
                          int id;
                          std::string firstName;
                          std::string lastName;
                          int birthDate;
                          std::shared_ptr<std::string> imageUrl;      
                          int typeId;
                      };
                      
                      #pragma db object(User)
                      #pragma db member(User::id) id
                      #pragma db member(User::firstName)
                      #pragma db member(User::lastName)
                      #pragma db member(User::birthDate)
                      #pragma db member(User::imageUrl);      
                      #pragma db member(User::typeId)
                      
                      struct UserType {
                          int id;
                          std::string name;
                      };
                      
                      #pragma db object(UserType)
                      #pragma db member(UserType::id) id
                      #pragma db member(UserType::name)
                      
                      /*
                      Можно даже так сделать:
                      
                      #ifdef ODB_COMPILER
                      #  include "user-pragmas.hxx"
                      #endif
                      
                      или из командной строки:
                      --odb-epilogue  '#include "user-pragmas.hxx"'
                      
                      */
                      


                      • +1

                        По правде сказать из всех ORM-библиотек, которые я видел, ODB меня впечатлила больше всего. У меня вопрос: кто именно занимается парсингом прагм? Тут, похоже, есть кастомный предкомпилятор как у Qt (MOC) или у IOD (https://github.com/matt-42/iod) парсер символов

                        • +1
                          Там используется gcc с плагином, котрый генерит код для C++-компилятора. Сам инструмент легко встраивается в любую IDE в качестве Pre-Build step, как МОС и другие подобные. Я написал два крупных проекта (две специализированных CAD/CAM/CAE системы c зачатками ERP и warehouse management, где БД использовалась в качестве хранилища данных) с использованием этой ORM и использовал практически весь арсенал библиотеки. Серьёзных багов не встретил. Очень удобно быстрое переключение между целевыми БД, для отладки SQLite, а для продакшн PostgreSQL, например.
                          • +1
                            Кстати, спасибо за ссылку на IOD. Интересная тема.
                            • 0

                              Пожалуйста. Впервые с IOD я столкнулся вот тут http://siliconframework.org. Может этот фреймворк будет вам тоже интересен.


                              Насчет ODB — классная штука за одним исключением — предкомпилятором. С++ и без того получает много критики за наличие сишного препроцессора. А если я подключу либу с предпредкомпилятором, что тогда получится? А если у меня две? Три? Кто первичен? Кто вторичен? Почему именно так? Например, у меня проект на Qt (у него есть MOC), я подключил ODB (у него своя приблуда), и еще IOD добавил, у него свой скрипт, который генерит заголовок с символами. Это все порождает путаницу и выходит за пределы возможностей] языка. Именно поэтому я такую черную магию не одобряю. Это мое личное мнение. Не сомневаюсь, что те два ваших проекта, которые содержали ODB, отлично функционировали в области взаимодействия с БД.

                              • +1
                                Согласен, тут всё сильно зависит от внешних требований. Я разрабатываю и поддерживаю крупные (для одного человека) CAx/ERP/WRM проекты и могу использовать любые компиляторы, библиотеки, версии, и инструменты лишь бы был результат. ODB я использовал в связке только с MOC (правда ODB и QT слабо пересекаются благодаря тому же SRP ;-)) ). В общем, конечно, решение об использовании того или иного инструмента достаточно ответственное и порой становиться судьбоносным для проекта во всех смыслах.
                                • 0

                                  Посмотрев внимательнее ODB я нашел еще одну важную вещь, которая есть в sqlite_orm и отсутствует в ODB — это возможность именовать столбцы таблиц независимо от имен полей класса. Как бы это смешно не звучало, но если в схеме есть колонка "new", например, то в C++ такой код не скомпилируется. Самое забавное, что если сменить язык на тот, в котором слово "new" не зарезервировано, то все заработает. Я сталкивался с таким лично в разработке (правда, это был enum-маппер, либа https://github.com/aantron/better-enums). Уговорить бэкенд разраба чтобы он поменял имя колонки в таблице только потому что это слово зарезервировано языком, на котором написан один из клиентов, нереально, да и идиоматически это неверно, на моя взгляд.

                • +1
                  Да, не плохо. Фактически, избавляет от многочисленных assigns при де-сериализации.
                  Но если использовать так
                  struct User{
                      int id;
                      int birthDate;
                      char firstName[64];
                      char lastName[64];
                  };
                  

                  то прослойка ORM вроде и не нужна, можно использовать fopen/fwrite.

                  Понимаете, к чему это?

                  C++ располагает к использованию UserType typeId; вместо int userTypeId; т.е. к использованию иерархического дерева объектов вместо таблиц.
                  • 0

                    нет, не понял ни первое, ни второе. 1) почему прослойка не нужна при использовании статичных С-массивов? 2) "С++ располагает к использованию UserType typeId; вместо int userTypeId; т.е. к использованию иерархического дерева объектов вместо таблиц." — вы про вложенные сущности? Если да, то вложенные сущности это ад. Это понимаешь, когда пытаешься масштабировать систему. Вложенные сущности актуальны в проектах с небольшим сроком жизни. Я не спорю — таких очень много, поэтому на вложенные сущности много кто смотрит как на нечто удобное. Плюс в таком виде как вы показали вложенные сущности не скомпилируются если вложение взаимное. Если я вас понял неверно — поясните пожалуйста.

                    • +1
                      Промахнулся, ответил новым комментарием.
                  • +1
                    Как-то у вас все данные «таблично-ориентированы», инкапсуляция хромает. Спору нет, такая структура данных тоже возможна, но ИМХО только если она дает значительное преимущество в чем-то. Как у вас получение типа пользователя выглядит?
                    auto user = storage.get<User>(123);
                    auto types = storage.get_all<UserType>();
                    ...
                    auto iter = std::find_if( std::begin(type), std::end(type), [&user](const UserType & t){ return t.id == user.typeId; } );
                    assert(iter != std::end(type));
                    const UserType & ut = *iter;
                    

                    где обычно ожидается user.type.
                    • 0

                      Вот так это делается


                      auto user = storage.get<User>(123);
                      auto userType = storage.get<UserType>(user.typeId);

                      Точнее "таблично-ориентированность" называется отсутствием вложенных сущностей.


                      По правде сказать вы уже не первый кто говорит про это, и я задумался как можно вложенность реализовать в sqlite_orm

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.