Разрабы
/

АКК

Мы логиним через
гитхаб

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

Залогиниться через github

Как я делал принципиально новые миграции на С++

Как я делал принципиально новые миграции на С++

Привет, я Женя. Я пишу на С++, ем коней и веду вот этот проект https://github.com/fnc12/sqlite_orm уже более шести лет. Если коротко, то суть данной либы в том чтобы дать возможность использовать sqlite3 API в С++ максимально емко минимальным количеством кода. А еще либа почти на 95% состоит из кода на шаблонах. На самом деле в моей практике реально шаблоны нужны примерно никогда, потому данная либа изначально была моим pet-проектом, отдушиной для моего бесконечного горящего бурлящего желания попробовать шаблоны на практике во всех проявлениях, вариантах и позах.

Код

С чего все начиналось

После публикации sqlite_orm на github.com я стал получать слова благодарности от совершенно незнакомых мне людей. Это было очень приятно. Я сразу же стал себя ассоциировать с человеком, от которого я буквально тащусь - с Линусом Торвальдсом. В его занимательной книге "Ради удовольствия" (Just for Fun) он как раз рассказывал про то, как ему приходили благодарности за создание ядра Линукс, и это ему было настолько приятно, что ему больше по душе было получать эти благодарности, нежели донаты. Правда, благодарности присылались в виде писем. Не электронных. Это был бородатый прошлый век, и гитхаба со звездами тогда еще не было, как и гита (который тоже чуть позже создаст Линус). Но не в этом суть. Я чувствовал себя Линусом. Конечно, дорогой любимый внимательный читатель, я с тобой согласен, что sqlite_orm это не ядро Линукс. Но пока только так, и от этого буквально меня перло как школьника от появления первой девушки в жизни, как Генри Кавилла от обновления железа на его ПК, или даже (не побоюсь это произносить вслух) пользователей макбуков от новости о том, что проклятый touch bar убрали из новых моделей навсегда.

Теперь когда понятно что такое sqlite_orm для меня, перейдем к технической реализации этой либы. Если коротко, то схема базы данных указывается статически, то есть, определяется во время компиляции. То есть, нельзя добавить таблицу или колонку в рантайме в схему. В смысле, в принципе менять схемы существующих баз данных SQLite можно в рантайме, но доступ к базе данных осуществляется при помощи объекта storage ("хранилице" в переводе с английского, но мне нравится говорить "сторож", и вряд ли что-то в жизни меня заставит говорить иначе), а сторож имеет статическую схему, и ее нельзя меня. Ровно так же, как std::tuple имеет статический набор полей и не может быть изменен в рантайме. Но можно создать другого сторожа, который будет работать с той же БД и иметь иную схему.

От слов к делу. Вот пример кода:

struct Object { int id = 0; std::string name; }; auto storage = make_storage("db.sqlite", make_table("objects", make_column("id", &Object::id, primary_key()), make_column("name", &Object::name)));

Это мы создали сторожа, который имеет в себе одну таблицу с двумя колонками. Мы не создали БД, это мы лишь создали сторож, который является сервис-объектом для работы с БД. Как вы видите сторож хранит в себе маппинг (привязки, биндинги) классов на таблицы (в нашем случае Object к "objects") и колонок к указателям на члены класса (&Object::id к "id" и &Object::name к "name"). В итоге такая формулировка кода позволяет писать SQL запросы без сырых строк при этом без каких-либо ограничений:

auto ids = storage.select(&Object::id); // decltype(ids) это std::vector<int> auto names = storage.select(&Object::name) // decltype(names) это std::vector<std::string>

Я тут не буду расписывать все варианты запросов которые поддерживаются в sqlite_orm. Их много, просто поверь. Я хочу остановиться на обратной стороне любой ORM - управления схемой. Управление схемой это боль для пользователей ORM-библиотек. Почему? Потому что часть библиотек просто не умеют управлять схемой из-за чего приходится сначала готовить БД вовне. Другие ORM имеют фунции для управления схемой, но руками создавать схему это гемор, давайте называть вещи своими именами. Исходя из таких мыслей я и придумал функцию sync_schema у сторожа. Вызов этой функции гарантирует, что после вызова схема в БД точно будет соответствовать той, которая описана в make_storage вызове. То есть, ты написал вызов make_storage, после него написал вызов storage.sync_schema() и все - БД будет создана если ее нет, исправлена если что-то отличается или не тронута если все и так есть. Как эта функция работает и почему ты, прелестный невероятный обалденный читатель, узнаешь в следующих абзацах.

Дьявол в деталях

