Migrations
Migrations allow you to version control your database schema. Sarah tracks which migrations have been executed and ensures each migration runs only once.
How Migrations Work
- You create migration classes that define schema changes
- Sarah maintains a migrations table to track executed migrations
- When you call
MigrationManager.execute(), only new migrations run - Migrations are executed in the order they were registered
Creating a Migration
Extend the Migration class and implement the up() method:
import fr.maxlego08.sarah.database.Migration;
public class CreateUsersTable extends Migration {
@Override
public void up() {
create("users", table -> {
table.uuid("uuid").primary();
table.string("name", 64);
table.text("location").nullable();
table.bigInt("play_time").defaultValue(0);
table.timestamps();
});
}
}Defining down()
Implement down() to register the inverse operations of up(). For example, a migration that creates a table can drop it during rollback. The operations in down() should cover the changes you want to revert.
public class CreateUsersTable extends Migration {
@Override
public void up() {
create("users", table -> {
table.uuid("uuid").primary();
table.string("name", 64);
});
}
@Override
public void down() {
drop("users");
}
}The default method is empty. A migration whose down() declares no operation cannot be rolled back.
Migration Operations
Create Table
public class CreatePlayersTable extends Migration {
@Override
public void up() {
create("players", table -> {
table.uuid("uuid").primary();
table.string("name", 32);
table.decimal("balance", 10, 2).defaultValue(0);
table.bool("is_banned").defaultValue(false);
table.timestamps();
});
}
}Create Table from Class Template
You can automatically create a table based on a record or class:
// Define your DTO
public record PlayerDTO(
UUID uuid,
String name,
long playTime
) {}
// Migration
public class CreatePlayersFromTemplate extends Migration {
@Override
public void up() {
create("players", PlayerDTO.class);
}
}Alter Table
Modify an existing table:
import fr.maxlego08.sarah.SchemaBuilder;
public class AddEmailToUsers extends Migration {
@Override
public void up() {
SchemaBuilder.alter(this, "users", table -> {
table.string("email", 255).nullable();
});
}
}Create Index
Create an index for better query performance:
public class CreateUsersNameIndex extends Migration {
@Override
public void up() {
index("users", "name");
}
}Drop Table
Remove a table:
public class DropOldLogsTable extends Migration {
@Override
public void up() {
drop("old_logs");
}
}Rename Table
import fr.maxlego08.sarah.SchemaBuilder;
public class RenamePlayersTable extends Migration {
@Override
public void up() {
SchemaBuilder.rename(this, "players", "users");
}
}Column Types
Sarah supports various column types:
| Method | SQL Type | Example |
|---|---|---|
uuid(name) | VARCHAR(36) | table.uuid("player_id") |
string(name, length) | VARCHAR(length) | table.string("name", 64) |
text(name) | TEXT | table.text("description") |
longText(name) | LONGTEXT / PostgreSQL: TEXT | table.longText("content") |
integer(name) | INT | table.integer("count") |
bigInt(name) | BIGINT | table.bigInt("balance") |
decimal(name) | DECIMAL | table.decimal("price") |
decimal(name, length, decimals) | DECIMAL(length, decimals) | table.decimal("price", 10, 2) |
bool(name) | BOOLEAN | table.bool("active") |
json(name) | JSON | table.json("metadata") |
blob(name) | BLOB / PostgreSQL: BYTEA | table.blob("data") |
timestamp(name) | TIMESTAMP | table.timestamp("verified_at") |
date(name) | DATE | table.date("birth_date") |
Types are adapted to the configured dialect. See the column type reference, including ENUM and PostgreSQL.
Column Modifiers
Primary Key
table.uuid("uuid").primary();Auto Increment
table.autoIncrement("id"); // INT
table.autoIncrementBigInt("id"); // BIGINTOn PostgreSQL, auto-incrementing keys use GENERATED BY DEFAULT AS IDENTITY.
Nullable
table.string("nickname", 32).nullable();Default Value
table.bigInt("balance").defaultValue(0);
table.bool("active").defaultValue(true);
table.string("status", 16).defaultValue("pending");Default Current Timestamp
table.timestamp("created_at").defaultCurrentTimestamp();Unique
table.string("email", 255).unique();Foreign Key
table.uuid("user_id").foreignKey("users");
// or with custom column and cascade
table.uuid("user_id").foreignKey("users", "uuid", true);Timestamps Helper
Add created_at and updated_at columns:
create("posts", table -> {
table.autoIncrementBigInt("id");
table.string("title", 255);
table.timestamps(); // Adds created_at and updated_at
});Or individually:
table.createdAt(); // Just created_at
table.updatedAt(); // Just updated_atOn PostgreSQL, updated_at defaults to CURRENT_TIMESTAMP but does not change automatically on updates: assign it explicitly. These helpers use the connection dialect at execution time, without requiring MigrationManager.setDatabaseConfiguration() first.
Running Migrations
You can call execute() again in the same JVM without duplicating registered schemas. A migration is recorded after all its operations succeed. If an operation fails, the migration is not recorded; operations already completed are not automatically undone. If the migration table cannot be read, execution throws instead of treating every migration as new.
Basic Execution
// Set custom table name (optional, defaults to "migrations")
MigrationManager.setMigrationTableName("my_plugin_migrations");
// Register migrations in order
MigrationManager.registerMigration(new CreateUsersTable());
MigrationManager.registerMigration(new CreatePostsTable());
MigrationManager.registerMigration(new AddEmailToUsers());
// Execute all pending migrations
MigrationManager.execute(databaseConnection, logger);Checking Migration Status
Read the names of applied migrations and the pending Migration objects. Pending migrations follow registration order. The tracking table is created if needed.
List<String> applied = MigrationManager.getAppliedMigrations(connection, logger);
List<Migration> pending = MigrationManager.getPendingMigrations(connection, logger);Rolling Back Migrations
Roll back the last applied migration, or several migrations, and retrieve their names. Only migrations registered with the manager can be rolled back; they are processed in reverse registration order.
List<String> last = MigrationManager.rollback(connection, logger);
List<String> previous = MigrationManager.rollback(connection, logger, 2);steps must be at least 1. Every selected migration must register operations in down(); otherwise an IllegalStateException is thrown before schema changes. After a successful rollback, its record is removed, so the next execute() can apply it again. If there is nothing to roll back, the result is an empty list.
A failed rollback throws DatabaseException. Migrations already rolled back stay rolled back; rolling back several migrations is not one global transaction.
Complete Example
public class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
// Setup database connection
DatabaseConnection connection = setupConnection();
// Configure migration table name
MigrationManager.setMigrationTableName("myplugin_migrations");
// Register all migrations
registerMigrations();
// Run migrations
try {
MigrationManager.execute(connection, JULogger.from(getLogger()));
getLogger().info("Migrations completed successfully!");
} catch (RuntimeException e) {
getLogger().severe("Migration failed: " + e.getMessage());
e.printStackTrace();
}
}
private void registerMigrations() {
MigrationManager.registerMigration(new CreatePlayersTable());
MigrationManager.registerMigration(new CreateHomesTable());
MigrationManager.registerMigration(new CreateWarpsTable());
MigrationManager.registerMigration(new AddPlayerEmailColumn());
}
}Migration Manager Methods
| Method | Description |
|---|---|
setMigrationTableName(name) | Set custom migrations table name |
getMigrationTableName() | Get current migrations table name |
registerMigration(migration) | Register a migration to be executed |
getMigrations() | Get list of registered migrations |
execute(connection, logger) | Execute all pending migrations |
getAppliedMigrations(connection, logger) | Get applied migration names (List<String>) |
getPendingMigrations(connection, logger) | Get registered pending migrations (List<Migration>) |
rollback(connection, logger) | Roll back the last applied migration and return its name in a list |
rollback(connection, logger, steps) | Roll back the last applied migrations in reverse order |
setDatabaseConfiguration(config) | Set the database configuration |
getDatabaseConfiguration() | Get the database configuration |
Best Practices
1. Name Migrations Descriptively
// Good
public class CreateUsersTable extends Migration { }
public class AddEmailColumnToUsers extends Migration { }
public class CreateUserPostsIndex extends Migration { }
// Bad
public class Migration1 extends Migration { }
public class UpdateTable extends Migration { }2. Keep Migrations Small
Each migration should do one thing:
// Good - separate migrations
public class CreateUsersTable extends Migration { ... }
public class CreatePostsTable extends Migration { ... }
// Bad - too much in one migration
public class CreateAllTables extends Migration { ... }3. Never Modify Existing Migrations
Once a migration has been executed in production, create a new migration for changes:
// Don't modify CreateUsersTable after it's deployed
// Instead, create a new migration:
import fr.maxlego08.sarah.SchemaBuilder;
public class AddAvatarToUsers extends Migration {
@Override
public void up() {
SchemaBuilder.alter(this, "users", table -> {
table.string("avatar_url", 255).nullable();
});
}
}4. Use Timestamps
Always include timestamps for auditing:
create("important_data", table -> {
table.autoIncrementBigInt("id");
table.string("data", 255);
table.timestamps(); // Always include this
});Real-World Example
Here's a complete migration setup for a homes plugin:
// Migration 1: Create players table
public class CreatePlayersTable extends Migration {
@Override
public void up() {
create("homes_players", table -> {
table.uuid("uuid").primary();
table.string("name", 16);
table.integer("max_homes").defaultValue(3);
table.timestamps();
});
}
}
// Migration 2: Create homes table
public class CreateHomesTable extends Migration {
@Override
public void up() {
create("homes", table -> {
table.autoIncrementBigInt("id");
table.uuid("player_uuid").foreignKey("homes_players", "uuid", true);
table.string("name", 32);
table.string("world", 64);
table.decimal("x", 10, 2);
table.decimal("y", 10, 2);
table.decimal("z", 10, 2);
table.decimal("yaw", 5, 2);
table.decimal("pitch", 5, 2);
table.timestamps();
});
}
}
// Migration 3: Add icon column
public class AddIconToHomes extends Migration {
@Override
public void up() {
SchemaBuilder.alter(this, "homes", table -> {
table.string("icon", 64).defaultValue("GRASS_BLOCK");
});
}
}Next Steps
- Learn about queries - Execute SELECT, INSERT, UPDATE, DELETE
- Schema Builder reference - All available methods