Миграции схемы данных YDB с помощью Liquibase

Введение

Liquibase – это библиотека с открытым исходным кодом для отслеживания, управления и применения изменений схемы базы данных. Она расширяется диалектами для различных систем управления базами данных (СУБД), включая YDB.

Диалект - это основная сущность в фреймворке Liquibase, которая помогает формировать SQL запросы к базе данных, учитывая специфику той или иной СУБД.

Возможности диалекта YDB

Основной функциональностью Liquibase является абстрактное описание схемы базы данных в .xml, .json или .yaml форматах. Что обеспечивает переносимость при смене одной СУБД на другую.

В диалекте поддержаны основные конструкции стандарта описания миграций (changeset).

Создание таблицы

Changeset createTable отвечает за создание таблицы. Описание типов из SQL стандарта сопоставляется с примитивными типами YDB. К примеру тип bigint будет конвертирован в Int64.

Примечание

Вы также можете явно указать имя исходного типа, например Int32, Json, JsonDocument, Bytes или Interval. Однако в этом случае схема не будет переносимой.

Таблица сравнения описаний типов Liquibase с типами YDB:

Liquibase типы YDB тип
boolean, java.sql.Types.BOOLEAN, java.lang.Boolean, bit, bool Bool
blob, longblob, longvarbinary, String, java.sql.Types.BLOB, java.sql.Types.LONGBLOB, java.sql.Types.LONGVARBINARY, java.sql.Types.VARBINARY,java.sql.Types.BINARY, varbinary, binary, image, tinyblob, mediumblob, long binary, long varbinary Bytes (синоним String)
java.sql.Types.DATE, smalldatetime, date Date
decimal, java.sql.Types.DECIMAL, java.math.BigDecimal Decimal(22,9)
double, java.sql.Types.DOUBLE, java.lang.Double Double
float, java.sql.Types.FLOAT, java.lang.Float, real, java.sql.Types.REAL Float
int, integer, java.sql.Types.INTEGER, java.lang.Integer, int4, int32 Int32
bigint, java.sql.Types.BIGINT, java.math.BigInteger, java.lang.Long, integer8, bigserial, long Int64
java.sql.Types.SMALLINT, int2, smallserial, smallint Int16
java.sql.Types.TINYINT, tinyint Int8
char, java.sql.Types.CHAR, bpchar, character, nchar, java.sql.Types.NCHAR, nchar2, text, varchar, java.sql.Types.VARCHAR, java.lang.String, varchar2, character varying, nvarchar, java.sql.Types.NVARCHAR, nvarchar2, national, clob, longvarchar, longtext, java.sql.Types.LONGVARCHAR, java.sql.Types.CLOB, nclob, longnvarchar, ntext, java.sql.Types.LONGNVARCHAR, java.sql.Types.NCLOB, tinytext, mediumtext, long varchar, long nvarchar Text (синоним Utf8)
timestamp, java.sql.Types.TIMESTAMP, java.sql.Timestamp Timestamp
datetime, time, java.sql.Types.TIME, java.sql.Time Datetime

Важно

В YDB тип данных Timestamp хранит дату с точностью до микросекунд, в то время как java.sql.Timestamp или java.time.Instant хранят временную метку с точностью до наносекунд. Обратите на это внимание при работе с этим типом данных.

Имена типов не чувствительны к регистру.

dropTable - удаление таблицы. Пример: <dropTable tableName="episodes"/>

Изменение структуры таблицы

addColumn - добавление колонки. Пример:

<addColumn tableName="seasons">
    <column name="is_deleted" type="bool"/>
</addColumn>
"changes": [
    {
      "addColumn": {
        "tableName": "seasons",
        "columns": [
          {
            "column": {
              "name": "is_deleted",
              "type": "bool"
            }
          }
        ]
      }
    }
  ]
changes:
- addColumn:
    tableName: seasons
    columns:
    - column:
        name: is_deleted
        type: bool

createIndex - создание вторичного индекса. Пример:

<createIndex tableName="episodes" indexName="episodes_index" unique="false">
    <column name="title"/>
