Migrations and code generation#
The schema comes first, the code second. You write SQL and apply it, and the table definition classes are built from that.
| When | Locally | Everywhere else (CI, staging, production) |
|---|---|---|
| At build time | migrate → codegen → compileJava |
compileJava only |
| At startup | nothing | apply any migration that has not been applied |
Note
Commit the generated code to the repository. Otherwise you cannot build in
an environment with no DB.
codegenCheck in CI watches for drift.
Migrations#
Put your SQL in conf/migration/<schema name>/.
# --- !Ups
CREATE TABLE `note` (
`id` BIGINT UNSIGNED AUTO_INCREMENT COMMENT 'ID' PRIMARY KEY,
`title` VARCHAR(200) NOT NULL COMMENT 'Title',
`created_at` DATETIME NOT NULL COMMENT 'Created at'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Note';
# --- !Downs
DROP TABLE `note`;
./gradlew migrate
Note
That is MySQL DDL. Migration SQL is handed to the database as it is, so write
PostgreSQL syntax if that is what you are on (BIGINT UNSIGNED AUTO_INCREMENT becomes
bigserial, DATETIME becomes timestamp, no backticks). Unlike the SQL builder,
nothing here absorbs the dialect for you. To ship both, you can
split them per product.
- File names are up to you. They are applied in the natural order of the names
(a
001_prefix makes it obvious) <schema name>isschemefrom the configuration (or the data source name if that is missing)- They are looked up from the classpath. Add
conf/to your resources (Configuration)
Splitting into single statements#
One file can hold several statements. They are split on ;, but never on a
; inside a string, an identifier or a comment.
Where those start and end differs by product, so they are read with the rules of the product you are connected to.
| Written | MySQL | PostgreSQL |
|---|---|---|
the \ in 'a\'b' |
an escape | an ordinary character (an escape only in E'a\'b') |
# ... |
a line comment | not a comment |
1--2 |
not a comment (-- needs a space after it) |
a line comment |
`a` |
quotes an identifier | not a quote |
$$ ... $$ |
ordinary characters | a string (function bodies, DO blocks) |
/* /* */ */ |
closes at the first */ |
can nest |
Trap
Get the product's rules wrong and the split changes silently.
Read insert into t values ('c:\'); with MySQL's rules and the \' looks
like an escape, so the string never closes — every statement after it is
glued onto this one.
A fragment that is nothing but a comment is dropped (MySQL fails it with "Query
was empty"). MySQL's and MariaDB's /*!40101 ... */ and /*M!100301 ... */ are
executable comments, so they are kept.
Careful
If not one statement can be taken out of it, that is a failure. Letting "it was all comments" pass as success means a down that leaves the tables in place and deletes only the history.
The record, and failures#
| Table | What is in it |
|---|---|
migration |
The SQL applied, its hash, and its state (complete / up_error / down_error) |
migration_history |
A run record per statement (kind, SQL, success or failure, timestamp) |
A failure stops there, and the application does not start. That is so it
never takes requests in a half-migrated state.
The failure stays in migration as up_error, and the next run redoes
down → up.
Careful
DDL is not rolled back. MariaDB / MySQL commit implicitly on
CREATE TABLE and friends.
Put several DDL statements in one file and it stops with part of it applied.
Do not rewrite SQL that has already been applied#
The hash is checked, so rewriting it after it has been applied fails.
適用済みのマイグレーションが書き換えられています: 003_xxx.sql
(down が無効なため巻き戻せません。新しい SQL ファイルを追加してください)
In English: "a migration that has already been applied was rewritten: 003_xxx.sql (down is off, so it cannot be rolled back — add a new SQL file instead)."
Set migration.down = true (the default is false) and it redoes down → the
new up.
There is no command to roll back by hand. down runs in exactly two cases:
the file is gone, or the hash changed.
Splitting SQL per product#
The same DDL will not do for both MySQL and PostgreSQL (AUTO_INCREMENT versus
bigserial, ENGINE=InnoDB, how column comments are written).
Split them by filename suffix.
conf/migration/blog_example/
001_create_post.mysql.sql <- mysql / mariadb only
001_create_post.postgresql.sql <- postgresql only
002_add_status.sql <- no suffix = both
- The suffix is the same name you can write in
db.<name>.product(mariadbcounts asmysql) - A file with no suffix is applied on every product. SQL that works on both does not need splitting
- Files that were skipped are named in the startup log
マイグレーション: 他の製品向けを飛ばしました(いまは postgresql): 001_create_post.mysql.sql
In English: "migration: skipped files for other products (currently postgresql): ...".
What has been applied is remembered by filename, so
001_create_post.mysql.sql and 001_create_post.postgresql.sql are separate
records. A file that is present is never treated as "gone", not even a file
for the other product, so it is never downed.
Two files for the same version that both run on the current product is an error
(001_x.mariadb.sql and 001_x.mysql.sql both run on MySQL).
Trap
Renaming a file that has already been applied makes it a different file.
Rename 001_create_post.sql to 001_create_post.mysql.sql and the same SQL
runs a second time, giving you Table 'post' already exists.
That is detected by name and stopped, with the fix printed. The comparison is on the name with the product suffix removed, so it is still found when the split halves have different contents.
マイグレーション SQL の名前が変わったようです(いまは mysql):
001_create_post.sql → 001_create_post.mysql.sql
履歴の名前も変えてください。
UPDATE migration SET name = '001_create_post.mysql.sql' WHERE name = '001_create_post.sql';
UPDATE migration_history SET name = '001_create_post.mysql.sql' WHERE name = '001_create_post.sql';
In English: "the migration SQL looks renamed — rename it in the history too."
When the contents changed as well, hash is included in the UPDATE (that
version is already applied, so the record is corrected rather than re-applied).
The printed SQL belongs to that database's product; on the other product's
database, read it as that product's filename.
Careful
It stops when no file is left for the current product.
Rename 001_create_post.sql to 001_create_post.postgresql.sql only, and on a
MySQL database that file will never be read again. Rather than downing it
silently, jimble makes you choose: add the mysql file, or delete the history row.
Careful
Where an ALTER TABLE puts a new column differs by product.
MySQL takes AFTER body; PostgreSQL always appends.
The column order in the generated classes is the physical order, so the
same schema can produce a different order on a different product (the types,
nullability and comments are the same).
Generate on the product you lead with, and run codegenCheck there too.
When several servers start at once#
The row in db_lock is taken with FOR UPDATE before anything is applied, so
only one machine runs. The one that could not wait long enough fails to start
(migration.lock_timeout_seconds, 60 seconds by default).
Note
The lock wait timeout is set for that session only (SET SESSION).
The server-wide setting is left alone.
Migrations written in Java#
A migration you cannot express in SQL (repacking existing data, for instance) goes in a class.
CodeMigration.add(new V20260901FillUserKana());
Migration.install();
They run once each, in versionYyyyMmDd() order, and the result is kept in
migration_code.
The classpath is not scanned (principle 2). Write one, then add it.
Trap
The CLI's migrate does not run code migrations.
The registration lives in your application code, so they only run when the
application starts.
Code generation#
./gradlew codegen
From the DB schema, three kinds of file are generated per data source.
| Generated | Where it goes | What it is for |
|---|---|---|
| Schema class | db/<data source>/BlogExample.java |
BlogExample.db(), the table list |
| Table class | db/<data source>/table/post/Post.java |
The Columns, such as Post.id |
| Typed accessors | db/<data source>/table_data/post/AbstractPostData.java |
data.title("...").published(true) |
public class Post extends Table {
/* Post ID */
public static final Column id = new Column(instance(), "id", long.class, false, null, true);
private static final List<Column> COLUMNS = List.of(id, title, body, published, created_at);
}
The column list is fixed at generation time (nothing is reflected on at runtime).
Careful
The output directory is rebuilt from scratch every time.
Put a hand-written file under db/<data source>/ and the next codegen
deletes it.
Generated files carry a marker at the top, too.
/*
* このファイルは jimble が作りました(codegen)。手で直さないでください。
* 直しても次の codegen で消えます。
*/
In English: "jimble wrote this file (codegen). Do not edit it by hand — an edit is gone at the next codegen."
Type mapping#
| DB type | Java |
|---|---|
bigint |
long |
tinyint / bool |
boolean |
int / smallint / mediumint |
int |
decimal / float / double |
double |
datetime / date / timestamp |
java.util.Date |
varchar / text and so on |
String |
json |
Data |
Everything else (blob / enum and so on) |
String |
On PostgreSQL these are treated the same way.
| DB type | Java |
|---|---|
bigserial |
long |
serial / smallserial |
int |
numeric / real / double precision |
double |
boolean |
boolean |
jsonb |
Data |
Trap
tinyint is boolean no matter what width you gave it. If you are storing
something other than 0/1 in a tinyint(4), make it a smallint.
decimal becomes double, so it is a poor fit for money.
On PostgreSQL#
Write db.xxx.product = postgresql and the same ./gradlew codegen works
(Using the DB). Only the reading side changes; the shape of the
generated classes is the same.
There are a few places where it does not line up with MySQL.
| What it is | What happens |
|---|---|
bigserial / serial |
Read as auto-increment columns (the nextval(...) default is not emitted) |
| Expression indexes | Something like create unique index ... (lower(code)) does not count as a unique key. The column cannot be recovered, so this avoids mistaking the remaining columns for a unique set |
| Partial indexes | Ones with a where do not count as a unique key either. They are only unique among the rows that match the condition |
The generated SchemaSQL |
Written in that product's DDL (comments come out separately, as comment on ...) |
Note
If the catalog cannot be queried, we fail. Silently treating it as "zero tables" and deleting the generated code would hide the cause.
Tables that are not generated#
The tables jimble creates for itself (migration / db_cache / session /
batch_* and so on) are excluded automatically.
The application names its own MQ queue tables, so list those yourself.
codegen {
package = "db"
exclude_tables = ["mq_blog", "mq_scheduler"]
}
Note
Wildcards do not work (you cannot write mq_*). List them one by one.
Configuration#
migration {
on_startup = "auto" # auto | true | false. auto applies them everywhere but locally
down = false # whether to roll back
lock_timeout_seconds = 60
resource_dir = "migration"
}
codegen {
package = "db"
exclude_tables = []
}
From the CLI#
Where Gradle is not available (CI, production), the CLI does the same things.
java -cp app.jar io.jimble.db.cli.JimbleDbCli migrate
java -cp app.jar io.jimble.db.cli.JimbleDbCli codegen src/main/java
Both the configuration and the SQL are looked up from the classpath. The Gradle tasks are listed in the Gradle plugins.