skorm
Simple Kotlin Object Relational Mapping
The nicest Kotlin multiplatform ORM around. Fully multiplatform. Coroutines-enabled.
Concepts
Five main concepts:
-
Database - Root container for your data model
-
Schema - Logical grouping of entities (see Configuration)
-
Entity - Corresponds to a table or view (defined in kddl syntax)
-
Instance - A single row in a table, read-only; a MutableInstance adds the writes
-
Attribute - Custom queries and mutations (see ksql syntax), with five variants:
- ScalarAttribute, returning Any?
- RowAttribute (and NullableRowAttribute), returning a row: an Instance, or a plain kson object
- RowSetAttribute, returning a Flow of rows
- MutationAttribute, returning Long (either the number of modified rows, or the generated serial value)
- TransactionAttribute, work in progress (a
mut block of statements is a MutationAttribute, run in one transaction)
Four main methods in the lifecycle of database objects instances (along with transaction handling):
MutableInstance.insert()
Entity.fetch(primaryKey)
MutableInstance.update()
MutableInstance.delete()
The runtime has two halves: , , , read; the objects of a mutable database also implement , , , , which is where and the writes live. is an interface over a read-only map (); a is backed by a mutable one () — a read-only row has no at all.
Three main customization points (see Configuration):
- identifiers mapping (snake to camel, prefix/suffix removal, lowercase ...)
- fields filtering (hide secret field, mark field as read-only, ...)
- values filtering (transform timestamps, etc.)
Two model definition formats: kddl for DDL (schema structure), ksql for DML (custom queries and mutations). And two concrete database connectors, one for JDBC and one for a service API. More to come, hopefully.
One goal: maximum simplicity without conceeding anyting to extensibility.
Zero annotation. Zero SQL code fragmentation.
Quick Start
Let's create a simple todo list application.
1. Define your schema (todo.kddl)
database todo_app {
schema todos {
table task {
*task_id serial
title varchar(200)
completed boolean = false
}
}
}
2. Add custom queries and mutations (todo.ksql, optional)
database todo_app {
schema todos {
attr pendingCount: Int =
SELECT count(*) FROM task WHERE completed = false
mut Task.toggle =
UPDATE task SET completed = NOT completed WHERE task_id = {task_id}
}
}
3. Configure the Gradle plugin (build.gradle.kts)
plugins {
kotlin("multiplatform") version "2.4.0"
id("com.republicate.skorm") version
}
skorm {
model.(file())
attributes.(file())
destPackage.()
dialect.()
}
dependencies {
implementation()
implementation()
implementation()
}
Nothing else to declare: the single generateSkormCode task registers its output on your Kotlin
source sets, so Gradle sequences generation before compilation on its own — no srcDir, no
dependsOn. What it emits follows the platforms you build for, and core / client force either
one on or off, for instance to emit client code in a project that has no JS target of its own.
One call, initialize(), registers the entities, the navigations and the ksql attributes.
Options:
Generated code is laid out by role, not by source set, because one directory often feeds several
of them — client serves jsMain, wasmJsMain and linuxX64Main alike:
build/generated-src/
├── common/kotlin row interfaces and their impl classes, navigations, attribute accessors
├── core/kotlin server-side attribute registrations
├── core/resources database creation script
└── client/kotlin REST client attribute registrations
common is registered on commonMain, core on the JVM target's source set, client on every
JS, wasm and native one. In a plain kotlin("jvm") project there is a single source set, and
common and core are both registered on main.
The generated types are nested (ExampleDatabase.BookshelfSchema.Book); alias in your own code
the ones you use, e.g. typealias Book = ExampleDatabase.BookshelfSchema.Book.
Two halves: read-only and mutable
The generator emits a read-only database and a mutable one extending it, so that code which must
not write — a render, a user-edited template — is handed a database that cannot, not one that
promises not to:
A row type is an interface, so a table hierarchy (MutableVip : Vip, MutablePerson) and mutability
inherit side by side; the Impl class behind it is the storage, and carries the blocking twins.
The companion object is the entity: Book.browse(), Book.fetch(id) read through the read-only
database, MutableBook.browse(), through the mutable one. A row has no
constructor: builds one to insert. Read attributes and navigations are
declared once, on the read-only interface, and inherited; the mutable interface redeclares those
returning rows, covariantly, and adds the mutations.
Constructing a MutableExampleDatabase also constructs its read-only sibling over the same
processor, reachable as mutableDb.readOnly (and ExampleDatabase.instance): both share the
attribute registry and any ambient transaction, and the sibling reads on the processor's read
connector when one is configured. A read-only database holds the processor through a read-only
view (ReadOnlyProcessor): a caller that ignores the Kotlin types, a template engine calling
by reflection, gets an exception, not a write. An app that never writes constructs alone —
and can set on the plugin, so that the mutable half is not even generated.
4. Use the generated code
val database = MutableTodoAppDatabase(CoreProcessor(JdbcConnector()))
database.configure(mapOf(
to mapOf(
to mapOf(
to ,
to
)
)
))
database.initialize()
task = MutableTask.new().apply {
title =
completed =
insert()
}
fetched = MutableTask.fetch(task.taskId)
fetched?.let {
it.completed =
it.update()
}
Task.browse().collect { println(it.title) }
That's it! The skorm Gradle plugin generates all the necessary Kotlin types from your .kddl file.
Dynamic Usage (Without Code Generation)
You can also use skorm without the code generator, accessing entities dynamically:
val schema = database.schema("todos")
val taskEntity = schema.entity("task")
val task = taskEntity.new() MutableInstance
task.put(, )
task.put(, )
task.insert()
fetched = taskEntity.fetch(task[]!!) MutableInstance?
fetched?.let {
it.put(, )
it.update()
}
taskEntity.browse().collect { println(it[]) }
This is useful for generic tools, migrations, or when the schema is only known at runtime.
Transactions
Transactions are ambient: wrap any suspend code in database.transaction { ... } and every skorm operation against that database inside the block — entity ops, attributes, raw eval/perform — joins the same transaction, without passing any handle around.
database.transaction {
task.update()
MutableTask.new().apply { title = "follow-up"; insert() }
}
Nested blocks on the same database join the enclosing transaction (single commit). The transaction holds one connection: don't fan out parallel coroutines inside the block, and collect row flows before the block exits. Not available in REST mode.
Reference
Configuration
Database *—— Schema *—— Entity *—— Instance
Four main verbs to interact with attributes:
eval(name, params...) - returns a scalar value
retrieve(name, params...) - returns a single row (plus Entity.fetch(params...) to get an instance by ID)
query(name, params...) - returns a rowset
Identifiers Mapping
Skorm automatically maps between database identifiers and Kotlin property names:
database.configure(mapOf(
"core" to mapOf(
"mapping" to mapOf(
"read" to "snake_to_camel",
"write" to "camel_to_snake"
)
)
))
A value is a comma-separated list of mappers, composed. Without read, it is snake_to_camel; without write, camel_to_snake then quoted in the database's own case.
Built-in mappers:
snake_to_camel / camel_to_snake
snake_to_pascal / pascal_to_snake
lowercase / uppercase
- Custom mappers can be registered (
IdentifiersMapping["name"] = { … })
Values Filtering
Transform values as they are read, by SQL type:
ValuesFiltering["trim"] = { it?.toString()?.trim() }
database.configure(mapOf(
"core" to mapOf(
"filter" to mapOf(
"read" to mapOf(
"text" to "trim"
)
)
)
))
json/jsonb columns are parsed by default. filter.write is accepted but not applied yet.
Connector Configuration
JDBC Connector:
database.configure(mapOf(
"core" to mapOf(
"jdbc" to mapOf(
"url" to "jdbc:postgresql://localhost:5432/mydb",
"login" to "dbuser",
"password" to "secret"
)
)
))
Rows are a Flow. browse(), navigations to many rows and attributes return a cold : nothing runs until it is collected, rows are fetched as they are collected, and what backs them is released when the collection ends, exhausted or not. Collect once, inside the transaction the flow was queried in. Every call the processor makes to its connector runs on , on the JVM, so a handler on an event loop awaits rows instead of blocking on them. On JDBC a many-row read streams: the driver fetches rows at a time (1000 by default) on a connection held until the flow is done, then committed and released, so a million-row query costs a million rows of memory nowhere. Blocking twins are the exception by construction: they park the calling thread, so a template that uses them is rendered off the event loop, .
Read connector. CoreProcessor(connector, readConnector) runs the reads of a read-only database on the second connector — a pool on a SELECT-only role is what makes the database actually read-only. It takes its settings from core.read.<tag> (core.read.jdbc.url, …), or the write connector's when absent. With a single connector, core.read.* is ignored: the read-only build's SELECT-only guarantee comes from the database role, not from the library, which only routes the reads. Inside a transaction { } of the mutable database, the read-only one reads on the transaction's connection.
API Client (for JS/WASM):
val database = TodoAppDatabase(ApiClient("https://api.example.com"))
database.initialize()
kddl Syntax
The kddl (Kotlin Data Definition Language) format defines your database structure. It generates both SQL DDL scripts and Kotlin classes.
Basic Structure
database <name> {
schema <name> {
table <name> {
<field_name> <type> [modifiers]
}
}
}
Field Types
Field Modifiers
* - part of the primary key (prefix)
! - unique constraint (prefix)
? - nullable field
= <value> - default value
Example:
table user {
*user_id serial
!email varchar(255)
name varchar(100)?
status =
now()
}
Primary Keys
Declare them with *. A table that a link references without declaring one gets an implicit
<table_name>_id serial key — deprecated, kddl warns about it:
table book {
*book_id serial
title varchar(100)
}
Relationships
A link's * marks the many side, which holds the foreign key; a chevron (>) pointing at the
one side restricts traversal to the reference. With no chevron, both navigations are generated.
book *-- author // book holds author_id — Book.author() and Author.books()
borrowing -> book // borrowing holds book_id — Borrowing.book() only
book *-* tag // join table book_tag — Book.tags() and Tag.books()
table book {
donor -- dude? // field link, both ways — Book.donor() and Dude.donorBooks()
editor -> dude? // field link, reference only — Book.editor()
}
When two links reach the same table, as donor and editor do here, the collection is named
after the field (donorBooks) rather than the table (books).
A many-to-many is always navigable both ways. a -- b between two tables is rejected, nothing
saying which side holds the key (kddl ≥ 0.27).
The kddl compiler generates:
- SQL DDL scripts for database creation
- Kotlin row interfaces with typed properties, read-only and mutable
- Navigation methods for the relationships above, as members of the generated interfaces — each with a
blocking twin on the impl class (
BookImpl.tagsBlocking(), reachable by reflection as tags()) for callers that cannot suspend
For complete kddl documentation, see the kddl project.
ksql Syntax
Beyond the basic CRUD operations, skorm allows you to define custom queries and mutations using the ksql format. These definitions generate type-safe Kotlin objects and member functions on their receivers.
Declaration Syntax
attr [Entity.]name[(params)]: ReturnType = SQL
mut [Entity.]name[(params)] = SQL
mut [Entity.]name[(params)] = { SQL
attr - defines a query attribute (SELECT)
mut - defines a mutation attribute (INSERT/UPDATE/DELETE)
- Schema-level:
attr name - member function of the schema class
- Entity-level:
attr Entity.name - member function of the row interface (a mut: of the mutable one)
Return Types
Supported scalar types: Int, Long, String, Boolean, Double, Float, LocalDate, LocalDateTime,
Parameters
SQL parameters are enclosed in curly braces:
attr getUserByEmail(email: String): User? =
SELECT * FROM users WHERE email = {email};
For entity-level attributes, all entity fields are automatically available:
attr Book.currentBorrower: Dude? =
SELECT dude.* FROM borrowing
JOIN dude USING (dude_id)
WHERE book_id = {book_id}
AND returned_date IS NULL;
Any other parameter is declared in the signature, for attributes and mutations alike:
mut Book.lend(dude_id: Long) =
INSERT INTO borrowing (book_id, dude_id, borrowed_date)
VALUES ({book_id}, {dude_id}, now());
Examples
Schema-level scalar:
attr booksCount: Int =
SELECT count(*) FROM book;
Entity-level composite object:
attr Book.currentBorrower: (Dude, borrowing_date: LocalDateTime)? =
SELECT dude.*, borrowing_date FROM bookshelf.borrowing
JOIN dude USING (dude_id)
WHERE book_id = {book_id}
AND restitution_date IS NULL;
Mutation with parameters:
mut Book.lend(dude_id: Long) =
INSERT INTO borrowing (dude_id, book_id, borrowing_date)
VALUES ({dude_id}, {book_id}, now());
Anonymous object:
attr Book.stats: (title_length: Int, borrowed: Int) =
SELECT
CHARACTER_LENGTH(title) title_length,
(SELECT COUNT(*) FROM borrowing WHERE book_id = {book_id}) borrowed
FROM book
WHERE book_id = {book_id};
Flow (rowset):
attr topBorrowers: (dude_id: Long, borrow_count: Int)* =
SELECT dude_id, COUNT(*) borrow_count
FROM borrowing
GROUP BY dude_id
ORDER BY borrow_count DESC
LIMIT 10;
All generated functions are coroutine-based (suspend) and type-safe, providing compile-time checking of parameters and return types.
Complete Example
Let's build a complete bookshelf application that tracks books and borrowings, demonstrating both JVM backend and JS frontend using the same business logic.
Database Schema (bookshelf.kddl)
This generates:
- SQL creation script
- Row interfaces:
Dude, Author, Book, , and their twins
Custom Queries (bookshelf.ksql)
database example {
schema bookshelf {
attr booksCount: Int =
SELECT count(*) FROM book;
attr Book.currentBorrower: (Dude, borrowing_date: LocalDate)? =
SELECT dude.*, borrowing_date FROM bookshelf.borrowing
JOIN dude USING (dude_id)
WHERE book_id = {book_id}
AND restitution_date IS NULL;
mut Book.lend(dude_id: ) =
INSERT INTO borrowing (dude_id, book_id, borrowing_date)
VALUES ({dude_id}, {book_id}, now());
mut Book.restitute =
UPDATE borrowing SET restitution_date = NOW()
WHERE book_id = {book_id} AND restitution_date IS NULL;
attr Book.stats: (title_length: , borrowed: ) =
SELECT
CHARACTER_LENGTH(title) title_length,
(SELECT COUNT(*) FROM borrowing WHERE book_id = {book_id}) borrowed
FROM book
WHERE book_id = {book_id};
attr topBorrowers: (dude_id: , borrowed: )* =
SELECT dude_id,
COUNT(restitution_date) borrowed
FROM borrowing
GROUP BY dude_id
ORDER BY borrowed DESC;
}
}
JVM Backend (Server.kt)
JS Frontend (Client.kt)
import com.republicate.skorm.ApiClient
import kotlinx.browser.window
database = MutableExampleDatabase(ApiClient())
{
window.onload = {
database.initialize()
document.querySelector()?.addEventListener() { event ->
event.preventDefault()
GlobalScope.launch {
bookId = form.getAttribute()
book = MutableBook.fetch(bookId) ?: error()
dudeId = selectElement.value.toLong()
book.lend(dudeId)
document.location?.reload()
}
}
}
}
The Magic
The same business logic code works on both JVM and JS:
val book = MutableBook.fetch(bookId)
book?.let {
val borrower = it.currentBorrower()
it.lend(dudeId)
it.restitute()
}
On JVM: CoreProcessor → JDBC → Database
On JS: ApiClient → HTTP → REST API → CoreProcessor → JDBC → Database
The Processor abstraction makes your code platform-agnostic!