Если внимательно почитать комментарий к функции sync_schema, то сначала кажется, что эта функция автоматизирует кучу всего, что обычно разработчики делают руками - создание таблиц, колонок, определение констрэинтов (primary key, вот это вот все), создание индексов и даже триггеров. Однако в процессе чтения того, как работает sync_schema, точнее, какие сценарии могут быть при различных состояниях схемы и сторожа оказывается, что sync_schema может иногда дропнуть таблицу. Кому-то на это поровну потому что они используют SQLite для кэша, а к кэшу нет жалости. Но для некоторых все иначе - сохранность данных превыше всего, и я с этими людьми полностью согласен.

  • Подожди, - спросишь ты, - если ты с этим полностью согласен, тогда зачем ты сделал так, что вызов sync_schema может дропнуть данные? Смысла в такой функции примерно столько же, сколько смысла в супе из гвоздей!

Хм, позволь объяснить. Дело в том, что есть сценарии когда ты вызываешь sync_schema, а старая схема имеет особые отличия от схемы описанной в make_storage. Например: мы имели какую-то таблицу "users", а при определенном обновлении нашего приложения нам понадобилась еще одна колонка. И, допустим, у нас мобильное или десктопное приложение, то есть, инстансов приложения есть множество, и прямого доступа у нас, разработчиков, к этим инстансам нет. И количество кортежей в таблице "users" у любого пользователя может быть какое-угодно. Мы добавляет колонку, скажем, "rating" с типом INTEGER которая NOT NULL и не имеет DEFEAULT констрэйинта (это важно). Это значит, что если в таблице "users" есть кортежи, надо указать какое значение будет иметь новая колонка в каждом кортеже. Этого сделать мы не можем. И в таком случае вызов sync_schema удалит таблицу и создаст ее вновь с уже нужной схемой.

  • Не, ну так не пойдет, - скажешь ты, - у тебя статья про миграции, а ты тут рассказываешь как твоя либа дропает данные юзеров! И вообще когда будут непосредственно миграции?

Верно подмечено. Не стоит торопиться. Все будет. И к миграциям мы как раз плавно переходим.

Дело в том, что либа так и жила несколько лет с такой реализацией sync_schema. Для людей, которые боятся, что их данные будут утеряны, я сделал функцию sync_schema_simulate, которая возвращает std::map с информацией о том, что станет с каждой таблицей после вызова. Это немного помогло. Но реальных миграций не хватало. Мы (наше коммунити кто участвовал в разработке либы в том числе я) стали думать над реализацией API для миграций. Казалось бы задача простая - посмотри как у других сделаны миграции и сделай так же. Но была одна загвоздка. Традиционный подход к миграциям это функции up и down, которые напрямую редактируют схему функциями типа "создать/удалить таблицу/колонку", "изменить колонку" etc. Можно сделать так, но тогда теряется смысл в статичном определении сторожа. В таком случае придется дважды писать одно и то же - и таблицу в make_storage, и ее создание внутри миграции. Это дублирование кода, а это плохо. Такой вариант отпадает. У нас ведь есть sync_schema, которая и так знает схему и вызывает редактирование схемы в зависимости от определение сторожа. Значит нужно как-то подтюнить вызов sync_schema или сделать аналогичный, но безопасный. При этом, из моего примера с добавлением таблицы ясно, что задача должна решаться добавлением пользовательского кода, потому что сама либа никогда не узнает какими значениями хочет забить пользователь новую колонку.

В процессе я понял, что я создаю что-то, что ранее не встречал. То есть, принципиально новый подход к реализации API для миграций. Прям как Денис Попов в свое время творил принципиально новую операционную систему, только у меня все взаправду.

Только позволь сначала сделать ремарку: если где-то есть уже аналогичный функционал на каком-либо языке, то, пожалуйста, сообщи об этом в комментарии прямо под этой статьей.

Функционал миграций

Миграция в sqlite_orm это сущность которая имеет три свойства: пользовательский индекс "откуда", пользовательский индекс "куда" и лямбда, которая имеет в качестве аргумента ссылку на инстанс нового класса connection_container. И чтобы перейти от теории к практике давайте придумаем кейс где sync_schema заведомо грохнет данные, то есть нам нужна будет миграция кровь из носу.

Если класс User банальный до невозможности:

struct User { int id = 0; std::string name; };

И есть сторож:

auto storage = make_storage(filename, make_table("users", make_column("id", &User::id, primary_key()), make_column("name", &User::name))); storage.sync_schema();

Первый вызов sync_schema создаст БД в точности с указанной схемой при первом запуске, а в следующие запуски просто никак не будет ее трогать. Далее мы разрабатываем наше приложение, публикуем, юзеры юзают, БД у каждого хранит какие-то локальные данные, все прекрасно, байтики пишутся, сегфолты не случаются. А потом наступает время когда нам надо выкатить обновление для нашего приложения, и в рамках этого обновления нам надо обновить схему БД. В частности, нам надо заменить колонку name на две колонки first_name и last_name. И очень важно сохранить данные.

Новая схема будет выглядеть вот так:

struct User { int id = 0; std::string firstName; std::string lastName; }; auto storage = make_storage(filename, make_table("users", make_column("id", &User::id, primary_key()), make_column("first_name", &User::firstName), make_column("last_name", &User::lastName)));

