Ir para o conteúdo

Migrations com Kotlin e Flyway

De Wikiversidade

Migração de banco de dados ou schema migration é o processo de gerenciamento de alterações (como criação, alteração ou exclusão de tabelas, colunas e restrições) incrementais, versionadas e possivelmente reversíveis no esquema de bancos de dados relacionais, aplicado por meio de ferramentas que executam scripts de migração em sequência[1]. Esses scripts levam o banco de dados de seu estado atual a uma versão alvo desejada.

Vantagens

[editar | editar código]
  • Versionamento e rastreabilidade: cada migration recebe um identificador único (ex.: V1, V2…), permitindo reverter o esquema a versões anteriores em caso de falhas[2].
  • Integração em pipelines de CI/CD: as ferramentas de migration podem ser chamadas automaticamente em processos de build/deploy, garantindo que o esquema esteja sempre alinhado ao código-fonte[3].
  • Consistência de equipe: elimina divergências de esquema entre desenvolvedores, pois as migrações ficam versionadas junto ao repositório de código[1].
  • Rollback controlado: muitas ferramentas suportam desfazer migrations específicas, minimizando riscos em caso de problemas.

Ferramenta de migração

[editar | editar código]

O Flyway é uma ferramenta Java open source mantida pela Redgate. Organiza migrations em arquivos nomeados no padrão V{versão}__{descrição}.sql (migrações versionadas) e suporta comandos como Migrate, Clean, Info, Validate, Undo, Baseline e Repair[4].

Exemplo de funcionamento

[editar | editar código]
  1. Quatro arquivos V1_{descrição}.sql, V2_{descrição}.sql, V3_{descrição}.sql e V4_{descrição}.sql de migration estão presentes num diretório.
  2. O Flyway é executado e verifica que V1_{descrição}.sql e V2_{descrição}.sql estão aplicadas ao database e a ordem delas, sendo V2_{descrição}.sql a última migration aplicada (versão atual é V2).
  3. A migração é executada pelo Flyway com versão alvo V4, e agora V3_{descrição}.sql e V4_{descrição}.sql estão aplicadas ao database (versão virou V4)[4].

Implementação com Kotlin, PostgreSQL e Flyway

[editar | editar código]

Configuração do PostgreSQL

[editar | editar código]

Crie um docker-compose.yaml:

version: '3.3'

services:
  postgresdb:
    container_name: postgresdb
    image: 'postgres:15-alpine'
    ports:
      - "5432:5432"
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'postgres']
    volumes:
      - 'postgresdb-volume:/var/lib/postgresql/data'
    restart: always
    environment:
      POSTGRES_PASSWORD: pass

volumes:
  postgresdb-volume:

Inicialize a instância e crie uma DB inicial:

docker-compose -f ./docker-compose.yaml up -d
docker exec -it postgresdb /usr/local/bin/psql \
  -U postgres \
  -c "CREATE DATABASE my_sample_db"

Configuração geral do projeto

[editar | editar código]

Crie um novo diretório e troque o diretório atual para ele no terminal:

mkdir migrations-sample
cd migrations-sample/

Inicialize o seu projeto Kotlin:

gradle init --type kotlin-application --dsl kotlin

Edite o arquivo app/build.gradle.kts:

plugins {
  kotlin("jvm") version "1.8.21"
  kotlin("plugin.serialization") version "1.8.21"
  application
}

repositories {
  // Use Maven Central for resolving dependencies.
  mavenCentral()
}

dependencies {
  // For managing our database migrations
  // https://github.com/flyway/flyway
  implementation("org.flywaydb:flyway-core:9.17.0")

  // For parsing CLI arguments
  // https://github.com/Kotlin/kotlinx-cli
  implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.5")

  // For couroutines support; not strictly needed, but it's nice to
  // indicate when blocking I/O needs the thread-pool meant for blocking stuff.
  // https://github.com/Kotlin/kotlinx.coroutines
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0-RC")

  // For parsing our configuration file. Using:
  //  - https://github.com/Kotlin/kotlinx.serialization
  //  - https://github.com/lightbend/config (HOCON as the format)
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-hocon:1.5.0")

  // Database driver (JDBC)
  implementation("org.postgresql:postgresql:42.6.0")

  // Flyway has built-in logging, which we can expose via SLF4J/Logback
  implementation("ch.qos.logback:logback-classic:1.4.7")
}

// Apply a specific Java toolchain to ease working on different environments.
java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(11))
  }
}

