Всем привет. Пишу на Хабре впервые, не судите строго. Хочу поделиться своим опытом поиска универсальной 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
.
igorjan94
а как же… связи?
fnc12
в нынешней версии библиотеки связей нет. Вообще уже был запрос один добавить
foreign key
. В целом это сделать несложно, вопрос в другом — нужно ли это во встраиваемой БД. Лично я не использую связи даже при их логическом наличии, так как это создает runtime overhead. Однако спасибо за отзыв — в дальнейших версиях обязательно будет добавлена функцияforeing_key
со ссылкой на поле другого класса.igorjan94
Да, понятно, что сделать несложно. Но, в целом, в качестве связей для меня больше важна возможность достать связанные foreign keyем сущности, нежели чем ограничение существования той самой связанной сущности
Просто я тоже искал «идеальную» ORM для с++, но ничего «годного» не нашел. Вы пытаетесь сделать решение, лучшее, чем существующие, и в этом случае без этих фич никуда. Попробовав, например, hibernate для java или sequelize для node.js вряд ли Вы будете делать запросы руками, которые за Вас может сделать ORM, даже если это просто. Я бы с радостью перевел некоторые свои поделки на с++, как на свой первый и «родной» язык, но пока все ORM, на которые я смотрел имеют только лишь недостатки. Select у Вас выглядит, действительно, очень хорошо, но, согласитесь, достаточно узкая специализация получилась, без join'ов, и, как уже указано leschenko group by и select field1, field2… а не select * (и, кстати, offset'а)
fnc12
Расскажите, пожалуйста, поподробнее про "возможность достать связанные foreign keyем сущности". Я так понимаю это что-то типа взяли юзера, у него есть поле
category_id
, достали категорию с этим id?Lachezis
Причем обычно это можно делать как с помощью +1 запроса, так и через JOIN.
igorjan94
Да, Вы все правильно поняли. Это в том числе работает со связями многие-ко-многим и рекурсивно. Например, получить 10 книг, отсортированных по дате создания, у каждой получить по 3 категории, которые не имеют родителя. Это, разумеется, просто сделать руками, но в sequelize-like синтаксисе будет выглядеть примерно так:
fnc12
В вашем примере есть вложенные сущности, а они не поддерживаются в
sqlite_orm
просто потому что это усложняет основу системы. Более того, взаимновложенные сущности не скомпилируются в С++ вообще (при условии агрегации).А то, о чем вы говорите, реализуется в
sqlite_orm
вот так:Или можно сымитировать сложные запрос:
igorjan94
Вы немного неправильно меня поняли, я имел в виду связь между книгой и категорией многие-ко-многим (+сущность BookCategory с соответствующими айдишниками) и, соответственно, выдирать категории именно для конкретной книги, к которой они привязаны. Для этого случая ваш код еще больше усложнится и будет еще менее поддерживаемый.
Я понимаю, что пока Вы такие случаи не рассматриваете, но они встречаются очень часто в реальной жизни. Как ORM для несвязанных между собой таблиц получилось неплохо, и, надеюсь, полностью покрывает ваши нужды ;)
fnc12
расскажите поподробнее. Изначально цель
sqlite_orm
покрыть бОльшую часть использования sqlite3 в приложениях, а значит ваш кейс особенно интересен. Покажите схему как хотите хранить отношения многие ко многим. Отдельным словарем или как?igorjan94
Я сейчас говорил не о именно sqlite3, просто о БД. Но не думаю, что таких кейсов использования нет в embedded
Вы все сами уже ниже описали ;)
Ничего хитрее я не имел в виду
fnc12
Понял, в каком финальном виде вы хотите получить данные?
igorjan94
В каком-нибудь таком:
И что-нибудь типа такого в описании:
Тут скорее всего надо еще руками определить `BookCategories` и привязывать не к `&Category::books` а к `&BookCategories::bookId`, но в идеале это делать автоматически.
Пишу «типа такого», потому что если бы у меня было четкое представление как это сделать, я бы это давно уже сделал ;)
fnc12
Идея отличная, только структуры схеме не соответствуют. Это создает неочевидности. Я не говорю, что вы что-то говорите неверно, я пытаюсь понять как лучше добавить возможность вложенных сущностей.
Вообще при схеме (добавил NOT NULL так как зануляемые значения мапятся в указатели только)
Достать все категории конкретной книги можно так:
igorjan94
Разумеется, так можно. И нужно делать именно так, если ORM не поддерживает вложенные сущности. Но, согласитесь, хочется спихнуть весь этот (можно сказать служебный и практически ненужный) код на ORM
Все правильно, они и не должны. Если я хочу добавить transient поле в структуру, я его туда добавлю, но не буду добавлять в make_column. Ну и по-хорошему в структуре пометить их комментарием, что они не сериализуются.
Здесь еще тонкий момент со связями не многие-ко-многим. Придется писать в структуре или
experimental::optional<T>
или, как в вашем решенииstd::shared_ptr<T>
, что, безусловно, снизит читаемость кодаfnc12
Что?
sqlite_orm
как и любая другая sqlite ORM библиотека отправляет запросы в первую очередь в SQLite. И разработчик работая с ORM или еще каким-угодно фронтэндом БД должен понимать в какие запросы его клиентский код вырождается в результате. Так вот: когда классы соответствуют схеме — это делает код очень читабельным в плане "где и какой sql запрос отправляется". Иначе повышается шанс быдлокодинга, так как новичок может не понимать какие запросы под собой подразумевает вот эта простая и удобная в использовании функция. Именно поэтому последний примеридеально иллюстрирует вложенный запрос:
Второе: поддержки
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
очень интересенigorjan94
Я хотел лишь сказать, что если я захочу добавить в структуру какое-то поле, например, немного надуманное поле
shouldHavePassport
пользователю, которое не хранится в БД, а вычисляется в триггере или еще где-нибудь потом какuser.age >= 14
или еще и учитывает страну, то будет нелогично не добавлять его в структуру. Его стоит указать несериализуемым, да, но добавлять в структуру просто необходимо, в то время как к базе оно может не иметь никакого отношения.О, спасибо, теперь я понял что мне напомнила ваша ORM. Текстовое представление regexp'ов
Правильно, не надо, мы живем в С++1(4|7)
Тут думать не передумать. Посмотрите в строну sequelize. К сожалению, не все их решения можно перевести на С++, но как позаимствовать идеи, синтаксис и некоторые фичи — очень полезно будет
fnc12
Добавить поля, которые не сериализуются, в структуру не составляет проблем. Это даже подразумевается либой. Просто не создавайте колонку при создании хранилища со ссылкой на это поле и все
igorjan94
Я так и понял, я не понял к чему тогда относился комментарий
fnc12
Тут другой случай — тут не просто абы-какие поля, о которых хранилище не знает, тут поля, типы которых связаны с хранилищем, но не в том виде, в котором они оформлены внутри структур. Это порождает несоответствие схемы в бд схеме доменной модели в программе и делает код менее читабельным, и, как следствие, менее поддерживаемым. А если у меня просто будет лишнее поле, которое не нужно сериализоваться (хэш имени пользователя, например), то это не породит неясностей, так как ясно, что это приблуда
igorjan94
А, Вы про то, что в базе написано
categoryId
, а в структуреcategory
, например?fnc12
да
igorjan94
В таком случае не знаю, у меня никогда такая трактовка не вызывала какого-то непонимания. Если указан примитивный тип — поле, хранимое в БД; не примитивный тип
T
— связь, когда на другом конце один объект;std::vector<T>
— связь, когда эта сущность имеет много других объектов. В таком случае даже не надо думать, как это хранится в БД — через третью таблицу или через строку с id через запятую, если утрировать. Мы просто знаем, что это сущность связана с другой, а имя поля должно однозначно говорить как и зачем.fnc12
окей, тогда мне следует добавить поддержку этого в
sqlite_orm
. И если один ко многих ясно как сделать, то вектор устроен хитрее — тут надо умно и очевидно указывать какая таблица выполняет роль словаря (в нашем случае этоbooks_categories
)igorjan94
Да, именно. В таком случае Вы сможете в промежуточной таблице также хранить какую-нибудь информацию о этой связи. Это, конечно, подбросит Вам еще "парочку" подводных камней ;)
fnc12
@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, которую, кажется, вы мне рекомендовали как пример)? Попадает в бесконечную рекурсию?
igorjan94
В данном конкретном случае все немного сложнее. Sequelize попадет в рекурсию на моменте создания сущностей. Точнее по какой-то причине они не "создали таблицы, затем alter table'ом добавили foreign key", а по моему личному ощущению примерно так: "создадим таблицы в порядке, обратному топологической сортировки". Это касается исключительно ограничений на foreign key, при выключении которых все замечательно заводится.
Если же говорить про сам запрос, то нет, не попадет. Ведь Вы в запросе явно или неявно указываете, что нужно доставать из базы
В рекурсию может попасть сериализатор, если сущность хранится как ссылка на объект из базы. Но с этим в sequelize я не встречался, встречался только при использовании кривыми руками java+hibernate+jackson
Lachezis
А есть пример как такой набор будет выглядеть? У меня сильные подозрения что при вводе лимита ОРМ будет создать подзапрос на каждую книгу.
igorjan94
Оказывается, в sequelize нельзя указывать limit при many-to-many(мне не нужно было), хотя может быть в версии 4.0 уже и можно
Lachezis
В many-to-many понятно почему нельзя, я не был уверен что join подзапроса позволит сделать лимит в подзапросе, интересный подход. Спасибо.
leschenko
Ничего не нашел про JOIN, GROUP BY. Пропустил? Самому делать эти операции используя антипаттенр N+1 очень не красиво.
Можно ли вытянуть не все колонки, а только заданные? Т.е. могу ли я достать из базы только id,name от юзера? Я вот хочу аватарку хранить в базе, но вытягивать ее для отображения простого списка (где нет аватарки) иногда не хочу.
Без этого ORM это «неполноценный» ORM.
fnc12
select
присутствует для одной колонки только пока. В ближайших коммитах будет интерфейс для выбора нескольких колонок вstd::tuple
:auto vectorOfTuples = storage.select(columns(&User::id, &User::firstName));
.Насчет
JOIN
иGROUP BY
покажите примеры из своей практики чтобы я понял вас лучше. Спасибоleschenko
Из практики. Есть список книг и категорий. Книга может быть в нескольких категориях.
Необходимо:
1. при отображении списка категорий, отображать количество книг в ней — нужен group by.
2. отображить список книг, выбранной категории — нужен join.
Можно обойтись и без этого, но с проседанием производительности и большим расходом памяти.
fnc12
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. Спасибо
leschenko
и
fnc12
Очень интересный кейс. Чтобы его воспроизвести в
sqlite_orm
нужно время. А пока в последнем коммите добавлена возможность выбора нескольких колонок как вы и говорили. В конце статьи добавлен примерhasu0
Я бы сделал, чтобы get_all возвращал не вектор, а итератор, который выбирает данные по мере своей работы. С вектором у вас все ваши данные оказываются разом в памяти, а их может быть очень много. Синтаксис использования остался бы тот же самый, если к итератору/курсору begin и end приделать.
fnc12
разумное замечание. Сейчас я размышляю сделать так: оставить
get_all
, добавитьauto it = storage.iterate<User>().begin();
. Итератор будет хранить в себе statement указатель, оператор* будет возвращать объект, оператор++ будет двигать statement вперед,storage.iterate<T>.end()
будет возвращать итератор с нулевым statement. При присвоении итератору .end() или при очередном оператор++ когда достигнут конец таблицы итератор закрывает statement. В общем, получится forward iterator полностью stl-совместимый. Тут, конечно, есть косяк, что итератор хранит важную инфу, что не совсем stl-style, но с другой стороны можно будет делать так:И после break statement закроется, что по сути и есть то, о чем вы говорите — одномоментно в памяти висит только один юзер, а не все сразу.
yuriv
А чем не устраивает ODB? (ODB is an open-source, cross-platform, and cross-database object-relational mapping (ORM) system for C++. It allows you to persist C++ objects to a relational database without having to deal with tables, columns, or SQL and without manually writing any mapping code. ODB supports MySQL, SQLite, PostgreSQL, Oracle, and Microsoft SQL Server relational databases as well as C++98/03 and C++11 language standards. It also comes with optional profiles for Boost and Qt which allow you to seamlessly use value types, containers, and smart pointers from these libraries in your persistent C++ classes...)
fnc12
пунктом про single responsibility
yuriv
Спасибо за развёрнутый ответ с примерами. При чём тут SRP? Pragm'ы это разметка класса для внешнего инструмента автоматически строящего необходимый код для доступа к БД. Тот код который вы пишите руками в своей реализации ORM.
fnc12
я не спорю, что pragma это очень важный и нужный инструмент. То же самое можно сказать даже про макросы. Однако речь не об этом, а о том, что при подключении ORM приходится модифицировать код модели. Вот тут https://habrahabr.ru/post/263027/ очень хорошо описано почему так делать не надо
yuriv
Не обязательно. ODB pragma language
fnc12
По правде сказать из всех ORM-библиотек, которые я видел, ODB меня впечатлила больше всего. У меня вопрос: кто именно занимается парсингом прагм? Тут, похоже, есть кастомный предкомпилятор как у Qt (MOC) или у IOD (https://github.com/matt-42/iod) парсер символов
yuriv
Там используется gcc с плагином, котрый генерит код для C++-компилятора. Сам инструмент легко встраивается в любую IDE в качестве Pre-Build step, как МОС и другие подобные. Я написал два крупных проекта (две специализированных CAD/CAM/CAE системы c зачатками ERP и warehouse management, где БД использовалась в качестве хранилища данных) с использованием этой ORM и использовал практически весь арсенал библиотеки. Серьёзных багов не встретил. Очень удобно быстрое переключение между целевыми БД, для отладки SQLite, а для продакшн PostgreSQL, например.
yuriv
Кстати, спасибо за ссылку на IOD. Интересная тема.
fnc12
Пожалуйста. Впервые с IOD я столкнулся вот тут http://siliconframework.org. Может этот фреймворк будет вам тоже интересен.
Насчет ODB — классная штука за одним исключением — предкомпилятором. С++ и без того получает много критики за наличие сишного препроцессора. А если я подключу либу с предпредкомпилятором, что тогда получится? А если у меня две? Три? Кто первичен? Кто вторичен? Почему именно так? Например, у меня проект на Qt (у него есть MOC), я подключил ODB (у него своя приблуда), и еще IOD добавил, у него свой скрипт, который генерит заголовок с символами. Это все порождает путаницу и выходит за пределы возможностей] языка. Именно поэтому я такую черную магию не одобряю. Это мое личное мнение. Не сомневаюсь, что те два ваших проекта, которые содержали ODB, отлично функционировали в области взаимодействия с БД.
yuriv
Согласен, тут всё сильно зависит от внешних требований. Я разрабатываю и поддерживаю крупные (для одного человека) CAx/ERP/WRM проекты и могу использовать любые компиляторы, библиотеки, версии, и инструменты лишь бы был результат. ODB я использовал в связке только с MOC (правда ODB и QT слабо пересекаются благодаря тому же SRP ;-)) ). В общем, конечно, решение об использовании того или иного инструмента достаточно ответственное и порой становиться судьбоносным для проекта во всех смыслах.
fnc12
Посмотрев внимательнее
ODB
я нашел еще одну важную вещь, которая есть вsqlite_orm
и отсутствует вODB
— это возможность именовать столбцы таблиц независимо от имен полей класса. Как бы это смешно не звучало, но если в схеме есть колонка "new", например, то в C++ такой код не скомпилируется. Самое забавное, что если сменить язык на тот, в котором слово "new" не зарезервировано, то все заработает. Я сталкивался с таким лично в разработке (правда, это был enum-маппер, либа https://github.com/aantron/better-enums). Уговорить бэкенд разраба чтобы он поменял имя колонки в таблице только потому что это слово зарезервировано языком, на котором написан один из клиентов, нереально, да и идиоматически это неверно, на моя взгляд.DmitrySokolov
Да, не плохо. Фактически, избавляет от многочисленных assigns при де-сериализации.
Но если использовать так
то прослойка ORM вроде и не нужна, можно использовать fopen/fwrite.
Понимаете, к чему это?
C++ располагает к использованию UserType typeId; вместо int userTypeId; т.е. к использованию иерархического дерева объектов вместо таблиц.
fnc12
нет, не понял ни первое, ни второе. 1) почему прослойка не нужна при использовании статичных С-массивов? 2) "С++ располагает к использованию UserType typeId; вместо int userTypeId; т.е. к использованию иерархического дерева объектов вместо таблиц." — вы про вложенные сущности? Если да, то вложенные сущности это ад. Это понимаешь, когда пытаешься масштабировать систему. Вложенные сущности актуальны в проектах с небольшим сроком жизни. Я не спорю — таких очень много, поэтому на вложенные сущности много кто смотрит как на нечто удобное. Плюс в таком виде как вы показали вложенные сущности не скомпилируются если вложение взаимное. Если я вас понял неверно — поясните пожалуйста.
DmitrySokolov
Промахнулся, ответил новым комментарием.
DmitrySokolov
Как-то у вас все данные «таблично-ориентированы», инкапсуляция хромает. Спору нет, такая структура данных тоже возможна, но ИМХО только если она дает значительное преимущество в чем-то. Как у вас получение типа пользователя выглядит?
где обычно ожидается user.type.
fnc12
Вот так это делается
Точнее "таблично-ориентированность" называется отсутствием вложенных сущностей.
По правде сказать вы уже не первый кто говорит про это, и я задумался как можно вложенность реализовать в
sqlite_orm