И вот незадача: если вызвать storage.sync_schema() следующей строкой то данные будут потеряны. Почему так произойдет? Ведь бывают ситуации когда старой БД не было (например, если пользователь не пользовался нашим приложением ранее и установил сразу вторую версию) или когда в таблице нет данных (в таком случае колонка удаляется и просто добавляются новые две). Но нас интересует ситуация когда пользователь пользуется нашим приложением с самой первой версии и имеет данные в таблице. Такие пользователи тоже есть, и их нельзя игнорировать. Мы хотим чтобы пользовательский опыт был максимально положителен во всех ситуациях, потому что нам надо что-то предпринять в данной ситуации.

Теперь когда ты уважаемый неповторимый опупенный читатель понял, что нам реально нужен функционал миграций, давай разбираться как нам его сделать. Как я говорил ранее, традиционные миграции имеют функции up и down, в которых обычно пишется код по редактированию схемы и данных. Но у нас уже есть редактирование схемы в sync_schema. Надо лишь как-то хитро поймать момент когда данные еще не стерты, подправить их и обновить в БД. Не буду ходить вокруг да около, расскажу сразу что я сделал. Только сначала давайте условимся как мы будем превращать данные из старой колонки name в данные в новых колонках first_name и last_name. Самый простой способ это "пилить" слово по первому пробелу на две части. Первая часть будет first_name, а вторая - last_name. Если пробела нет, то все содержимое колонки name запишется в колонку first_name, а поле в колонке last_name останется пустым. То есть, 'Sertab Erener' превратится в `Sertab' и 'Erener', а 'Inna' превратится в 'Inna' и ''.

storage.register_migration(0, 1, [&] (const connection_container& connection) { // старый тип юзера. Он нужен чтобы работать со старыми данными struct OldUser { int id = 0; std::string name; }; // создаем старую версию сторожа auto oldStorage = connection.make_storage(make_table("users", make_column("id", &OldUser::id, primary_key()), make_column("name", &OldUser::name))); // достаем всех старых юзеров auto oldUsers = oldStorage.get_all<OldUser>(); // decltype(oldUsers) это std::vector<OldUser> // синхронизиурем схему. Теперь схема обновлена, но данные в БД удалены storage.sync_schema(); for (auto& oldUser: oldUsers) { User newUser; newUser.id = oldUser.id; auto spaceIndex = oldUser.name.find(' '); if (spaceIndex != oldUser.name.npos) { newUser.firstName = oldUser.name.substr(0, spaceIndex); newUser.lastName = oldUser.name.substr(spaceIndex + 1, oldUser.name.length() - spaceIndex - 1); } else { newUser.firstName = oldUser.name; } storage.replace(newUser); // replace это как insert в SQLite только с заменой если такой primary key уже есть } });

Это мы зарегистрировали миграцию. То есть, мы еще ничего не вызвали, данные не потеряли, мы лишь описали сценарий что делать если будет произведена миграция с 0 на 1. Цифры 0 и 1 это PRAGMA user_version (ранее я это назвал "пользовательский индекс"). Это специальное целочисленное значение, которое хранится в БД, которое создано для того чтобы пользователь в нем хранил версию БД. Важная оговорка: PRAGMA user_version похумолчанию равен 0.

Далее мы вызываем миграцию:

storage.migrate_to(1);

Что тут происходит? Проверяется нынешнее значение PRAGMA user_version. Если оно не равно тому, которое передано в качестве единственного аргумента в функцию migrate_to, то происходит поиск миграции с нынешнего значения PRAGMA user_version на указанное. Если таковой нет, то бросается исключение, а если есть, то выдывается зарегистрированная миграция (та самая лямбда).

Заключение

И тут ты, внимательный любезный чуткий читатель можешь заметить "А почему вся таблица выгружается в память? Это же удар по памяти! Ведь количество юзеров может быть чуть больше чем дофига!". Абсолютно верно подмечено. Данный пример показывает в первую очередь функционал подхода миграций в sqlite_orm, а не то как следует эффективно перегонять данные из таблицы в таблицу. В идеале тут надо делать или ATTACH, или еще как-то хитро держать данные на файловой системе, но суть не в этом. Так что прошу принять пример каков он есть со всеми его прекрасами и недостатками.

Каждая статья пишется с какой-то целью, и цель обычно не пишется открытым текстом. Я же хочу честно сообщить цель данной статьи и получить честный фидбэк. Мне нужна обратная связь на API для миграций. И, как я выше писал, если подобный функционал где-то на каком-то языке программирования уже существует я бы хотел об этом узнать. Данный функционал недоступен на GitHub. Его можно приобрести вместе с расширенной проприетарной версией sqlite_orm_plus, потому то, что ты тут увидел, это самый настоящий инсайд.

Сайт посвященный sqlite_orm сейчас в разработке. Находится он тут https://sqliteorm.com/. Спасибо за внимание.

Автор текста - Евгений Захаров.