Управление структурой базы данных без боли

от автора

image

Хочу поделиться инструментом, который родился при разработке одного веб-проекта и очень помогает мне не потеряться в море таблиц, хранимых процедур, индексов и прочих обитателей базы данных.

Сам проект написан на Django, в качестве бекенда — PostgreSQL. В самом начале работы было решено, по крайней мере, частично отказаться от использования Django ORM в пользу «сырого» SQL и хранимых процедур. Другими словами, почти вся бизнес-логика вынесена на уровень базы данных. Сразу скажу, что готовить ORM я умею, но в данном случае требовалось производить многоступенчатые вычисления, связанные с множеством выборок, а это лучше делать на сервере БД и не таскать промежуточные данные в приложение.

Столкнувшись с необходимостью поддержания структуры базы данных вручную, без приятностей Django Migrations, я выяснил, что вручную писать инкрементальные SQL патчи возможно, но трудно уследить за зависимостями объектов БД. К примеру, когда функции, которая используется где-то еще, добавляешь еще один аргумент, простого CREATE OR REPLACE недостаточно — ее нужно сначала DROP, а потом CREATE. При этом нужно предварительно удалить зависимые от нее функции, а потом создать заново (а если от этих функций еще кто-то зависит, тогда надо и их пересоздать).

Под катом краткое описание возможностей в виде туториала. Встречайте — Sqlibrist.

Надо сказать, что мою проблему уже научились решать. Например, сравнительно давно существует Sqitch. Он позволяет описывать структуру базы данных в декларативном виде на SQL. Каждая таблица, вид или функция хранится в отдельном файле, а простой DSL описывает зависимости. Утилита написана на Perl, и мне, не знакомому с разработкой на Perl и экосистемой его пакетов, пришлось очень постараться, чтобы эту утилиту скомпилировать. Возможно, в силу долгой истории разработки, Sqitch имеет многовато зависимостей, как для такой простой программы. Еще мне не понравилось запутанное описание зависимостей и работа с версиями структуры. Признаю, что я просто не захотел подстраиваться и разбираться с инструментом, который мне показался неудобным.

Создавая Sqlibrist, я вдохновлялся и Sqitch, и Django Migrations, и немного VCS. А еще хотел, чтобы он был простым и понятным в использовании. Объекты структуры БД хранятся в отдельных файлах. Каждый содержит SQL-инструкцию для создания и (не обязательно) удаления этого объекта. Зависимости между объектами описываются явно в виде директив на встроенном DSL (в нем, кстати, только три ключевых слова: REQ, UP, DOWN). Подобно системе управления версиями, Sqlibrist хранит снимки структуры БД и SQL-патч для обновления до него с предыдущего снимка.

Интеллект Sqlibrist ограничен, он не парсит SQL и не генерирует ALTER TABLE — это ваша работа. Он только отслеживает изменения в файлах и создает патчи с вашими инструкциями, а также ведет учет примененных миграций.
Все это звучит как-то абстрактно, давайте перейдем к практике.

Установка

Моя основная ОС — Linux и на сервере и на десктопе, поэтому инструкции по установке только для нее. Возможно, кто-нибудь поможет мне с Windows и Mac.

Сначала заголовочные файлы:

Ubuntu

$ sudo apt-get install python-pip python-dev libyaml-dev $ sudo apt-get install libmysqlclient-dev  # for MySQL $ sudo apt-get install libpq-dev  # PostgreSQL 

Fedora/CentOS

$ sudo dnf install python-devel python-pip libyaml-devel $ sudo dnf install postgresql-devel  # PostgreSQL 

$ sudo dnf install mariadb-devel  # for MariaDB 

или

$ sudo dnf install mysql++-devel  # for MySQL 

Sqlibrist написан на Python и имеет две зависимости: PyYAML и что-то одно из psycopg2 и mysql-python.

Устанавливается с помощью pip либо в virtualenv, либо в системные библиотеки:

$ pip install sqlibrist 

или

$ sudo pip install sqlibrist 

После установки становится доступной команда sqlibrist.

База данных интернет-магазина

Давайте поиграемся с Sqlibrist на примере примитивного интернет-магазина.

$ mkdir shop_schema $ cd shop_schema $ sqlibrist init Creating directories... Done. 

Команда init создала структуру директорий нашего проекта:

shop_schema     sqlibrist.yaml     migrations     schema         constraints         functions         indexes         tables         triggers         types         views 

В sqlibrist.yaml конфигурация проекта для подключения к БД:

--- default:   engine: pg   user: <username>   name: <database_name>   password: <password> # host: 127.0.0.1 # port: 5432 

