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 with CRUD operations
-
Attribute - Custom queries and mutations (see ksql syntax), with five variants:
- ScalarAttribute, returning Any?
- RowAttribute, returning Instance?
- RowSetAttribute, returning Sequence
- MutationAttribute, returning Long (either the number of modified rows, or the generated serial value)
- TransactionAttribute, returning List (number of modified rows for each comprised mutation statement)
Four main methods in the lifecycle of database objects instances (along with transaction handling):
Instance.insert()
Entity.fetch(primaryKey)
Instance.update()
Instance.delete()
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 {
title string(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.3.0"
id("com.republicate.skorm") version "0.14"
}
skorm {
structure.set(File("src/commonMain/model/todo.kddl"))
runtimeModel.set(File("src/commonMain/model/todo.ksql"))
destPackage.set("com.example.todo")
}
dependencies {
implementation()
implementation()
implementation()
}
4. Use the generated code
val database = TodoAppDatabase(CoreProcessor(JdbcConnector()))
database.configure(mapOf(
"jdbc" to mapOf(
"url" to "jdbc:h2:mem:todo",
"user" to "sa"
)
))
database.initialize()
val task = Task().apply {
title = "Learn skorm"
completed = false
insert()
}
fetched = Task.fetch(task.taskId)
fetched?.let {
it.completed =
it.update()
}
Task.browse().forEach { println(it.title) }
That's it! The skorm Gradle plugin generates all the necessary Kotlin classes 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()
task["title"] = "Learn skorm"
task["completed"] = false
task.insert()
val fetched = taskEntity.fetch(task["taskId"])
fetched?.let {
it["completed"] =
it.update()
}
taskEntity.browse().forEach { 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()
Task().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 iterate lazy Sequence results before the block exits. Not available in REST mode.
Reference
Configuration
Database *—— Schema *—— Entity *—— Instance
Five 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
perform(name, params...) - for atomic mutations
Identifiers Mapping
Skorm automatically maps between database identifiers and Kotlin property names:
database.configure(mapOf(
"core" to mapOf(
"mapping" to mapOf(
"read" to listOf("snakeToCamel"),
"write" to listOf("camelToSnake")
)
)
))
Built-in mappers:
snakeToCamel / camelToSnake
lowercase / uppercase
- Custom mappers can be registered
Values Filtering
Transform values during read/write operations:
database.configure(mapOf(
"core" to mapOf(
"filter" to mapOf(
"read" to mapOf(
"timestamp" to "epochToLocalDateTime"
),
"write" to mapOf(
"timestamp" to "localDateTimeToEpoch"
)
)
)
))
Connector Configuration
JDBC Connector:
database.configure(mapOf(
"jdbc" to mapOf(
"url" to "jdbc:postgresql://localhost:5432/mydb",
"user" to "dbuser",
"password" to "secret"
)
))
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
? - nullable field
! - unique constraint
= <value> - default value
Example:
table user {
!email string(255)
name string(100)?
age int =
status =
now()
}
Primary Keys
Primary keys are auto-generated as <table_name>_id with type serial:
table book {
title string
}
Relationships
-> - many-to-one (foreign key)
*-* - many-to-many (creates join table)
--> - one-to-many (reverse navigation)
Examples:
author *-* book // many-to-many: creates author_book join table
borrowing -> book, user // borrowing has book_id and user_id foreign keys
The kddl compiler generates:
- SQL DDL scripts for database creation
- Kotlin entity classes with typed properties
- Navigation methods for relationships (e.g.,
book.author(), author.books())
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 extension functions.
Declaration Syntax
attr [Entity.]name: ReturnType = SQL
mut [Entity.]name[(params)] = SQL
attr - defines a query attribute (SELECT)
mut - defines a mutation attribute (INSERT/UPDATE/DELETE)
- Schema-level:
attr name - function on schema
- Entity-level:
attr Entity.name - function on entity instance
Return Types
Supported scalar types: Int, Long, String, Boolean, Double, Float, LocalDate, LocalDateTime,
Parameters
SQL parameters are enclosed in curly braces:
attr getUserByEmail: 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;
Mutation parameters are declared in the signature:
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};
Sequence (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)
database example {
schema bookshelf {
table dude { name string }
table author { name string }
table book {
title string
genre enum('essay', 'literature', 'art')
language string(2)
}
table borrowing {
borrowing_date date = now()
restitution_date date?
}
author *-* book
book
borrowing -> book, dude
}
}
This generates:
- SQL creation script
- Entity classes:
Dude, Author, Book, Borrowing
- Relationship methods:
book.author(), author.books(), , etc.
Custom Queries (bookshelf.ksql)
database example {
schema bookshelf {
attr booksCount: Int =
SELECT count(*) FROM book;
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;
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 = ExampleDatabase(ApiClient())
{
window.onload = {
database.initialize()
database.initJoins()
database.initRuntimeModel()
document.querySelector()?.addEventListener() { event ->
event.preventDefault()
GlobalScope.launch {
bookId = form.getAttribute()
book = Book.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 = Book.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!