tasks.register<JavaExec>("migrate") {
  group = "Execution"
  description = "Migrates the database to the latest version"
  classpath = sourceSets.getByName("main").runtimeClasspath
  mainClass.set("migrations.sample.RunMigrations")

  val user = System.getenv("POSTGRES_ADMIN_USER")
    ?: "postgres"
  val pass = System.getenv("POSTGRES_ADMIN_PASSWORD")
    ?: throw GradleException(
      "POSTGRES_ADMIN_PASSWORD environment variable must be set"
    )
  args = listOf(user, pass)
}

Para configurar o logging, adicione o arquivo app/src/main/resources/logback.xml:

<configuration debug="false">
  <statusListener class="ch.qos.logback.core.status.NopStatusListener" />
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <withJansi>true</withJansi>
    <encoder>
      <pattern>[%date{ISO8601}] [%highlight(%level)] [%boldYellow(%marker)] [%logger] [%thread] %cyan([%mdc])  %msg%n</pattern>
    </encoder>
  </appender>

  <logger name="org.flywaydb.core" level="WARN" />

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

Crie um arquivo app/src/main/resources/database.conf:

jdbc-connection.main {
  driver = "org.postgresql.Driver"

  url = "jdbc:postgresql://localhost:5432/my_sample_db"
  url = ${?JDBC_CONNECTION_MAIN_URL}

  username = "sample_user"
  username = ${?JDBC_CONNECTION_MAIN_USERNAME}

  password = ${JDBC_CONNECTION_MAIN_PASSWORD}

  migrationsTable = "main_migrations"
  migrationsLocations = [
    "classpath:db/migrations/main/psql"
  ]
}

Configure o driver da DB adicionando o arquivo app/src/main/kotlin/JdbcConnectionConfig.kt:

package migrations.sample

import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.hocon.Hocon

@Serializable
data class JdbcConnectionConfig(
  val url: String,
  val driver: String,
  val username: String,
  val password: String,
  val migrationsTable: String,
  val migrationsLocations: List<String>,
  val migrationsPlaceholders: Map<String, String> = emptyMap()
) {
  companion object {
    @OptIn(ExperimentalSerializationApi::class)
    suspend fun loadFromGlobal(
      configNamespace: String,
      config: Config? = null
    ): JdbcConnectionConfig =
      withContext(Dispatchers.IO) {
        val rawCfg = config ?: ConfigFactory.load().resolve()
        val cfg = rawCfg.getConfig(configNamespace)
        Hocon.decodeFromConfig(serializer(), cfg)
      }
  }
}

Integração com a API Flyway

[editar | editar código]

Crie o arquivo app/src/main/kotlin/RunMigrations.kt:

package migrations.sample

import com.typesafe.config.ConfigFactory
import kotlinx.cli.ArgParser
import kotlinx.cli.ArgType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.flywaydb.core.Flyway
import org.flywaydb.core.api.configuration.FluentConfiguration
import org.flywaydb.core.api.output.MigrateResult
import org.slf4j.LoggerFactory
import kotlin.system.exitProcess

suspend fun dbMigrate(
  config: JdbcConnectionConfig,
  adminUsername: String?,
  adminPassword: String?
): MigrateResult =
  withContext(Dispatchers.IO) {
    val m: FluentConfiguration = Flyway.configure()
      .dataSource(
        config.url,
        adminUsername ?: config.username,
        if (adminUsername != null) adminPassword else config.password,
      )
      .group(true)
      .outOfOrder(false)
      .table(config.migrationsTable)
      .locations(*config.migrationsLocations.toTypedArray())
      .baselineOnMigrate(true)
      .loggers("slf4j")
      .placeholders(
        config.migrationsPlaceholders +
            mapOf(
              "dbUsername" to config.username,
              "dbPassword" to config.password
            ).filterValues { it != null }
      )

    val validated = m
      .ignoreMigrationPatterns("*:pending")
      .load()
      .validateWithResult()

    if (!validated.validationSuccessful) {
      val logger = LoggerFactory.getLogger("RunMigrations")
      for (error in validated.invalidMigrations) {
        logger.warn(
          """
            |Failed to validate migration:
            |  - version: ${error.version}
            |  - path: ${error.filepath}
            |  - description: ${error.description}
            |  - error code: ${error.errorDetails.errorCode}
            |  - error message: ${error.errorDetails.errorMessage}
          """.trimMargin("|").trim()
        )
      }
    }
    m.load().migrate()
  }