Чтобы проверить, что настройки верны:

$ sqlibrist test_connection Connection OK 

Дальше проинициализируем таблицу, где Sqlibrist будет хранить информацию о примененных миграциях. Эта часть идентична Django Migrations/South.

$ sqlibrist initdb Creating db... Creating schema and migrations log table...  Done. 

Кстати, в терминологии Sqlibrist, миграция — это снимок структуры базы и патчи для применения этой миграции или отката к предыдущей.

Далее создадим файл shop_schema/schema/tables/user.sql:

--UP CREATE TABLE "user" ( id SERIAL PRIMARY KEY, name TEXT, password TEXT); 

Первая строка —UP означает, что следующие SQL-инструкции создают объект БД. Этого достаточно для создания таблицы.

Аналогично создадим еще два файла:

shop_schema/schema/tables/product.sql:

--UP CREATE TABLE product ( id SERIAL PRIMARY KEY, name TEXT, price MONEY); 

shop_schema/schema/tables/order.sql:

--REQ tables/user --UP CREATE TABLE "order" ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES "user"(id), date DATE); 

Обратите внимание на строку —REQ tables/user. Она означает, что текущий объект зависит от объекта в файле tables/user.sql (в REQ расширение не пишется). Это гарантирует, что при генерации патча таблица user будет создана перед таблицей order. Все —REQ должны идти в начале файла.

Еще один файл:

shop_schema/schema/tables/order_product.sql:

--REQ tables/order --UP CREATE TABLE order_product ( id SERIAL PRIMARY KEY, order_id INTEGER REFERENCES "order"(id), product_id INTEGER REFERENCES product(id), quantity INTEGER); 

Создадим первую миграцию:

$ sqlibrist makemigration -n 'initial' Creating:  tables/user  tables/product  tables/order  tables/order_product Creating new migration 0001-initial 

Файлы миграции созданы в shop_schema/migrations/0001-initial:

up.sql down.sql schema.json 

В up.sql содержится патч для применения миграции, down.sql в данном случае пустой, а в schema.json снимок текущей структуры БД.

Перед применением патча вы можете (и это желательно) ознакомиться с текстом патча и удостовериться, что он делает то, что нужно. Если он вас не устраивает, удалите всю директорию 0001-initial и создайте миграцию заново. Можете редактировать up.sql и down.sql, если знаете, что делаете, но не трогайте schema.json.

Теперь применим нашу первую миграцию:

$ sqlibrist migrate Applying migration 0001-initial... done 

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

shop_schema/schema/views/user_orders.sql:

--REQ tables/user --REQ tables/order --REQ tables/product --REQ tables/order_product  --UP CREATE VIEW user_orders AS SELECT  u.id as user_id,  o.id as order_id,  o.date,  SUM(p.price*op.quantity) AS total   FROM "user" u  INNER JOIN "order" o ON u.id=o.user_id  INNER JOIN order_product op ON o.id=op.order_id  INNER JOIN product p ON p.id=op.product_id   GROUP BY o.id, u.id;  --DOWN DROP VIEW user_orders; 

После директивы —DOWN идут инструкции для удаления user_orders при его пересоздании.

Общее правило: содержащие данные объекты, например таблицы, мы обновляем вручную, поэтому их описания не содержат —DOWN, а функции, типы, индексы можно безболезненно удалять и создавать, поэтому это можно доверить автоматике.

Еще нам нужна функция, которая возвращает user_orders для заданного пользователя:

--REQ views/user_orders  --UP CREATE FUNCTION get_user_orders(_user_id INTEGER) RETURNS SETOF user_orders LANGUAGE SQL AS $$  SELECT * FROM user_orders WHERE user_id=_user_id;  $$;  --DOWN DROP FUNCTION get_user_orders(INTEGER); 

Создадим и применим следующую миграцию:

$ sqlibrist makemigration -n 'user_orders view and function' Creating:  views/user_orders  functions/get_user_orders Creating new migration 0002-user_orders view and function  $ sqlibrist migrate Applying migration 0002-user_orders view and function... done 

Таким образом, у нас 4 таблицы, один вид и одна функция.

Допустим, нам нужно добавить еще одно поле в вид user_orders. Вот какие проблемы могут возникнуть:

  • мы можем удалить и создать заново новый вид user_orders, но БД не позволит этого сделать, потому что функция get_user_orders зависит от этого вида;
  • можно схитрить и выкрутиться CREATE OR REPLACE VIEW user_orders…, но тип поля вида и тип результата функции будут отличаться. И в этом случае БД не даст нам этого сделать без пересоздания функции.

