Corner cases
The edge rules of the Database module, grouped like its pages.
Statements and mapping
-
Tokens do not expand inside string literals.
'{prefix}profiles'stays as written; pass a table name as a value built fromdatabase.tablePrefix:Wrong: looks for a table literally named {prefix}profilessql("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{prefix}profiles'").single<Long>()Rightsql("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table", "table" to database.tablePrefix + "profiles").single<Long>() -
Tokens expand inside a dollar-quoted body (
$$ ... $$), but not inside the string literals and comments of that body: SQL a PostgreSQL function builds as text forEXECUTEkeeps{prefix}as written (example). -
"..."is a string literal on MySQL and MariaDB twin, as their defaultsql_modereads it, so nothing expands inside it there; quote names with`...`. On SQLite, H2 and PostgreSQL"..."is a quoted identifier, inside which tokens expand. -
ORDER BY :columndoes not sort: it binds a constant, the same for every row. Choose the column from texts you wrote (Dynamic identifiers). -
Table names of builders and stores, and the keys of a
Maprow, are SQL: they are written into the statement as names, not bound, so they never come from a player. Each must be a plain name once prefixed, letters, digits and_not starting with a digit ([A-Za-z_][A-Za-z0-9_]*); any other fails with aSqlTemplateExceptionnaming it, before any SQL runs:Plugin Shop: column name "coins total" is not allowed; names written into SQL by builders and stores are letters, digits and _, not starting with a digit ([A-Za-z_][A-Za-z0-9_]*). -
Every
?outside literals and comments is a positional parameter. A statement using PostgreSQL's JSONB operators?,?|and?&therefore cannot also have:namedparameters; use the functionsjsonb_exists,jsonb_exists_anyandjsonb_exists_allthere. -
A null bound through a pair has no type:
"owner" to nullis bound as an untyped NULL.bind("owner", owner)keeps the declared type ofowner, which engines that check parameter types (PostgreSQL) may need. -
A
ByteArrayis one value, never a collection to expand. -
singleOrNullis about rows, not values. It returns null when there is no row; a row whose only column is NULL needs a nullable type:single<Long?>(). -
Columns are matched ignoring case; of two columns with the same label, the first counts.
-
A store names its table and columns as the database stores them, so
@Column(name = "lastSeen")reaches a column created aslastSeen, which PostgreSQL and H2 keep aslastseen. Builders quote the names you give them, which makes them case-sensitive on those two engines: keep table and column names in lower case. -
Instantkeeps milliseconds. It is truncated when written, so a value read back equals the written one only to the millisecond. -
Builders write every constructor property, defaults included. A column whose database
DEFAULTshould apply (paid_at ... DEFAULT {now}) must not be a property of the row class, or the Kotlin value is written instead; only the key ofinsertAndGetKeyis left out, when it is0or null. -
upsertreturns 1 for an insert and for an update alike; MySQL itself counts an update as 2. -
Enums are stored by name. Renaming a constant makes old rows unreadable (
"GOLD" is not a Rank; known: MEMBER, VIP); migrate the rows in the same release. -
Several registered supertypes of one class: the nearest wins, the one no other matching registered type extends. Two equally near (a class and an interface it implements, say) fail with
Plugin Shop: GoldCoin matches the codecs registered for com.example.Coin and com.example.Shiny equally closely; register a codec for GoldCoin itself. A value class wrapping another value class is not a column type. -
insertReturningneeds the key as a property of the row class, named after the key column, holding aLong, anIntor a type stored as one of them; it is checked before the insert.
Threads and transactions
join()of a future that is done returns at once anywhere, the server thread included; one that is not done is guarded like any blocking call. Continue withthenSyncinstead of joining.database.async { }on a database thread runs inline, on that thread, not in parallel.onFailureSyncleaves the future failed, so athenSyncafter it never runs; put the failure branch last.- Only the outermost transaction retries: the
retriesof a nestedtransaction { }are ignored. setRollbackOnly()in a nested transaction rolls back the outermost one, all of it.- An exception from an
afterCommitaction is thrown out oftransaction { }after the commit: the data is written, and the transaction is not retried. - A broken connection is never retried: a write whose acknowledgement was lost may have been committed.
connection { }ends in auto-commit: if the block turned auto-commit off, what it left uncommitted is rolled back.0means the driver's limit, not no limit: on MySQL, MariaDB and PostgreSQL a connection that waits longer than twicequery-timeout(at least 2 minutes) for the server is broken, so withtimeout(Duration.ZERO)orquery-timeout: 0a statement running longer than 120 s still fails, as a broken connection. Raisequery-timeoutfor long work.
Stores
store[key] = valuefor a key that is not loaded writes the whole value as an upsert at the next save, over a row that may exist. To change a stored row,loadit first andupdateit.- A keyed store does not insert what
createmade until it is changed; a player store writes new players at the next save. - Values are immutable. A table store refuses at activation a class with a
varproperty or a mutable collection type (MutableList,MutableMap,ArrayList, ...); aStoreSchemastore is not checked, and a property changed inside its cached instance is neither seen nor saved. Replace the value withcopy. tryUpdatereturns null while a store transaction holds the key, even when the change itself would succeed.- Two timeouts bound the waits for a key:
stores.login-timeouta login's, andstores.lock-timeoutevery other (saves, loads, store transactions); the final save at stop waits only as long asshutdown-timeoutleaves. - Before
HIGH, a player's value is not loaded. AnAsyncPlayerPreLoginEventhandler atLOWEST,LOWorNORMALruns before the store; read the value fromPlayerJoinEventon. - Your quit handlers see the value before
onLeave, which the store applies atMONITOR. - The autosave events fire only for changed entries: an autosave with nothing to write fires
neither; the counts of the start event are those of the moment it fires, and a change made in
between is written but not counted. The end event of a run never comes once the plugin is
disabled, and an autosave the queue refuses ends at once with
saved0 and the refusal asfailure. - An NPC has no value:
profiles[npc]fails;profiles[npc.uniqueId]is null. - Offline players loaded by a store transaction stay cached until
stores.idle-expiry; withidle-expiry: 0nothing a keyed store loaded is ever forgotten. - A failed quit save keeps the values of the player in every player store cached and changed; the autosave writes them later and then forgets them.
- Stores follow the foreign keys between their tables (Stores that reference each other).
Tables that reference each other in a cycle are written in the order the stores were made, with
one warning at start:
the tables of stores eggs, hens reference each other through foreign keys, so no order writes them safely; they are written in the order they were made, and a new row the database refuses is retried at the next save. deletetakes along only the rows that extend the row: those of stores whose key column references the store's key column, with a key of the same class. A reference from another column (members.guild REFERENCES guilds (id)) is the database's: withoutON DELETEthe delete fails withFOREIGN_KEY; withON DELETE CASCADEthe database deletes rows that the referencing store may still cache, and its next save of them fails. Change or evict the referencing values first.- A table that is no store's and references a store's table (
payments.payer REFERENCES profiles (uuid)) sees a new player's row only once a save wrote it. Insert into it in a transaction of that store after changing the player's value, which writes the row first, or afterprofiles.save(player). evictkeeps a key that changed again while it was being saved: the new change stays cached for the next save.deleteof a store over your ownStoreSchemafails unless the schema overridesdelete.stores.save-failure.aftercounts from the first failure since the last successful write of a value; a change made meanwhile does not restart it, and a successful write of the value ends it. A value that saves again lets its player log in at once, even after a kick.kickconcerns player stores only: a failing value of a keyed store (a guild, say) is logged and retried, but kicks nobody, since no player owns it.shutdowncounts values of every store.- A player kicked under
kickkeeps their data in memory: the quit that follows does not forget the unsaved values, which are written once the database takes them and then forgotten. shutdownstops the server once, however many values keep failing, and only afterstores.save-failure.after: a database restart shorter thanstores.save-failure.afterstops nothing.- A kept value is checked row by row: when the database changed any column the value changes,
the whole value goes to
unsaved/conflicts/, also the columns that did not clash, so a row never mixes the two servers' changes. A value the database refuses at the start (a constraint) goes there too, with the database's message; the others are written. - A kept file waits for its database: a file kept for another engine, location or table prefix stays untouched, with a warning at every start, until the plugin uses that database again.
- A value of a store not made yet at the start (a store made later than in a bean constructor) stays in the file for the next start.
Migrations
- Line endings and a byte-order mark do not change the checksum; any other edit does, a comment or a trailing space included.
V01andV1are the same version, so both in one JAR are a duplicate.- A file with only comments, or an empty one, is a migration with no statements: it is
recorded in the history like any other, with
applied_statements0. This is the generic file of a step only one engine needs. - Adding or removing an engine override of an applied version changes the file that version resolves to on that engine, so its checksum no longer matches and the start fails as for an edit.
- Every engine directory is checked on every engine: a broken
postgresql/file fails the start on SQLite too, each file with the rules of its own engine. - A query override replaces queries by name, not files by name:
postgresql/ranks.sqlmay redefine a query ofprofiles.sql; the queries it leaves out keep their generic text. - A
-- name:line inside a block comment or a multi-line string is not a marker: markers are found by the same SQL reader that expands tokens, so only a line comment standing alone on its line starts a query. - Directives count only at the top of a file, before its first statement.
- Only
CREATE TRIGGER,PROCEDURE,FUNCTIONandEVENTbodies are kept whole; any other body containing;needs-- versatia: single-statement, or a$tag$quote on PostgreSQL. - Positional
?pass through migrations untouched, for PostgreSQL's operators;:nameis a problem, since migrations take no parameters. - A renamed plugin (
name:inplugin.yml) no longer finds its history rows: update theplugincolumn of its history table. With a prefix the message says so; without one the plugin starts as new and its first migration fails on the existing tables. - Backups are taken only of SQLite and H2 files that already have a history, so the very first
start makes none; two in the same second get
-2,-3.
Configuration
- The friendly-name prefix is the default and is recorded in
database.ymlat the first open, so a later change of the friendly name renames nothing. - A non-empty prefix belongs to one plugin: a second plugin under the same prefix (two plugins
with one friendly name) fails the start. Plugins with
table-prefix: ''shareschema_history, each with its own rows, and a migration creating a table another of them owns fails before it runs, naming the owner. Tables recorded before the module kept that list, or created outside migrations, are not known to belong to anyone: theCREATE TABLEitself fails then. import-fromcopies what the migrations create: the tables ofCREATE TABLEstatements, followed throughALTER TABLE ... RENAME TO,RENAME TABLEandDROP TABLE. A table made by a procedure or by other SQL is not copied; other tables of the source database never are.import-from.table-prefixrenames on the way: the source's tables under that prefix fill this plugin's tables under its own; without it the source is read under this plugin's prefix.${env:NAME},${file:PATH}and${pluginfile:PATH}work inhost,database,username,passwordand the text values ofpropertiesonly; anywhere else they are plain text. In those keys any other${...}is an error, so a password that really contains${goes into a file or a variable, whose content is taken as written. A number or a boolean inproperties(connectTimeout: 5000) stays as written.- Text keys refuse what YAML did not read as text: an unquoted
0123,yesor2024-01-01inpassword,databaseor any other text key is something else to YAML, so it is refused (forpasswordwithout the value in the message) instead of being used changed; quote it. Driverpropertiesare the exception: their numbers and booleans pass as written by YAML. - A key the engine does not use is ignored:
schemaoutside PostgreSQL,fileon a server engine,hostand the rest on SQLite and H2; none is an error. threadsandpoolfollow the engine and the connection unless a plugin'sdatabase.ymlsets them: the file the module writes at the first open sets neither, so a changedthreads-per-pluginor connection pool reaches every plugin on it at/versatia reload database.- A
database.ymlwithouttable-prefix(written by hand, or shipped in the plugin's JAR) gets the friendly-name prefix, unless the plugin's history lives only without a prefix: then it keeps'', with a warning at every open. Until the database opens,database.tablePrefixis the friendly-name prefix. An explicittable-prefix,''included, always wins. - The module's
defaultsnever reachthreads,poolor where the data lives, and a key a plugin'sdatabase.ymlsets, even to the built-in value, is never overridden by them. pool.connection-timeoutunder 250 ms is 250 ms,0included, at the start and at a reload alike; the validation timeout follows it, up to 5 s.- The import runs once per target: the
IMPORTrow of version 0 in the target's history skips it from then on; delete that row, with the copied rows, to import again. - An
h2orsqliteconnection ofconnections.ymlkeeps its file in the module's folder, a plugin's own in the plugin's folder; an inlineimport-fromreads files from the plugin's folder. engine: mysqlreaching MariaDB twin runs MariaDB's SQL and uses themariadb/overrides, through the MySQL driver, without a warning; only the summary line,MARIADB (engine: mysql) ..., tells.default-connectionnever moves a plugin that has data: a plugin with adatabase.dband nodatabase.ymlstays on SQLite, with a warning.
Shutdown and status
thenSyncandonFailureSyncsteps are dropped once the plugin is disabled; their futures fail.- A plugin enabled on a running server (a plugin manager) loads and claims the players already
online, but does not apply
onJointo them: they did not join now. See Players already online. /versatiadb statuslists a plugin under its friendly name; messages and log lines use thename:of itsplugin.yml.- A failed open is remembered for 5 seconds: calls in that time fail with the same exception without trying again.