object RunMigrations {
  private suspend fun migrateNamespace(
    label: String,
    config: JdbcConnectionConfig,
    adminUsername: String,
    adminPassword: String
  ): Unit = withContext(Dispatchers.IO) {
    val result = dbMigrate(
      config,
      adminUsername,
      adminPassword
    )
    println("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=")
    println("Migrating: $label")
    println("------------------------------------")
    println("Initial schema version: ${result.initialSchemaVersion}")
    println("Target schema version: ${result.targetSchemaVersion}")
    if (result.migrations.isNotEmpty()) {
      println("------------------------------------")
      println("Executed migrations:")
      for (migration in result.migrations) {
        println(" - ${migration.version} ${migration.type} ${migration.description}")
      }
    }
    if (result.warnings.isNotEmpty()) {
      println("------------------------------------")
      System.err.println("WARNINGS:")
      for (warning in result.warnings) {
        System.err.println(" - $warning")
      }
    }
    println("------------------------------------")
    if (result.success) {
      println("Successfully migrated: $label!")
    } else {
      System.err.println("ERROR: Failed to migrate $label!")
      exitProcess(1)
    }
  }

  @JvmStatic
  fun main(args: Array<String>) {
    val parser = ArgParser("RunMigrations")
    val adminUsername by parser.argument(
      ArgType.String,
      fullName = "admin-username",
      description = "Admin username for the database. Example: postgres"
    )
    val adminPassword by parser.argument(
      ArgType.String,
      fullName = "admin-password",
      description = "Admin password for the database."
    )
    parser.parse(args)

    runBlocking {
      val config =
        ConfigFactory.load("database.conf").resolve()
      val mainConfig =
        JdbcConnectionConfig.loadFromGlobal(
          "jdbc-connection.main",
          config
        )
      migrateNamespace(
        "main",
        mainConfig,
        adminUsername,
        adminPassword
      )
    }
  }
}

Adicionando as migrações SQL

[editar | editar código]

No diretório app/src/main/resources/db/migrations/main/psql, crie o arquivo V0010__create-user.sql:

CREATE USER "${dbUsername}" WITH PASSWORD '${dbPassword}';
CREATE SCHEMA IF NOT EXISTS sample
  AUTHORIZATION "${dbUsername}";

GRANT
  CONNECT,
  TEMPORARY
ON DATABASE "my_sample_db"
TO "${dbUsername}";

E também o arquivo V0020__create-table.sql:

CREATE TABLE sample.users
(
  id bigint not null generated always as identity primary key,
  email varchar(255) not null,
  password varchar(255) default null,
  timezone varchar(30) not null,
  created_at timestamp with time zone not null,
  updated_at timestamp with time zone not null
);

GRANT
  SELECT,
  INSERT,
  UPDATE,
  DELETE,
  TRUNCATE
ON ALL TABLES IN SCHEMA sample
TO "${dbUsername}";

Execução das migrações

[editar | editar código]

No terminal, execute:

# Needed by the Gradle task
export POSTGRES_ADMIN_PASSWORD="pass"
# Needed by the application (HOCON) config
export JDBC_CONNECTION_MAIN_PASSWORD="pass"

./gradlew migrate

Estado do banco de dados:

[editar | editar código]

Ao executar a Migration, uma tabela flyway_schema_history será criado no banco de dados. Essa tabela é responsável por manter o histórico da versão atual do banco de dados. Isso é útil, pois o Flyway será capaz de realizar uma nova atualização no banco de dados a partir da última versão registrada.

Flyway Schema History
Coluna Tipo Descrição
installed_rank INTEGER Ordem de instalação das migrações.
version VARCHAR Versão da migração.
description VARCHAR Descrição da migração.
type VARCHAR Tipo da migração (SQL, JDBC, etc.).
script VARCHAR Nome do script de migração.
checksum INTEGER Checksum do script para verificação de integridade.
installed_by VARCHAR Usuário que aplicou a migração.
installed_on TIMESTAMP Data e hora em que a migração foi aplicada.
execution_time INTEGER Tempo de execução da migração em milissegundos.
success BOOLEAN Indica se a migração foi bem-sucedida.

Algumas boas práticas

[editar | editar código]
  • Testar migrações em pipelines de CI/CD e em bancos de teste antes de produção[3].
  • Manter backups regulares e ter estratégias de rollback com flywayUndo[4].

Exemplo utilizado de repositório com migrations

[editar | editar código]

Kotlin Database Migrations.

Referências

[editar | editar código]
  1. 1,0 1,1 Schema migration. Página visitada em 2025-04-21.
  2. Organising your migrations. Página visitada em 2025-04-21.
  3. 3,0 3,1 Why you should be testing Flyway migrations in CI. Página visitada em 2025-04-21.
  4. 4,0 4,1 4,2 The Flyway Migrate Command Explained Simply. Página visitada em 2025-05-13.