Kyo's SQL module supports both raw SQL and typed queries. The two build the same AST and run through the same execution path, so a raw fragment embeds in a typed query and a typed query embeds in raw SQL, and the typed DSL mirrors SQL syntax rather than translating collection operations: what you write keeps the shape of the statement it renders to.
Every driver implements its server's wire protocol directly on Kyo's async network stack, with no JDBC
underneath. A statement suspends a fiber rather than blocking a thread, and the whole implementation,
protocols included, is one set of shared sources compiled for the JVM, Scala.js, Scala Native, and Wasm, so
every feature works the same on all four platforms. PostgreSQL, MySQL and SQLite drivers ship with the
module, and the URL scheme selects among the drivers a build depends on. The two server drivers each expose
their engine's own capabilities (COPY, LISTEN/NOTIFY, LOAD DATA LOCAL INFILE) alongside the portable
surface; SQLite is embedded, so it runs in the same process with no server to reach and adds typed refusals
where the engine lacks a construct rather than methods.
Typed queries can be generated at compile time through ordinary Scala 3 inlining, with no annotation, build step, or return type annotation restrictions. The SQL string lands in the artifact as a constant avoiding runtime overhead.
libraryDependencies ++= Seq(
"io.getkyo" %% "kyo-sql" % "<latest version>",
"io.getkyo" %% "kyo-sql-postgres" % "<latest version>",
"io.getkyo" %% "kyo-sql-mysql" % "<latest version>",
"io.getkyo" %% "kyo-sql-sqlite" % "<latest version>"
)One import covers the surface: import kyo.*. A first program, end to end:
case class Ping(id: Long, note: String) derives SqlSchema
val program: Chunk[Ping] < (Async & Abort[SqlException]) =
DB.run(url) {
for
_ <- sql"INSERT INTO ping VALUES (1, 'hello')".execute
rows <- sql"SELECT id, note FROM ping".as[Ping].run
yield rows
}DB.run(url) opens a connection pool, supplies it to every statement inside, and closes it when the block
ends. The url is an ordinary runtime String. "postgres://user:pass@host:5432/app" runs the program
against PostgreSQL; swapping in "mysql://user:pass@host:3306/app" runs it against MySQL, or
"sqlite://app.db" against a local file, because the engine
is chosen by the URL, not by the code. For a URL written as a
literal, the scheme is resolved while compiling, and a scheme no driver on the classpath claims is a build
warning listing the schemes that are available (a warning rather than an error, since a backend registered
at startup can still claim it).
The DB in each statement's row is the database dependency, visible in the type: a computation that talks
to the database says so, and it cannot run until a DB.run says which database that is. There is no session
factory, no execution context, and no client value to thread. Most programs never hold a client. The cases
that need one are covered in Holding a client.
The examples from here on run against a small billing domain:
enum InvoiceStatus derives SqlSchema.Column:
case Pending, Paid, Cancelled
case class Customer(
id: Long,
email: String,
@column("signed_up_at") signedUpAt: Instant
) derives SqlSchema
case class Invoice(
id: Long,
@column("customer_id") customerId: Long,
total: BigDecimal,
status: InvoiceStatus,
note: Maybe[String]
) derives SqlSchemaderives SqlSchema makes a case class a row type, an enum stores itself by variant label with
derives SqlSchema.Column, and @column pins a SQL name where it differs from the field's. Those three
declarations carry every example below. Rows, names, and column types are covered in detail in
their own section.
Everything the module does is reachable with raw SQL alone. This section covers the module in that form, and the typed DSL follows it.
sql"..." builds a statement. Every interpolated value becomes a bind parameter, encoded through its column
codec, so the text written is exactly the text the server prepares and a value cannot splice into it:
val minTotal = BigDecimal(100)
val large: Chunk[Invoice] < (Abort[SqlException] & DB) =
sql"SELECT id, customer_id, total, status, note FROM invoice WHERE total >= $minTotal".as[Invoice].runInterpolating a value whose type has no column codec is a compile error at the $ that lifted it. .as[A]
names what a row decodes to, since the interpolator cannot know the statement's result shape, and .run
executes and decodes.
Rows decode by column name: SELECT customer_id, id, ... into Invoice lands every value in its own field,
whatever order the SELECT listed them in. A field that matches no column fails the decode with a typed error
naming the missing column, never a silent positional match. A computed column is addressed by aliasing it to
the field that reads it (SELECT sum(total) AS total ...).
.execute is the terminal for statements that answer with a row count. DB.executeRaw is the entry point
for DDL and migrations, running on the simple-query protocol, which takes no binds:
val note = "settled early"
val updated: Long < (Abort[SqlException] & DB) =
sql"UPDATE invoice SET note = $note WHERE status = 'Paid'".execute
val indexed: Long < (Abort[SqlException] & DB) =
DB.executeRaw("CREATE INDEX IF NOT EXISTS idx_invoice_status ON invoice (status)")Statements are assembled like any other value. ++ concatenates fragments, Fragment.empty is the
identity, and a clause built behind a condition is an ordinary expression:
val paidOnly = true
val listing: Chunk[Invoice] < (Abort[SqlException] & DB) =
val base = sql"SELECT id, customer_id, total, status, note FROM invoice"
val filter = if paidOnly then sql" WHERE status = 'Paid'" else Sql.Fragment.empty[Any]
(base ++ filter).as[Invoice].run
end listingA composed fragment's row tag is the last part's, so the .as[A] goes on the whole statement, at the end,
rather than on a part of it.
.stream pulls rows as the consumer reads them, so a result larger than memory is a Stream rather than a
Chunk. It is the one run form carrying Scope: the server-side cursor stays open for the stream's life
and closes when the enclosing scope ends:
val revenue: BigDecimal < (Abort[SqlException] & Scope & DB) =
sql"SELECT total FROM invoice".as[BigDecimal].stream
.fold(BigDecimal(0))((acc, total) => acc + total)The typed DSL expresses a statement as type-checked structure over the same AST. The same listing in both spellings, side by side:
val status = InvoiceStatus.Paid
val largestRaw: Chunk[BigDecimal] < (Abort[SqlException] & DB) =
sql"""SELECT total
FROM invoice
WHERE status = $status
ORDER BY total DESC
LIMIT 20""".as[BigDecimal].run
val largest: Chunk[BigDecimal] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.where(r => r.invoice.status == InvoiceStatus.Paid)
.select(r => r.invoice.total)
.orderBy(r => r.invoice.total.desc)
.limit(20)
.runThe result type follows the projection: one column answers that column's type and a tuple projection
answers tuples. Joins carry both aliases, and an outer join lifts the unmatched side's columns to Maybe,
because an unmatched row holds no value in them:
val billed: Chunk[(String, BigDecimal)] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.innerJoin(Sql.from[Customer])
.on(j => j.invoice.customerId == j.customer.id)
.select(j => (j.customer.email, j.invoice.total))
.run.to[B] retargets a tuple projection at a case class whose field types line up, so the same query answers
named fields instead:
case class CustomerBill(email: String, total: BigDecimal)
val billed: Chunk[CustomerBill] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.innerJoin(Sql.from[Customer])
.on(j => j.invoice.customerId == j.customer.id)
.select(j => (j.customer.email, j.invoice.total))
.to[CustomerBill]
.runSql.from[Invoice] names the record the lambdas receive after the type it queries: the key is the
decapitalized type name, so r.invoice.total reads the invoice table's column. The table name is not
written anywhere either: it derives from the type (invoice), and a
naming convention can case it. Both derived names take parameters when the
defaults do not fit. Sql.from[Invoice]("i") sets the alias, which re-keys the record (r.i.total) and is
how a self-join tells its two sides apart, and Sql.from[Invoice]("i", "invoice_ledger") names the table
outright beside it.
The override is per call site, which is a repetition where a schema's table names differ from its type names throughout, plural tables being the common case. Name the source once instead:
transparent inline def invoices = Sql.from[Invoice]("i", "invoice_ledger")
val pending = invoices.where(r => r.i.status == InvoiceStatus.Pending)
val topByTotal = invoices.orderBy(r => r.i.total.desc).limit(10)Every query then starts from invoices and the table name is written once. transparent is what makes it
work: without it the record type widens and the lambdas cannot name a column, and with it the source still
folds at compile time (see Static queries).
In a self-join both sides need their own key, so the predicate can name one against the other, pairing distinct invoices billed to the same customer:
val sameCustomer: Chunk[(Long, Long)] < (Abort[SqlException] & DB) =
Sql.from[Invoice]("first")
.innerJoin(Sql.from[Invoice]("second"))
.on(j => j.first.customerId == j.second.customerId && j.first.id < j.second.id)
.select(j => (j.first.id, j.second.id))
.runA table in a schema names it beside the table, on each of the four statement kinds:
transparent inline def invoices =
Sql.from[Invoice](alias = "i", schemaName = "app", tableName = "invoice")
Sql.insert[Invoice](schemaName = "app", tableName = "invoice")
Sql.update[Invoice](schemaName = "app", tableName = "invoice")
Sql.delete[Invoice](schemaName = "app", tableName = "invoice")Name the arguments. Sql.from's schema form takes three identifier strings in a row, and transposing two of
them is a mistake the compiler cannot see. Named arguments may be written in any order and the statement
still folds at compile time.
Qualifying names the table too. Like any explicitly named table, it is sent as written, so name the source
once with a transparent inline def rather than repeating both names at every call site.
The schema renders as its own quoted identifier, "app"."invoice", so it reaches the wire as two names
rather than one. Writing it into the table name instead does not work: Sql.from[Invoice]("i", "app.invoice") quotes the whole string as a single identifier and the server answers that no relation by
that name exists.
The schema travels in the statement, not in the session. That is what makes it reliable under a connection
pool: SET search_path or USE reaches whichever connection served it, leaving every other connection in
the pool resolving somewhere else, and since the same table name usually exists in both schemas the result
is wrong rows rather than an error.
Two schemas can meet in one statement, which is what a session default cannot express:
val billing: Chunk[(BigDecimal, String)] < (Abort[SqlException] & DB) =
Sql.from[Invoice](alias = "i", schemaName = "app", tableName = "invoice")
.innerJoin(Sql.from[Customer](alias = "c", schemaName = "crm", tableName = "customer"))
.on(j => j.i.customerId == j.c.id)
.select(j => (j.i.total, j.c.email))
.runA schema chosen at run time works on .run and .runDynamic but not on .runStatic, for the reason any
runtime value does not: the compile-time render cannot read it. See Static queries.
Each engine means something slightly different by a schema, and the qualified form reaches all of them:
PostgreSQL a schema inside the database, MySQL and Dolt a database on the server, SQLite and DoltLite an
attached database. On the last two, main is always reachable and a second database has to be attached when
the connection opens, since ATTACH run as a statement would reach one pooled connection only:
val config = SqlConfig.default.extension(SqliteAttach(Map("archive" -> "/var/db/archive.sqlite")))Statements that never reach the renderer, sql"..." fragments, executeRaw and migrations, carry whatever
names they were written with. On PostgreSQL a session default for those is
PostgresConfig(searchPath = Chunk("app", "public")), sent when each connection starts up so every
connection in the pool agrees. A deployment that can set ALTER ROLE <user> SET search_path = app on the
server needs nothing from the driver at all.
A fragment, a column reference, and a typed query are the same kind of AST node, so the two forms embed in
both directions. A typed column and a bound value drop into a raw predicate, and the fragment sits inside a
typed query's where like any other predicate:
val cutoff = BigDecimal(100)
val flagged: Chunk[Invoice] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.where(r => sql"${r.invoice.total} >= $cutoff".as[Boolean])
.runA typed query embeds in raw SQL wherever a subquery belongs:
val payers: Chunk[String] < (Abort[SqlException] & DB) =
val paidIds =
Sql.from[Invoice]
.where(r => r.invoice.status == InvoiceStatus.Paid)
.select(r => r.invoice.customerId)
sql"SELECT email FROM customer WHERE id IN ($paidIds)".as[String].run
end payersAdoption is incremental in both directions: a raw program lifts the one query that benefits from
types, a typed program drops to a fragment for the clause the DSL does not model, and nothing around the
statement changes. Sql.raw[A](text) does the same at expression granularity, for a single function or
operator the DSL lacks.
A query is an ordinary value at every step. There is no macro and no quotation: it is plain values composed
through Record, so the parts of a statement can be named separately, shared, and assembled. One filtered
trunk below serves two different reports:
val paid = Sql.from[Invoice].where(r => r.invoice.status == InvoiceStatus.Paid)
val firstPage: Chunk[(Long, BigDecimal)] < (Abort[SqlException] & DB) =
paid.select(r => (r.invoice.customerId, r.invoice.total)).orderBy(r => r.invoice.total.desc).limit(20).run
val largestPaid: Chunk[Maybe[BigDecimal]] < (Abort[SqlException] & DB) =
paid.max(r => r.invoice.total).runComposition is plain Scala: no builder to close, no session to thread through, and a composed value renders
once, when a terminal runs it. The shape stays SQL's: a query carries one where, so a narrower trunk says
status == Paid && total >= min in that predicate rather than stacking a second filter later.
A statement held in a val still runs: .run accepts it and renders it at run time, and .runDynamic
says so explicitly. What a val cannot give is compile-time rendering: the compiler sees only a reference
to the variable, not the query construction behind it:
val paid = Sql.from[Invoice].where(r => r.invoice.status == InvoiceStatus.Paid)
val page = paid.select(r => (r.invoice.customerId, r.invoice.total)).orderBy(r => r.invoice.total.desc).limit(20)
val rows: Chunk[(Long, BigDecimal)] < (Abort[SqlException] & DB) =
page.runDynamicCalling .runStatic on the same value is a compile error, and the error says what to change rather than
falling back silently:
9 | page.runStatic
| ^
| .runStatic: this statement cannot be folded at compile time. The statement reaches this macro
| as a variable reference rather than as its construction, which is what a query stored in a
| `val` looks like here. Construct it at the call site, or name it with a `transparent inline
| def`. Use .run for opportunistic static folding with runtime fallback, or .runDynamic to skip
| static folding entirely.
The refusal names the construct it found rather than guessing, so a statement carrying a raw sql"..."
fragment or a having clause says so instead of blaming a val.
.runStatic requires the SQL to be produced while compiling, so the construction moves to the terminal,
written out where the macro can see it:
val firstPage: Chunk[(Long, BigDecimal)] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.where(r => r.invoice.status == InvoiceStatus.Paid)
.select(r => (r.invoice.customerId, r.invoice.total))
.orderBy(r => r.invoice.total.desc)
.limit(20)
.runStaticThe compile-time render works through ordinary Scala 3 inlining: no annotation, no code-generation step,
no macro configuration. When it
succeeds, the SQL text is a constant in the artifact and rendering costs nothing at run time. The render is
made once per driver on the compile classpath, each targeting its dialect's capability floor (PostgreSQL
11.0, MySQL 8.0.31, SQLite 3.39.0), since compiling has no server to ask. Bind positions must land identically across the
rendered texts, and at run time the client picks the text matching its own dialect, which is how one
compiled statement serves every driver on the classpath from the same artifact. Every successful .runStatic
reports what it folded to at the call site, one line per driver, so the sbt log shows the exact SQL that
was produced (on by default, off with -Dkyo.sql.static.log=false on the compiler's JVM):
[info] |static SQL [postgres]: SELECT "invoice"."customer_id", "invoice"."total" FROM "invoice" "invoice" WHERE ("invoice"."status" = $1) ORDER BY "invoice"."total" DESC LIMIT 20
[info] |static SQL [mysql]: SELECT `invoice`.`customer_id`, `invoice`.`total` FROM `invoice` `invoice` WHERE (`invoice`.`status` = ?) ORDER BY `invoice`.`total` DESC LIMIT 20
A statement that cannot fold, or whose dialects disagree on bind positions, is a compile error there rather
than a silent fallback, which is the difference from .run: the default terminal folds opportunistically
and falls back to the runtime renderer, which targets the version the server reported at handshake rather
than the capability floor. The three terminals:
| Terminal | SQL produced | When it cannot be |
|---|---|---|
.run |
while compiling, when the query's shape allows | falls back to the runtime renderer |
.runStatic |
while compiling, required | compile error |
.runDynamic |
at run time, always | not applicable |
The set of rendered dialects is fixed when the call site compiles. This matters when a driver is only
available at run time (registered through kyo.db.Backend.register after the compile-time warning about a
scheme no classpath driver claims). Raw fragments and .runDynamic render against the live client and work
against such a driver unchanged. A statically folded statement does not: running it against a dialect
outside the rendered set fails with SqlStaticRenderMissingDialectException, naming the dialect asked for
and the dialects that were rendered. Putting the driver on the compile classpath is what extends .run and
.runStatic to it.
Keeping a statement on the static path takes two habits beyond constructing at the terminal: declare
SqlNaming givens at the top level of the file that runs the queries, because a casing given local to a
method cannot be resolved statically and sends the render to run time, and write .runStatic where the
build should fail if a refactor breaks the compile-time render, because .run falls back silently and
reports nothing.
The same rule decides what a schema may be. A literal schema name folds, and so does one
behind a final val or an inline def; a name computed at run time, a schema per request being the case
that wants one, cannot, so those statements belong on .run or .runDynamic. There is no third option
here: a schema the compile-time render cannot read is a schema the folded SQL would have to leave out, and a
statement that reached a different table depending on which terminal ran it would be worse than one that
renders at run time.
A SqlNaming given is found by ordinary implicit search where the statement is constructed, so where it
is declared decides which queries it reaches. Top level of the file, or an object the queries are written
inside of, both work. A companion object does not reach the class beside it: a given in object Store is
not in scope inside class Store, so a query written there runs uncased and asks for the verbatim Scala
field names.
What that failure looks like depends on the tier. The typed DSL names its columns in the projection, so the
statement goes out as SELECT "signedUpAt" … and the server rejects it before a row exists, arriving as a
SqlServerException carrying SQLSTATE 42703. A raw sql"…" decoded by name gets as far as the decode and
fails with SqlDecodeColumnNotFoundException, which lists the columns the row does have and says a casing
given is resolved elsewhere. Import it (import Store.given) or declare it where the queries are.
Note the construction site is not always the run site. A statement built at the terminal, and one named by a
transparent inline def, are constructed where they run; a shared trunk held in a val fixed its casing
where the val is defined, so moving a given next to the .run will not reach it.
Two constructs are rendered at run time, so a statement carrying either one folds no further: an embedded
raw sql"..." fragment, and a having clause. Spelling the predicate with the typed operators is what keeps
it static.
Two more shapes refuse for a different reason, both about what the macro can read rather than what it can
render. A statement reaching the macro as a variable reference cannot be folded, which is what a query held
in a val looks like from there. And a statement embedding a value known only at run time, a limit count
read from configuration being the usual one, has nothing to fold that value to.
On .runStatic each of the four is a compile error naming which one it found; on .run the statement falls
back to the runtime renderer, which is the same SQL either way.
Reuse and the static path meet at the source. A source named by a transparent inline def folds, and
transparent is required, so the table-name override has a home rather than being repeated at every call
site (see Names derive from the type).
A source is as far as that goes: a transparent inline def naming a source with a filter already on it
does not compile, at the definition rather than at the use site. The record the predicate lambda reads is
typed through the Fields evidence summoned for the row type, and inside an inline def that evidence is not
resolved yet, so the lambda cannot name a column. Reuse a filtered trunk as a val and run it with .run,
which renders it at run time.
Whichever path a statement takes, its text is inspectable without executing anything: ast.render(dialect)
is pure and needs no client, and client.render(ast) targets a live client's flavor and server version.
The record the lambdas receive is an ordinary kyo.Record: field names mapped to values, here the alias
mapped to the table's columns, each column a Term of its Scala type. r.invoice.total is a
Term[BigDecimal], so comparing it to a String does not compile.
Each combinator transforms that record, and the transformations line up with relational algebra. A join
combines two records with the same & that merges any two, widening the schema with the second table's columns
under its key rather than nesting tuples. groupBy narrows it the other way: the grouping keys keep their term
surface, every other column drops to the aggregate vocabulary, and where(col.count > 5) stops compiling
rather than rendering SQL no server accepts:
val perCustomer: Chunk[(Long, BigDecimal)] < (Abort[SqlException] & DB) =
Sql.from[Invoice]
.groupBy(r => r.invoice.customerId)
.having(v => v.total.sum >= BigDecimal(500))
.select(v => (v.customerId, v.total.sum))
.runAggregate result types are the ones the servers return: sum over an Int column is a Long, avg over
an exact operand is a BigDecimal, so the widening the engines perform is present in the types. A typed
query's rows decode positionally, because the renderer emitted the columns itself in field order, so a
naming convention at the call site cannot reorder a typed read.
Inserts, updates, and deletes are statements of the same kind:
val write: Unit < (Abort[SqlException] & DB) =
for
_ <- Sql.insert[Invoice].values(Invoice(1L, 7L, BigDecimal("49.90"), InvoiceStatus.Pending, Absent)).run
_ <- Sql.update[Invoice].set(_.status := InvoiceStatus.Paid).where(_.id == 1L).run
_ <- Sql.delete[Invoice].where(_.status == InvoiceStatus.Cancelled).run
yield ()An insert answers with an InsertOutcome carrying the affected count and any generated key the engine
surfaced. The upsert forms are on the same builder, with Sql.Excluded naming the incoming row's value:
val upsert: SqlClient.InsertOutcome < (Abort[SqlException] & DB) =
Sql.insert[Invoice]
.values(Invoice(1L, 7L, BigDecimal("49.90"), InvoiceStatus.Pending, Absent))
.onConflictDoUpdate(_.id)(c => c.status := Sql.Excluded(c.status))
.runFor a server-assigned key, .overriding(_.id := Sql.default) sends DEFAULT in that one cell.
partialValues names the columns to send, fromSelect inserts from a query, and returning is available
on insert, update, and delete. An unconditional update or delete is spelled .build rather than by leaving
where off, so it is always explicit.
The surface continues where SQL does: distinct, set operations, exists, scalar subqueries, CASE WHEN,
window functions, common table expressions (recursive included), forUpdate / forShare, rollups and
grouping sets, and Sql.values / Sql.nested / Sql.lateral as sources. Anything the DSL does not cover
can be written as a fragment.
DB.transaction pins one connection for the body, commits on success, and rolls back on any failure, abort
and panic alike. Every statement inside runs on that connection, with nothing threaded by hand:
val settle: Unit < (Abort[SqlException] & DB) =
DB.transaction {
for
_ <- sql"UPDATE invoice SET status = 'Paid' WHERE id = 7".execute
_ <- sql"INSERT INTO invoice_payment (invoice_id, amount_in_cents) VALUES (7, 4990)".execute
yield ()
}- A domain error rolls back and is re-raised. A body that fails with its own
Aborterror rolls back exactly as anSqlExceptiondoes, and after the rollback the caller receives that error unchanged, with the sameAborttype the body already declared. - Nesting is a savepoint. An inner
DB.transactionissues a savepoint on the outer connection, so an inner failure rolls back to the savepoint and the outer transaction continues. - Concurrent fibers inside the body are safe. A forked fiber inherits the transaction, which is what keeps its statements atomic with the rest, and statements racing onto the shared connection serialise on a per-session mutex instead of interleaving frames on one socket.
- A fiber that outlives the body is refused, not rerouted. A forked fiber the body never awaited still
carries the transaction after the commit, and by then its connection is back in the pool. A statement it
issues then fails with
SqlRequestTransactionEndedExceptionrather than running on whatever session the pool leased that connection to next. Await the fiber inside the body, or give it a transaction of its own.
Isolation and read-only mode are the two-argument form:
DB.transaction(Present(SqlClient.IsolationLevel.Serializable), readOnly = true)(body).
DB.withAdvisoryLock(key, timeout) is the same session pinning without the transactional semantics: a
cross-process critical section, held for the body and released by a scope finalizer on every exit edge.
Statements inside route to the lock's session, so a one-connection pool still makes progress, and a
statement from a forked fiber that arrives after the release fails with
SqlRequestAdvisoryLockEndedException rather than running unlocked. MySQL bounds the wait with timeout.
PostgreSQL's advisory lock takes none, so the wait is bounded by wrapping the whole call in Async.timeout.
SqlSchema[A] is the evidence that a type can cross the wire. The derivation builds it one
SqlSchema.Column per field, so the columns that prove support are the columns that serialize. The base
column set covers the standard Scala and java.time types, mapped natively on every shipped driver, and
the full table is in the reference. List, Vector, Set, Map, and general Chunk[A] have
no column, and a nested case class does not flatten implicitly: each is a compile error naming the field or
bind position, because admitting a type to storage is a visible declaration rather than a library default.
The declaration is one given per type. A nested type stored as one JSON document:
case class ShippingAddress(street: String, city: String) derives Schema
given SqlSchema.Column[ShippingAddress] = Sql.jsonColumnThat line makes ShippingAddress a legal field, bind, and projection, stored as jsonb on PostgreSQL and
JSON on MySQL through kyo-schema's document codec, and an overload takes explicit encode and decode functions
for any other JSON library. SqlSchema.of and SqlSchema.ofMulti are the hand-written routes, one column
and several. JsonText carries a document whose shape is only known at run time. Sql.enumText is the
codec behind derives SqlSchema.Column, for installing on an enum that cannot take a derives clause. A
Maybe[A] field is the vocabulary for a nullable column: absent decodes to Absent, and NULL arriving at a
non-Maybe field is a typed decode failure rather than a null.
Names have one rule and two overrides. By default a column is the field name verbatim and a derived table
name is the type name lowercased. @column("name") pins one column, and an opt-in
given SqlNaming = SqlNaming.SnakeCase cases every unannotated name in scope (signedUpAt to
signed_up_at, UserProfile to user_profile). Precedence is narrowest first: the explicit name, then the
casing, then the default. SnakeCase keeps an acronym whole (HTTPServer to http_server, userID to
user_id), and the mapping does not change once released, because the produced names are a wire contract.
SnakeCaseUpper, UpperCase, and LowerCase round out the conventions; a name no convention gets right is
pinned with @column.
postgres://user:password@host:port/database[?options]
mysql://user:password@host:port/database[?options]
sqlite://path/to/database.db[?options]
sqlite://:memory:
SQLite's URL is a path rather than a network coordinate: no user, no password, no host, no port. Everything
between :// and the query string is the path, read as it stands, so a name containing a colon or an @
needs no escaping.
The scheme picks the driver: at compile time for a literal URL, through runtime discovery for a computed
one. URL options cover transport settings (sslmode, sslrootcert, connectTimeout, socketTimeout,
application_name), and an option value the parser cannot represent is a typed failure rather than a silent
default.
DB.run(url, config) states the pool settings, built from the defaults:
val tuned = SqlConfig.default
.maxConnections(20)
.minConnections(2)
.queryTimeout(30.seconds)
.retrySchedule(Schedule.exponentialBackoff(100.millis, 2.0, 5.seconds).take(5))
val opened: Chunk[Customer] < (Async & Abort[SqlException]) =
DB.run(url, tuned) {
sql"SELECT id, email, signed_up_at FROM customer".as[Customer].run
}The pool honours maxConnections exactly, warms minConnections eagerly, and bounds waiting and running
with acquireTimeout and queryTimeout. retrySchedule retries connection failures only: a statement the
server rejected would fail identically again, and a retried non-idempotent statement can land twice, so the
retry scope is deliberately the transport. DB.withConfig(f) adjusts the config in force for one block. The
full field list is in the reference.
TLS runs from disable through verify-full with chain and hostname checks. A mode that demands encryption
and reaches connect time without TLS settings fails typed rather than opening in the clear. Engine-specific
settings attach through config.extension(...), and engine-specific capabilities live on the concrete
clients, reached by narrowing (DB.clientAs[PostgresClient]), so portable code does not reach
engine-specific behavior accidentally. Both are documented in the drivers:
kyo-sql-postgres (COPY, LISTEN/NOTIFY, hstore, ranges, custom types),
kyo-sql-mysql (LOAD DATA LOCAL INFILE, TLS modes),
kyo-sql-sqlite (embedded, no server; the DDL type name is what the codec
dispatches on).
Every operation fails through one typed channel, Abort[SqlException], so failure is part of the signature.
Five categories split it by what a caller can do:
| Category | Cause |
|---|---|
SqlConnectionException |
Transport and pool: TCP refused, acquire timeout, TLS, authentication, closed pool. |
SqlRequestException |
The request could not be satisfied: an unencodable value, a refused advisory lock. |
SqlServerException |
The server answered with an error, carrying SQLSTATE, message, detail, hint, position. |
SqlDecodeException |
The row arrived and a column could not become the expected Scala type. |
SqlUnsupportedException |
The construct or type is not expressible on this engine. |
Server errors split further by SQLSTATE (SqlServerConstraintViolationException,
SqlServerDeadlockException, SqlServerSyntaxException, ...), and three marker traits cut across the tree
for recovery by property rather than by shape:
val handled: String < DB =
Abort.run[SqlException](Sql.from[Invoice]("i").run).map {
case Result.Success(rows) => s"${rows.size} rows"
case Result.Failure(_: SqlIntegrityViolation) => "constraint violation"
case Result.Failure(_: SqlRetryable) => "transient, safe to retry"
case Result.Failure(e: SqlServerException) => s"[${e.sqlState}] ${e.serverMessage}"
case Result.Failure(e) => s"failed: ${e.message}"
case Result.Panic(t) => s"panic: ${t.getMessage}"
}Cancellation needs no registration: the fiber running the statement is the handle, so an Async.timeout, a
lost race, or a Scope exiting stops the statement. The pool then reclaims the connection (asking the
server to stop, draining what it still owes, rolling back an open transaction) inside one cancelTimeout
budget, and a reclaim that cannot prove the session clean destroys the connection rather than pooling a
corrupted one.
Everything above runs through the DB effect. A client value in hand is for the cases that need a receiver:
several databases in one program, rows read without a row type, statements batched into one dispatch, or an
engine-only capability. SqlClient.init(url) opens a pool bound to the enclosing Scope (initUnscoped
hands close to the caller, and both have With variants). Neither installs the DB effect, because supplying
an effect is DB.run's job alone:
// Two connection strings supplied at run time, one per database.
val primaryUrl: String = ???
val replicaUrl: String = ???
val audited: Long < (Async & Abort[SqlException] & Scope) =
SqlClient.init(primaryUrl).map { primary =>
SqlClient.init(replicaUrl).map { replica =>
for
totals <- DB.run(replica)(sql"SELECT count(*) FROM invoice".as[Long].run)
n = totals.headMaybe.getOrElse(0L)
_ <- DB.run(primary)(sql"INSERT INTO audit (note) VALUES ('counted')".execute)
yield n
}
}client.query and client.simpleQuery answer Chunk[SqlRow] for result shapes no case class describes;
row.decode[A], row.decode[A](idx), and row.decode[A](name) read columns, and row.column gives the
raw bytes. client.pipeline batches statements into one dispatch (a single TCP write on PostgreSQL),
answering one Result per statement so one failure does not void the batch. ping, isAlive, reset,
close, serverVersion, DB.address, and the pool metrics round out the operational surface.
The module and its drivers compile from single shared sources on the JVM, Scala.js, Scala Native, and Wasm,
down to the authentication crypto, which is pure Scala rather than javax.crypto. Two things are platform
work:
- Driver registration. Every driver ships a
META-INF/services/kyo.db.Backendentry, which fully covers the JVM. On JS and Wasm each driver registers itself at module load (the shipped drivers already do). On Scala Native the application enlists the backend class innativeConfig.withServiceProviders, and a program opening several engines by computed URL also calls each driver'sregister(), because Native embeds a single services file. Literal URLs resolve at compile time and need none of this. The driver READMEs carry the snippets. - The FFI plugin, on Scala Native. Every connection goes through kyo-net, whose C shims are linked into
the binary rather than loaded, so a Native build also needs
addSbtPlugin("io.getkyo" % "kyo-ffi-plugin" % kyoVersion),.nativeConfigure(_.enablePlugins(kyo.ffi.sbt.KyoFfiPlugin)), and the twoffiNativeDependency*Optionstasks folded intonativeConfig. Without them the link fails on undefined symbols before any of the above matters. See kyo-net's Scala Native builds for the exact block. - A third engine is one artifact.
db.Backend(a scheme, a dialect, anopen), a dialect written by overriding only what diverges from standard SQL (four members are abstract), and the registration above. Nothing in kyo-sql names an engine, so an out-of-tree driver is an ordinary dependency.
Each entry is a SqlSchema.Column given with a native mapping on every shipped driver. A case class or
tuple of these is row evidence, and Maybe / Option wrap any of them as a nullable column.
The SQLite column reads differently from the other two. SQLite has no fixed column types, only a declared
name and a per-value storage class, so the driver dispatches on the DECLARED name and the names below are
what a CREATE TABLE must use. A trailing TEXT is load-bearing: it forces TEXT affinity, without which a
24-digit decimal, an all-digit date and a bare-number JSON document are each rewritten on insert.
| Scala type | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
Byte, Short |
int2 |
TINYINT, SMALLINT |
SMALLINT |
Int, Long |
int4, int8 |
INT, BIGINT |
INTEGER, BIGINT |
Float, Double |
float4, float8 |
FLOAT, DOUBLE |
FLOAT, DOUBLE |
BigDecimal, BigInt |
numeric |
DECIMAL |
DECIMAL TEXT(38,10) |
Boolean |
bool |
TINYINT(1) |
BOOLEAN |
String, Char |
text |
VARCHAR / TEXT |
TEXT |
Span[Byte] |
bytea |
BLOB |
BLOB |
Instant, java.time.Instant |
timestamptz |
DATETIME in UTC |
TIMESTAMP TEXT |
java.time.LocalDate, LocalTime, LocalDateTime |
date, time, timestamp |
DATE, TIME, DATETIME |
DATE TEXT, TIME TEXT, DATETIME TEXT |
java.time.OffsetTime |
timetz |
ISO-8601 text | TIMETZ TEXT |
java.time.OffsetDateTime, ZonedDateTime |
timestamptz, normalised to UTC |
DATETIME in UTC |
TIMESTAMP TEXT in UTC |
java.time.Duration, FiniteDuration |
interval |
TIME |
DURATION TEXT |
Duration |
int8, total nanoseconds |
BIGINT, total nanoseconds |
DURATION TEXT |
java.time.Period |
interval |
ISO-8601 text | INTERVAL TEXT |
java.util.UUID |
uuid |
36-character string | UUID TEXT |
UUID |
text |
VARCHAR |
UUID TEXT |
java.net.URI, java.util.Locale, java.util.Currency |
text |
VARCHAR |
TEXT |
Chunk[Int], Chunk[String], Chunk[JsonText] |
int4[], text[], jsonb[] |
JSON |
JSON TEXT |
JsonText |
jsonb |
JSON |
JSON TEXT |
OffsetDateTime and ZonedDateTime normalise to UTC on the way out, because that is what the engines
store. A codec that keeps the offset is a two-column SqlSchema.ofMulti.
| Field | Default | Meaning |
|---|---|---|
maxConnections |
10 | Concurrent connections, honoured exactly. 1 serialises a whole program onto one session. |
minConnections |
0 | Connections opened eagerly at open time. A warm-up failure aborts the open. |
acquireTimeout |
5 seconds | Wait for a pooled connection before a typed timeout failure. |
queryTimeout |
30 seconds | Bound on one query or execute. |
idleTimeout |
10 minutes | Idle time after which a pooled connection is replaced. |
retrySchedule |
Absent |
Retry policy for SqlConnectionException only. |
tlsMode / tls / caCertPath |
Disable / Absent / Absent |
TLS policy and settings. A URL's sslmode overrides the mode. |
preparedStatementCacheSize |
64 | Server-side prepared statements cached per connection. |
preparedStatementTtl |
Infinity |
Unused time after which a cached statement expires. |
cancelTimeout |
2 seconds | One budget for the whole reclaim chain after an interrupt. |
metricsEnabled / metricsScope |
true / "kyo.sql" |
Stat counters, histograms, and a pool gauge. |
connectionTestQuery |
Absent |
Probe before lending a pooled connection. Absent checks the socket only. |
closeGrace |
30 seconds | Grace period for close when given none. |
streamBatchSize |
64 | Rows per PostgreSQL portal fetch in a stream (informational on MySQL). |
extensions |
empty | Engine-specific settings, attached with .extension(...). |
| Capability | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Oldest version targeted | 11.0 | 8.0.31 | 3.39.0 |
| Authentication | trust, cleartext, MD5, SCRAM-SHA-256, SCRAM-SHA-256-PLUS | mysql_native_password, caching_sha2_password, sha256_password, mysql_clear_password |
none, in-process |
| TLS | SSLRequest upgrade |
CLIENT_SSL upgrade |
none, in-process |
| Extended protocol | prepared and binary, per-connection cache | prepared and binary, per-connection cache | one statement API, so the simple and extended paths are the same one |
| Streaming | portal fetches of streamBatchSize |
rows as the server frames them | one row per step, with no batch to size |
| Pipelining | one TCP write per batch | sequential on one connection | statements in turn, with no round trip to save |
| Cancellation | CancelRequest on a fresh connection |
KILL QUERY on a sidecar |
sqlite3_interrupt on the connection |
| Bulk transfer | COPY in and out |
LOAD DATA LOCAL INFILE |
none |
| Notifications | LISTEN / NOTIFY |
none | none |
The SQLite column reads as absences because most of it is protocol, and an embedded engine has none: the database is a file this process opens, so there is nothing to authenticate to, encrypt, or batch a round trip against.
A construct an engine lacks (RETURNING and CUBE on MySQL, GROUP BY ROLLUP and LATERAL on SQLite)
fails as a typed SqlUnsupportedDialectFeatureException at render time, never as SQL the server rejects.
An engine kyo-sql does not ship plugs in through db.Backend. A backend is one artifact with four pieces: the
db.Backend (the plug point, naming a scheme and opening a client), the SqlClient subclass its open returns
(the surface a user holds, where an engine adds its own operations like COPY or LOAD DATA LOCAL INFILE), the
db.Idiom it renders in, and the db.Connection it opens. Nothing above Backend names an engine, which is
what makes the two shipped drivers ordinary dependencies rather than privileged ones.
object ExampleIdiom extends db.Idiom:
def id: db.Idiom.Id = db.Idiom.Id("example")
def capabilityFloor: db.Idiom.ServerVersion = db.Idiom.ServerVersion(1, 0, 0)
def quoteIdent(ident: String): String = "\"" + ident.replace("\"", "\"\"") + "\""
def placeholder(position: Int): String = "?"
end ExampleIdiom
object ExampleBackend extends db.Backend:
def scheme: String = "example"
def aliases: Set[String] = Set.empty
def dialect: db.Idiom = ExampleIdiom
def open(url: SqlConfig.Url, config: SqlConfig)(using Frame): SqlClient < (Async & Abort[SqlException] & Scope) = ???
end ExampleBackendThe class needs a public zero-argument constructor with no side effects: it is constructed at every literal-URL call site while compiling, as well as at run time.
One META-INF/services/kyo.db.Backend entry in shared resources is the whole services contract; the dialect is
reached through Backend.dialect, never registered on its own. The rest is per platform:
- JVM: nothing further,
ServiceLoadercovers runtime discovery. - JS and Wasm: one
@JSExportTopLevelobject whose initializer callsBackend.register, because linker dead-code elimination drops an initializer nothing references. - Native: the application enlists the class in
nativeConfig.withServiceProviders(Map("kyo.db.Backend" -> Seq("com.vendor.VendorBackend"))), since Scala Native resolves service providers at link time. Native also embeds a singleMETA-INF/services/kyo.db.Backendfile when several jars declare the service rather than concatenating them, so a program that opens more than one flavor by computed URL calls each additional backend's ownregister()as well.
A missing platform registration fails silently in one direction. The backend stays reachable through a literal URL, which resolves against the compile classpath, and is invisible to a computed one, which resolves through runtime discovery. Neither path reports the omission, so the gap shows up as a scheme that works in one program and not in another.
A scheme resolves against a Backend.Registry: the compile-time backends first, then runtime discovery for a
scheme none of them claims. Backend.Registry.current derives from the caller's own compile classpath, so a
library that leaves the engine to its caller expands against the application's classpath, not its own.
Four members are abstract, the facts no default can guess: id, capabilityFloor, quoteIdent, placeholder.
Every emitting method has a standard-SQL body reached through this, so a subclass declares only its divergences
and an override retargets every path leading to it (overriding limit changes pagination under plain queries,
set operations, CTE bodies, and subqueries at once). Capability claims default to the standard: a supports*
predicate, a *Since floor, and the supports*(version) gates derived from those floors. The common shape
intercepts one arm of a family and delegates the rest:
object ModIdiom extends db.Idiom:
def id: db.Idiom.Id = db.Idiom.Id("example")
def capabilityFloor: db.Idiom.ServerVersion = db.Idiom.ServerVersion(1, 0, 0)
def quoteIdent(ident: String): String = "\"" + ident + "\""
def placeholder(position: Int): String = "?"
override def arithmetic(ctx: db.Idiom.Ctx, ar: Sql.Arithmetic[?]): Unit =
ar.op match
case Sql.Arithmetic.Op.Mod =>
ctx.append("MOD(")
term(ctx, ar.left)
ctx.append(", ")
term(ctx, ar.right)
ctx.append(")")
case _ => super.arithmetic(ctx, ar)
end ModIdiomA dialect is a stateless value shared across fibers; per-render state (text, binds, resolved server version,
call-site frame) lives in Idiom.Ctx, whose append, appendBind, quoted, appendQuoted, and joinWith an
override writes through. A construct the target cannot express goes out through Ctx.unsupported, never as text
the server would reject. Caller text stays verbatim (Sql.RawSql, a fragment's literal parts), and placeholder
is the single authority on placeholder spelling.
A Connection has two halves. The statement half (query, execute, stream, transaction, savepoint, advisory
lock) is what a caller invokes. The reclaim half (inFlight, inOpenTransaction, cancelInFlight,
rollbackIfOpenTransaction, drainToIdle) is what the pool invokes after an interrupt; no caller calls it.
The inFlight flag is the contract that matters most: it must read true before the first request byte and
stay true until the terminal response is drained or the session is reclaimed, because the pool's lease
finalizer reads it from outside the interrupted fiber to decide whether to send a cancel.
Connection.Factory is how the pool opens one; leftSessionIdle, isProtocolFatal, closingOnFailure,
requireUser, and errorOf are shared helpers to reuse rather than re-decide.
open calls db.Runtime.init, which merges the URL's declarations under the config, builds the pool over the
backend's Connection.Factory, and warms up minConnections sessions behind a bracket that closes what it
opened on any failure. It returns a Runtime the backend wraps in its client class, with a deliberately narrow
surface (openDedicated for a caller-owned session such as a notification listener; nothing that bypasses the
client's routing).
SqlRow.Codec is the backend's decoder for the rows it produced, one instance per result set carrying whatever
decode context it needs. Codec.catchingColumn and Codec.catching wrap a decode so a thrown failure arrives
as the typed SqlDecodeException the transport expects.
kyo-sql-conformance publishes the battery kyo's own engines pass, as one abstract class, SqlConformanceTest.
A backend's tests add it in test scope, wire kyo-test's runner, and subclass it with a SqlConformanceBackend
descriptor. The descriptor hands the battery the backend itself, which it opens every client through, so the
test run needs none of the registration above. It also answers the capability questions the leaves branch on
(whether the engine has RETURNING, how its DDL spells each column kind, which isolation levels it honours), and
provisions a fresh schema per leaf. On JS the test link needs ModuleKind.CommonJSModule.
// build.sbt
libraryDependencies += "io.getkyo" %%% "kyo-sql-conformance" % "<version>" % Test
// src/test/scala
class ExampleConformanceTest extends SqlConformanceTest(Seq(new ExampleConformanceBackend))Leaves that compare engines run with a single descriptor too: one that pins its expected answer still asserts it, and one that only compares is cancelled. A run where no descriptor is reachable fails.