</createIndex>
"changes": [
    {
      "createIndex": {
        "tableName": "episodes",
        "indexName": "episodes_index",
        "unique": "false",
        "columns": {
          "column": {
            "name": "title"
          }
        }
      }
    }
changes:
- createIndex:
    tableName: episodes
    indexName: episodes_index
    unique: false
    columns:
    - column:
        name: title

Важно

YDB не поддерживает уникальный вторичный индекс.

Примечание

Создание асинхронных индексов нужно делать через нативные SQL миграции.

dropIndex - удаление вторичного индекса. Пример:

<dropIndex tableName="series" indexName="series_index"/>
"changes": [
  {
    "dropIndex": {
      "tableName": "series",
      "indexName": "series_index"
    }
  }
]
changes:
- dropIndex:
    tableName: series
    indexName: series_index

Ввод данных в таблицу

loadData, loadUpdateData - загрузка данных из CSV файла. loadUpdateData загружает данные командой UPSERT INTO.

insert - changeset, который осуществляет единичный insert в таблицу командой INSERT INTO. Например:

<insert tableName="episodes">
    <column name="series_id" valueNumeric="1"/>
    <column name="season_id" valueNumeric="1"/>
    <column name="episode_id" valueNumeric="1"/>
    <column name="title" value="Yesterday's Jam"/>
    <column name="air_date" valueDate="2023-04-03T08:46:23.456"/>
</insert>
"changes": [
  {
    "insert": {
      "tableName": "episodes",
      "columns": [
        {
          "column": {
            "name": "series_id",
            "valueNumeric": "1"
          }
        },
        {
          "column": {
            "name": "season_id",
            "valueNumeric": "1"
          }
        },
        {
          "column": {
            "name": "episode_id",
            "valueNumeric": "1"
          }
        },
        {
          "column": {
            "name": "title",
            "value": "Yesterday's Jam"
          }
        },
        {
          "column": {
            "name": "air_date",
            "valueDate": "2023-04-03T08:46:23.456"
          }
        }
      ]
    }
  }
]
changes:
- insert:
    tableName: episodes
    columns:
      - column:
          name: series_id
          valueNumeric: 1
      - column:
          name: season_id
          valueNumeric: 1
      - column:
          name: episode_id
          valueNumeric: 1
      - column:
          name: title
          value: Yesterday's Jam
      - column:
          name: air_date
          valueDate: 2023-04-03T08:46:23.456

Вы также можете указать любое значение в поле value. Данные из поля value в наборе изменений insert или CSV файлов будут автоматически преобразованы в требуемые типы с учетом строгой типизации в YDB.

Таблица форматирования типов для загрузки в таблицу:

YDB тип Формат описания
Bool true или false
Int8, Int16, Int32, Int64 Целочисленное число
Uint8, Uint16, Uint32, Uint64 Целое число без знака
Text, Bytes, Json, JsonDocument Строковое описание
Float, Double, Decimal(22, 9) Вещественное число
Interval ISO-8601, соответствует классу java.time.Duration в Java.
Date Шаблон YYYY-MM-DD календарной даты из стандарта ISO-8601
Datetime Шаблон YYYY-MM-DDThh:mm:ss, timezone будет установлена UTC
Timestamp Временная метка из стандарта ISO-8601 соответствует классу java.time.Instant в Java, timezone будет установлена UTC (точность в микросекундах - ограничение типа данных Timestamp в YDB)

Пример CSV файла:

id,bool,bigint,smallint,tinyint,float,double,decimal,uint8,uint16,uint32,uint64,text,binary,json,jsondocument,date,datetime,timestamp,interval
2,true,123123,13000,112,1.123,1.123123,1.123123,12,13,14,15,kurdyukov-kir,binary,{"asd": "asd"},{"asd": "asd"},2014-04-06,2023-09-16T12:30,2023-07-31T17:00:00.00Z,PT10S

Важно

Чтобы понять, какие SQL-конструкции может выполнять YDB и какие существуют ограничения на типы данных, ознакомьтесь с документацией по языку запросов YQL.

Совет

Важно отметить, что кастомные инструкции YQL могут быть применены с помощью нативных SQL-запросов.

Как воспользоваться?

Есть два способа:

Как воспользоваться из Java / Kotlin подробно описано в README проекта. Также есть пример приложения Spring Boot, использующего его.

Для начала нужно установить саму утилиту liquibase любым из рекомендуемых способов. Затем нужно подложить актуальные .jar архивы YDB JDBC драйвера и Liquibase диалекта YDB в папку internal/lib.

# $(which liquibase)
cd ./internal/lib/

# you may need to sudo
# set actual versions of .jar files
curl -L -o ydb-jdbc-driver.jar https://repo.maven.apache.org/maven2/tech/ydb/jdbc/ydb-jdbc-driver-shaded/2.0.7/ydb-jdbc-driver-shaded-2.0.7.jar
curl -L -o liquibase-ydb-dialect.jar https://repo.maven.apache.org/maven2/tech/ydb/dialects/liquibase-ydb-dialect/1.0.0/liquibase-ydb-dialect-1.0.0.jar

Более подробное описание в разделе manual library management в документации Liquibase.

Теперь утилиту командной строки liquibase можно использовать с YDB.

Сценарии использования liquibase

Инициализация liquibase на пустой YDB

Основной командой является liquibase update, которая применяет миграции, если текущая схема YDB отстает от пользовательского описания.

Далее в примерах предполагается, использование следующего файла настроек liquibase.properties:

changelog-file=changelogs.xml
url=jdbc:ydb:grpc://localhost:2136/local

Применим к пустой базе данных следующий changeset:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">

    <changeSet id="episodes" author="kurdyukov-kir">
        <comment>Table episodes.</comment>

        <createTable tableName="episodes">
            <column name="series_id" type="bigint">
                <constraints primaryKey="true"/>
            </column>
            <column name="season_id" type="bigint">
                <constraints primaryKey="true"/>
            </column>
            <column name="episode_id" type="bigint">
                <constraints primaryKey="true"/>
            </column>

            <column name="title" type="text"/>
            <column name="air_date" type="timestamp"/>
        </createTable>
        <rollback>
            <dropTable tableName="episodes"/>
        </rollback>
    </changeSet>
    <changeSet id="index_episodes_title" author="kurdyukov-kir">
        <createIndex tableName="episodes" indexName="index_episodes_title" unique="false">
            <column name="title"/>
        </createIndex>
    </changeSet>
</databaseChangeLog>

После исполнения команды liquibase update, liquibase напечатает следующий лог:

UPDATE SUMMARY
Run:                          2
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            2

Liquibase: Update has been successful. Rows affected: 2
Liquibase command 'update' was executed successfully.

После применения миграций схемы данных выглядит следующим образом:

_assets/liquibase-step-1.png

Можно увидеть, что Liquibase создал две служебные таблицы DATABASECHANGELOG - лог миграций, DATABASECHANGELOGLOCK - таблица для взятия распределенной блокировки.

Содержимое таблицы DATABASECHANGELOG:

AUTHOR COMMENTS CONTEXTS DATEEXECUTED DEPLOYMENT_ID DESCRIPTION EXECTYPE FILENAME ID LABELS LIQUIBASE MD5SUM ORDEREXECUTED TAG
kurdyukov-kir Table episodes. 12:53:27 1544007500 createTable tableName=episodes EXECUTED migration/episodes.xml episodes 4.25.1 9:4067056a5ab61db09b379a93625870ca 1
kurdyukov-kir "" 12:53:28 1544007500 createIndex indexName=index_episodes_title, tableName=episodes EXECUTED migration/episodes.xml index_episodes_title 4.25.1 9:49b8b0b22d18c7fd90a3d6b2c561455d 2

Эволюция схемы базы данных

Допустим нам нужно создать YDB топик и выключить параметр AUTO_PARTITIONING_BY_SIZE таблицы. Это можно сделать нативным SQL скриптом:

--liquibase formatted sql

--changeset kurdyukov-kir:create-a-topic
CREATE TOPIC `my_topic` (
    CONSUMER my_consumer
) WITH (
     retention_period = Interval('P1D')
);

--changeset kurdyukov-kir:auto-partitioning-disabled
ALTER TABLE episodes SET (AUTO_PARTITIONING_BY_SIZE = DISABLED);

Также добавим новую колонку is_deleted и удалим индекс index_episodes_title:


<changeSet id="alter-episodes" author="kurdyukov-kir">
    <comment>Alter table episodes.</comment>

    <dropIndex tableName="episodes" indexName="index_episodes_title"/>

    <addColumn tableName="episodes">
        <column name="is_deleted" type="bool"/>
    </addColumn>
</changeSet>
<include file="/migration/sql/yql.sql" relativeToChangelogFile="true"/>

После исполнения liquibase update схема базы успешно обновится.

UPDATE SUMMARY
Run:                          3
Previously run:               2
Filtered out:                 0
-------------------------------
Total change sets:            5

Liquibase: Update has been successful. Rows affected: 3
Liquibase command 'update' was executed successfully.

Результатом будет удаление индекса, добавление колонки is_deleted, выключение параметра AUTO_PARTITIONING_BY_SIZE, а также создание топика:

_assets/liquibase-step-2.png

Инициализация liquibase в проекте с непустой схемой данных

Предположим, что я имею существующий проект с текущей схемой базы данных:

_assets/liquibase-step-3.png

Чтобы начать использовать liquibase, требуется выполнить:

liquibase generate-changelog --changelog-file=changelog.xml

Содержимое сгенерированного changelog.xml:

<changeSet author="kurdyukov-kir (generated)" id="1711556283305-1">
    <createTable tableName="all_types_table">
        <column name="id" type="INT32">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="bool_column" type="BOOL"/>
        <column name="bigint_column" type="INT64"/>
        <column name="smallint_column" type="INT16"/>
        <column name="tinyint_column" type="INT8"/>
        <column name="float_column" type="FLOAT"/>
        <column name="double_column" type="DOUBLE"/>
        <column name="decimal_column" type="DECIMAL(22, 9)"/>
        <column name="uint8_column" type="UINT8"/>
        <column name="uint16_column" type="UINT16"/>
        <column name="unit32_column" type="UINT32"/>
        <column name="unit64_column" type="UINT64"/>
        <column name="text_column" type="TEXT"/>
        <column name="binary_column" type="BYTES"/>
        <column name="json_column" type="JSON"/>
        <column name="jsondocument_column" type="JSONDOCUMENT"/>
        <column name="date_column" type="DATE"/>
        <column name="datetime_column" type="DATETIME"/>
        <column name="timestamp_column" type="TIMESTAMP"/>
        <column name="interval_column" type="INTERVAL"/>
    </createTable>
</changeSet>
<changeSet author="kurdyukov-kir (generated)" id="1711556283305-2">
    <createTable tableName="episodes">
        <column name="series_id" type="INT64">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="season_id" type="INT64">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="episode_id" type="INT64">
            <constraints nullable="false" primaryKey="true"/>
        </column>
        <column name="title" type="TEXT"/>
        <column name="air_date" type="DATE"/>
    </createTable>
</changeSet>
<changeSet author="kurdyukov-kir (generated)" id="1711556283305-3">
    <createIndex indexName="title_index" tableName="episodes">
        <column name="title"/>
    </createIndex>
</changeSet>

Затем нужно синхронизировать сгенерированный changelog.xml файл, делается это командой:

liquibase changelog-sync --changelog-file=dbchangelog.xml

Результатом будет синхронизация liquibase в вашем проекте:

_assets/liquibase-step-4.png

Подключение к YDB

В вышеуказанных примерах мы использовали Docker контейнер, для которого не требовалось дополнительных параметров для аутентификации.

Существуют различные варианты настройки аутентификации через параметр URL:

  • Docker контейнер (anonymous authentication):
    jdbc:ydb:grpc://localhost:2136/local
  • Self-hosted кластер:
    jdbc:ydb:grpcs://<host>:2135/Root/testdb?secureConnectionCertificate=file:~/myca.cer
  • Подключение с использованием токена:
    jdbc:ydb:grpcs://<host>:2135/path/to/database?token=file:~/my_token
  • Подключение с использованием сервисного аккаунта:
    jdbc:ydb:grpcs://<host>:2135/path/to/database?saFile=file:~/sa_key.json

Если ваш кластер настроен с использованием логина и пароля, процесс аутентификации происходит через параметры Liquibase.

За дополнительной информацией о различных настройках аутентификации обратитесь к соответствующему разделу.