Sqlibrist как раз и предназначен разрешать такие проблемы. Добавим поле SUM(op.quantity) as order_total в вид user_orders:

 --REQ tables/user --REQ tables/order --REQ tables/product --REQ tables/order_product  --UP CREATE VIEW user_orders AS SELECT  u.id as user_id,  o.id as order_id,  o.date,  SUM(p.price*op.quantity) AS total,  SUM(op.quantity) as order_total   FROM "user" u  INNER JOIN "order" o ON u.id=o.user_id  INNER JOIN order_product op ON o.id=op.order_id  INNER JOIN product p ON p.id=op.product_id   GROUP BY o.id, u.id;  --DOWN DROP VIEW user_orders; 

Можно посмотреть, что же изменилось:

$ sqlibrist -V diff Changed items:   views/user_orders ---  +++  @@ -2,7 +2,8 @@        u.id as user_id,       o.id as order_id,       o.date, -     SUM(p.price*op.quantity) AS total +     SUM(p.price*op.quantity) AS total, +     SUM(op.quantity) as total_quantity        FROM "user" u       INNER JOIN "order" o ON u.id=o.user_id 

Создадим миграцию:

$ sqlibrist makemigration Updating:  dropping:   functions/get_user_orders   views/user_orders  creating:   views/user_orders   functions/get_user_orders Creating new migration 0003-auto 

Вы видите, что сначала удаляется зависящий объект — функция get_user_orders, потом сам вид. Далее вид создается с новой структурой, после восстанавливается функция. Такая схема будет работать для зависимостей произвольной глубины (но не циклической зависимости — Sqlibrist попросит исправить ее).

Применим эту миграцию:

$ sqlibrist migrate Applying migration 0003-auto... done 

Наконец, давайте внесем изменение в таблицу. Поскольку файлы с определениями таблиц не содержат —DROP, будем работать руками:

  1. Изменим инструкцию CREATE TABLE;
  2. Сгенерируем новую миграцию той же командой makemigration;
  3. Добавим в up.sql необходимый ALTER TABLE.

Добавим новое поле «type» text в таблицу product:

shop_schema/schema/tables/product.sql:

--UP CREATE TABLE product ( id SERIAL PRIMARY KEY, name TEXT, "type" TEXT, price MONEY); 

Это пункт 1. Теперь создадим миграцию:

$ sqlibrist makemigration -n 'new product field' Updating:  dropping:   functions/get_user_orders   views/user_orders  creating:   views/user_orders   functions/get_user_orders Creating new migration 0004-new product field 

Обратите внимание, что несмотря на то, что мы изменили определение таблицы product, tables/product не присутствует в журнале миграции, НО все зависимые от него объекты пересоздаются. Это пункт 2.

Теперь пункт 3: откройте в редакторе shop_schema/migrations/0004-new product field/up.sql и найдите строку 12 с текстом — ==== Add your instruction here ====. Это логическая середина миграции. В этот момент все зависимые объекты удалены и мы можем вставить наш ALTER TABLE.

Вставьте следующее:

ALTER TABLE product ADD COLUMN "type" TEXT; 

Наш up.sql будет выглядеть так:

-- begin -- DROP FUNCTION get_user_orders(INTEGER); -- end --   -- begin -- DROP VIEW user_orders; -- end --   -- begin -- -- ==== Add your instruction here ==== ALTER TABLE product ADD COLUMN "type" TEXT; -- end --   -- begin -- CREATE VIEW user_orders AS SELECT      u.id as user_id,      o.id as order_id,      o.date,      SUM(p.price*op.quantity) AS total,      SUM(op.quantity) as total_quantity       FROM "user" u      INNER JOIN "order" o ON u.id=o.user_id      INNER JOIN order_product op ON o.id=op.order_id      INNER JOIN product p ON p.id=op.product_id       GROUP BY o.id, u.id; -- end --   -- begin -- CREATE FUNCTION get_user_orders(_user_id INTEGER)     RETURNS SETOF user_orders     LANGUAGE SQL AS $$      SELECT * FROM user_orders     WHERE user_id=_user_id;      $$; -- end -- 

Можно применить этот патч:

$ sqlibrist migrate  Applying migration 0004-new product field... done 

На этом моменте оставим в покое наш интернет-магазин.

Еще Sqlibrist умеет интегрироваться в проект Django, я им пользуюсь именно в этом контексте.

Сайт проекта — здесь, баг-репорты приветствуются.

ссылка на оригинал статьи https://habrahabr.ru/post/282123/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *