Using the DB#
Configure it#
Write your data source in application.conf.
db {
blog_example {
main = true
driver = "org.mariadb.jdbc.Driver"
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?useUnicode=true&characterEncoding=UTF-8"
url = ${?DB_URL}
username = "blog"
username = ${?DB_USER}
password = ""
password = ${?DB_PASSWORD}
maximum_pool_size = 10
connection_pool_type = "hikari"
}
}
codegen {
package = "db"
}
Do not put secrets in the file. Write them as ${?ENV_NAME} and the
environment variable wins when it is set. When it is not, the value from the
line above stays.
jimble-db ships drivers for both MySQL / MariaDB and PostgreSQL, so you
normally do not have to add one.
Encrypting the connection (TLS)#
Since 2.6.0, the MySQL / MariaDB driver (MariaDB Connector/J 4.0) uses TLS by default and verifies the server's certificate (sslMode=verify-full).
The driver in 2.5 (3.5) connected without encryption when nothing was set.
If the certificate cannot be verified, the connection fails at startup. With a self-signed MySQL such as Docker's mysql:8.4, you get:
Cannot use authentication plugin caching_sha2_password with a Self signed certificates.
Either set sslMode=trust, use password with a MitM-Proof authentication plugin or provide server certificate to client
MariaDB 11.4 and later servers can be verified even with a self-signed certificate, with nothing added (a local mariadb:11.4 connected without any setting).
Choose with sslMode in the url:
| Setting | Where |
|---|---|
Nothing (verify-full) |
The certificate is signed by a CA the JDK trusts, or the server is MariaDB 11.4+ |
sslMode=verify-full&serverSslCert=/path/to/ca.pem |
Production. Hand over the server's CA certificate (for Amazon RDS / Aurora, the CA certificate AWS publishes) |
sslMode=trust |
Encrypts, but does not verify the certificate. Stops eavesdropping, not impersonation |
sslMode=disable |
No encryption. Local databases only |
To skip encryption only on your machine, put it in the local configuration:
# application.local.conf
include "application.conf"
db {
blog_example {
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?sslMode=disable"
}
}
Note
The old form useSSL=false is treated the same as sslMode=disable (jimble's own test configuration uses it).
Choose a DB product#
Set product and the SQL builder emits SQL for that product.
db {
blog_example {
main = true
product = "postgresql"
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://127.0.0.1:5432/blog_example"
username = "blog"
password = "blog"
password = ${?DB_PASSWORD}
}
}
Trap
Never write the ${?ENV} line on its own. When the variable is not set, the key
disappears entirely (the form above is a pair: a default, then an override). Connect
to PostgreSQL with the password gone and what comes back is
The server requested SCRAM-based authentication, but no password was provided. —
which does not look like a configuration problem at all.
When the database does not exist yet#
Write create_database_sql and it is run once, after a failed connection.
db {
blog_example {
...
create_database_sql = "CREATE DATABASE blog_example"
}
}
Leave it out and nothing happens, so you do not need it if you created the database by hand. In production you normally connect as a user without that privilege, so this is for development and CI.
| Value you can write | Product |
|---|---|
mysql (default) / mariadb |
MySQL 8.x / MariaDB |
postgresql / postgres / pgsql |
PostgreSQL 16 and later |
Leave it out and you get mysql. Write a value it does not know and
startup fails.
Not one line of your application code changes.
// This one line becomes
// MySQL SELECT `post`.`id` AS `post__id` ... LIMIT ? OFFSET ?
// PostgreSQL SELECT "post"."id" AS "post__id" ... LIMIT ? OFFSET ?
// depending on product
SQL.select().from(Post.instance()).where(Post.id.eq(1L));
Each data source can name a different product (MySQL for the main one, PostgreSQL for reporting, and so on).
Note
Only MySQL (MariaDB) and PostgreSQL are supported. An unknown name fails at startup — falling back to MySQL silently would send backtick-quoted SQL to an application written for PostgreSQL.
There is no hook for adding your own dialect (Dialect is sealed). A dialect
has 40-odd methods, one per SQL function, so every function jimble adds would
break an outside implementation. If you need another product, ask for it to be
added to the framework.
What you cannot write for a given product#
What you cannot write raises DialectException at the point where the SQL is
built. You never get as far as running it and failing on the product's syntax
error.
| Builder | PostgreSQL |
|---|---|
forceIndex / useIndex / ignoreIndex (index hints) |
Not available. PostgreSQL has no hints, and silently dropping them would hide the slowdown, so it refuses |
Dsl.match(...) (full-text search) |
Not available. to_tsvector tokenizes differently and scores differently, so we do not silently substitute it |
Dsl.dateFormat(col, "%Y-%m-%d") |
Not available. to_char uses a different format language, so substituting it would return a different string instead of raising. Format it on the Java side |
Dsl.jsonExtract(col, "$.a[0]") |
Arrays, wildcards and quoted keys are not available. Only the $.a.b form |
The other way round, things that differ only in name we do line up silently
(RAND / RANDOM, IFNULL / COALESCE, TRUNCATE / TRUNC, and so on).
Dsl.concat(...) becomes || on PostgreSQL (PostgreSQL's concat() swallows
NULL as an empty string, so swapping the name alone would change the result
compared with MySQL).
When you really do want the product's own syntax, Dsl.freeSql(...) is the way out.
Things to watch#
- Spatial functions take their coordinates in a different order. SRID 4326 on MySQL 8 is latitude then longitude; PostGIS is longitude then latitude. jimble does not absorb this, so line it up yourself if you use both
- Migration SQL (
conf/migration/...) runs exactly as you wrote it. To run on both products, split it by filename suffix —001_xxx.mysql.sql/001_xxx.postgresql.sql(no suffix is applied on both). See Splitting SQL per product
Connecting at startup#
Writing the configuration is not enough. Call it explicitly from your main.
public static void main (String[] args) {
Migration.install(); // startup migrations (if you want them; before DBUtil.load)
DBUtil.load(Conf.conf().config(), App.class); // throws if it cannot connect. Startup stops here
JimbleServer.start(new App());
}
The second argument to DBUtil.load is the classpath anchor — migration SQL is
looked up from there. Pass a class of your own application.
When it cannot connect it throws SqlExecuteException (DB_007), and startup stops there.
There is no return value (void). Do not catch it; let it propagate out of main.
Note
In 1.x, DBUtil.load only returned false when it could not connect, and throwing that away let the server start anyway.
2.0 made it an exception (Moving to 2.0).
Trap
If your app has more than one entry point, put this sequence in one place. Write it separately in the web, batch and scheduler mains and one of them goes stale. Tests call the same thing (Pitfalls / Testing).
To shut down, call DBUtil.stop() (the web server does it as part of
graceful shutdown; in batches and tests you call it yourself).
When there is more than one data source#
db {
main_db { main = true, ... }
log_db { ... }
}
DBUtil.getDB("log_db") gets you a different data source.
The one with main = true is DBUtil.getMainDB() (there is no no-argument getDB()).
Everything listed at the top level is treated as its own thing:
- migrations under
conf/migration/<data source name>/are applied to it - codegen generates
db/<data source name>/for it migration/db_lock/db_value/db_cacheare created there too
If all you want is one more handle without copying the settings, use subs.
db {
main_db {
main = true
...
subs {
archive_db { }
}
}
}
Get it with db.newSubDB("archive_db").
Trap
Neither the connection nor the transaction is shared. subs is a separate pool
from its parent. Even when it points at the same database it is a different
connection, so writing through newSubDB inside the parent's transaction
does not roll back with it — one side stays. Assume it inherits the settings
from the parent and nothing more.
Trap
Migrations and codegen do not apply to subs. Per-data-source work only runs
for the top-level db { } entries. So all you can put in a subs is a scratch
table the application creates itself. Anything that needs a schema and generated
types (an audit log in another database, say) belongs in a second top-level entry.
Also, if a subs points at a different database, that database has to exist
first: subs connects while the parent data source is still being built, so a
create_database_sql written on a later top-level entry does not arrive in time
(startup stops with failed create subs datasource).
examples/approval-data has both a second top-level entry and a subs.
Generate table definitions#
./gradlew codegen
This generates one class per table from the DB schema.
Post.id // Column
Post.title
Post.instance() // Table
Add the io.jimble.db plugin and migrate → codegen → compileJava are wired
together. Write DDL, start up, and the code catches up on its own.
plugins {
id("io.jimble.db")
}
Commit the generated classes to the repository. If you do not commit them, you cannot build in an environment with no DB.
Selecting#
Data row = db.select(
SQL.select()
.from(Post.instance())
.where(Post.id.eq(1L))
).orElseThrow();
// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");
// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);
select returns Optional<Data>. It is empty when there are no rows
(the .orElseThrow() above is how you take it out when the row must be there).
When the row cannot be read you get an exception, not an empty result (see "Reading errors" below).
A SELECT result nests under the table name.
Join post and comment and the id on each side does not collide.
Query with Column and no string keys show up in your code.
row.getData(Post.instance()) pulls out one table's worth, flattened.
Trap
extractTableData does not flatten. It returns {post: {...}} still nested, so
serialising that to JSON leaves one level of nesting in. To flatten, use
getData(table) or flattenTable(table).
Trap
Results of a hand-written SQL string (db.select("SELECT ...")) do not nest. The nesting comes from the builder
aliasing columns as post__title, so your own SQL gives a flat Data. In a join, columns with the same name
(id …) silently overwrite each other with the later value — give them aliases.
JSON columns#
MySQL JSON and PostgreSQL json / jsonb columns come back already decoded, as Data (objects) or List (arrays).
row.getStringList("tags"); // ["a", "b"] an array column
row.getData("options"); // an object column
row.getString("tags"); // "a" <- only the first element, not JSON text
getString on an array column returns only the first element (a WARN is logged the first time it happens).
To get the JSON text itself, convert it in SQL: CAST(col AS CHAR) on MySQL, col::text on PostgreSQL.
Reading errors#
try (DB db = BlogExample.db()) {
/*
* 0件は空(空リスト・空の Optional・件数 0)、失敗は SqlExecuteException(2.0)。
* 書かなければ上まで飛んで 500。トランザクションの中なら巻き戻る。
*/
List<Data> rows = db.selectList(SQL.select().from(Post.instance()));
// 分岐したい失敗は一意制約くらい。それだけを受け止める
try {
db.insert(SQL.insert(Post.instance()).value(Post.title, "hello"));
} catch (DuplicateKeyException ex) {
Log.info("もうあります");
}
}
DB failures come back as exceptions. When a statement fails or the connection cannot be made, you get SqlExecuteException (unchecked).
Leave it uncaught and it travels up to a 500; inside a transaction, the transaction rolls back.
The code is in getCode() (DB_999 etc.), and the original JDBC exception is at getCause().getCause().
No rows is not a failure. It comes back as an empty Optional, an empty list, or a count of 0.
| Method | Returns | With no rows |
|---|---|---|
select / selectCached |
Optional<Data> |
An empty Optional |
selectList / selectListCached / selectListPerformance |
List<Data> (never null) |
An empty list |
insert |
Nothing (void) |
—— |
insertKey |
The generated key. SqlExecuteException if no key was generated |
—— |
update / delete |
The number of rows affected (int) |
0 |
execute |
The number of rows affected (int). 0 for DDL and statements that return a result set |
0 |
executeBatch / insertBatch |
A list of counts per statement / generated keys | An empty list for empty input |
executeBatch / insertBatch require every statement to be the same SQL. A mix throws with DB_998.
Note
On PostgreSQL, driver settings change the speed. jimble adds no driver settings to the connection URL.
- Bulk inserts: put
reWriteBatchedInserts=truein the URL and the INSERTs ofinsertBatchare sent as one multi-row statement - Reading large results in pieces:
fetch_sizeonly works inside a transaction (with autocommit on, the driver holds the whole result). Read insidedb.transaction(...)
"No rows" and "could not read"#
They come back as different things. An empty Optional means "no rows" and nothing else; a failure to read throws.
Optional<Data> user = db.select(sql, id); // throws SqlExecuteException here if it cannot be read
if (user.isEmpty()) { return nobody; } // empty means "no rows", nothing else
Data post = db.select(sql, id)
.orElseThrow(() -> new HttpException(404, "no such post"));
The day the DB goes down, your app does not answer "no such user".
Note
In 1.x, select returned null both for no rows and for a failure (you had to check isError() to tell them apart).
2.0 separates them. selectOrThrow / selectListOrThrow now mean the same as select / selectList, so they are
deprecated (they go away during 2.x).
Catching only unique-constraint violations#
The only failure you usually want to branch on is a unique-constraint violation. That one has its own type.
Hitting a unique constraint throws DuplicateKeyException (a subclass of SqlExecuteException) — from insert, insertKey, update or any other statement.
try {
long id = db.insertKey(SQL.insert(User.instance()).value(User.email, email));
} catch (DuplicateKeyException e) {
return "that email address is taken";
}
Trap
Catch it inside a transaction and carry on, and that Tx can no longer commit.
tx.commit() refuses with TransactionException (DB_004) and rolls everything back (Transactions).
To use it as "update if it exists", catch it outside the transaction or write it in SQL
(INSERT ... ON DUPLICATE KEY UPDATE on MySQL, INSERT ... ON CONFLICT on PostgreSQL).
Where to catch#
DB exceptions are unchecked, so the compiler never asks you to catch them. Decide where to catch with the table below. Anything not in it is left alone: the framework replies 500 and rolls back.
| When you | Catch | Reply with |
|---|---|---|
| Insert or change user input in a column with a unique constraint (UNIQUE or primary key) — email address, login ID and the like | DuplicateKeyException |
"Already taken" (409 or similar) |
Take RedisLock.lock(...) for a user action |
RedisLockException |
"Already in progress" (409 or similar; or branch on an empty tryLock(...)) |
Anything else (SqlExecuteException / TransactionException) |
Nothing | The framework replies 500 and logs it |
- Checking with
selectfirst does not let you skip the catch. If two requests arrive together, both pass the check and one hits the constraint - Catch outside the transaction (see the TRAP above)
- Write one test that inserts the same value twice; a forgotten catch shows up there as a 500
Generated keys and counts#
insert returns nothing. Use insertKey for the generated key, and execute when you need a count, as with INSERT ... SELECT.
db.insert(SQL.insert(Tag.instance()).value(Tag.name, name)); // just insert
long id = db.insertKey(SQL.insert(Post.instance()).value(...)); // the generated key
int count = db.execute("INSERT INTO archive SELECT * FROM post WHERE ..."); // the count
Note
In 1.x, insert returned the generated key when there was one and the number of rows inserted when there was not
(adding one auto-increment column changed its meaning). 2.0 splits them.
insertNoReturnKey is deprecated (it goes away during 2.x); use insert or execute.
Migrations#
Put them in conf/migration/<schema name>/001_xxx.sql.
# --- !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
What has already been applied is recorded, so it never runs twice. When several servers start at once, a lock is taken and only one of them runs.
When each thing runs, what happens when one fails, and the type mapping that gets generated are all in Migrations and code generation.