Migrations com Kotlin e Flyway
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]Flyway
[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]- Quatro arquivos
V1_{descrição}.sql,V2_{descrição}.sql,V3_{descrição}.sqleV4_{descrição}.sqlde migration estão presentes num diretório. - O Flyway é executado e verifica que
V1_{descrição}.sqleV2_{descrição}.sqlestão aplicadas ao database e a ordem delas, sendoV2_{descrição}.sqla última migration aplicada (versão atual éV2). - A migração é executada pelo Flyway com versão alvo
V4, e agoraV3_{descrição}.sqleV4_{descrição}.sqlestão aplicadas ao database (versão virouV4)[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.
| 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]Referências
[editar | editar código]- ↑ 1,0 1,1 Schema migration. Página visitada em 2025-04-21.
- ↑ Organising your migrations. Página visitada em 2025-04-21.
- ↑ 3,0 3,1 Why you should be testing Flyway migrations in CI. Página visitada em 2025-04-21.
- ↑ 4,0 4,1 4,2 The Flyway Migrate Command Explained Simply. Página visitada em 2025-05-13.