Skip to content

Queries

src/db.ts
import { database } from "@tetsujs/sql";
import { schema } from "./db.generated";
export const
const db: Kysely<DB>
db
= database(schema, {
url: string

A postgres:// URL of the database: the server, the user and the database it names are the ones used. The database is given apart from the URL as well, since Bun 1.4.4 would take PGDATABASE over it.

url
:
const config: {
DATABASE_URL: string;
}
config
.
type DATABASE_URL: string
DATABASE_URL
});

database() returns Kysely on a pool of Bun.SQL, for the database the generated schema describes. The URL is a postgres:// or postgresql:// one, and the other options are Bun’s options of a pool, such as max, idleTimeout or tls, but for those the package sets itself. The package reads nothing from the environment: the URL is passed in, from the application’s configuration. Bun fills in what the URL leaves out — the host, the port, the user, the password and sslmode — from PGHOST, PGPORT, PGUSER, PGPASSWORD and PGSSLMODE, so name them all in the URL.

Open it once, where the application is wired, and hand it to what needs it, as any other dependency. db.destroy() closes the pool, on shutdown:

src/main.ts
import { createApp } from "@tetsujs/core";
import { onShutdownSignals } from "@tetsujs/lifecycle";
import { database } from "@tetsujs/sql";
import { schema } from "./db.generated";
import { ordersController } from "./orders";
const
const db: Kysely<DB>
db
= database(schema, {
url: string

A postgres:// URL of the database: the server, the user and the database it names are the ones used. The database is given apart from the URL as well, since Bun 1.4.4 would take PGDATABASE over it.

url
:
const config: {
DATABASE_URL: string;
}
config
.
type DATABASE_URL: string
DATABASE_URL
});
const
const server: Bun.Server<unknown>
server
= Bun.serve({ ...createApp({ routes: ordersController(
const db: Kysely<DB>
db
) }) });
onShutdownSignals(
const server: Bun.Server<unknown>
server
, {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
const db: Kysely<DB>
db
.destroy()] });

On a Bun older than 1.4.4, database() throws: before it, Bun read an array of uuid, of a range and of more types as its literal, where the generated types say it reads as an array.

Every query is Kysely’s, typed by DB:

const recent = await db
const recent: {
id: bigint;
total: string;
placed_at: Date;
email: string;
}[]
.selectFrom("orders as o")
.innerJoin("customers as c", "c.id", "o.customer_id")
.select(["o.id", "o.total", "o.placed_at", "c.email"])
.where("o.customer_id", "=",
const customerId: bigint
customerId
)
.orderBy("o.placed_at", "desc")
.limit(20)
.execute();
const {
const id: bigint
id
} = await
const db: Kysely<DB>
db
.insertInto("orders")
.values({
customer_id: ValueExpression<DB, "orders", bigint>
customer_id
:
const customerId: bigint
customerId
,
total: ValueExpression<DB, "orders", string>
total
: "12.50",
tags?: ValueExpression<DB, "orders", readonly (string | null)[] | undefined>
tags
: ["gift"] })
.returning("id")
.executeTakeFirstOrThrow();
await
const db: Kysely<DB>
db
.updateTable("orders").set({
status?: ValueExpression<DB, "orders", "new" | "paid" | "shipped"> | undefined
status
: "paid" }).where("id", "=",
const id: bigint
id
).execute();

The package changes nothing of Kysely’s API but two things Bun.SQL cannot do yet: the kill session strategy of cancelling a query, and stream(), which needs a cursor: it throws, so read a long result in pages instead, by key. A query of raw SQL, with Kysely’s sql`…`, binds its values as parameters too. One that binds no value may hold several statements, and resolves to the rows of the last; with a value, Postgres refuses more than one.

Values go through as the generated types say: an int8 is a bigint both ways, unless the types were generated with --int8 string, a numeric a string, a json or jsonb column takes an object or an array and returns what Postgres parsed. The driver mends what Bun does not do:

  • a Date is written to any timestamp and to a date as its instant, in ISO text, which a date takes of any year, where it refuses Bun’s own text of a year past 9999 or before 1; an invalid Date throws a RangeError before anything is sent, where Bun would send it for Postgres to refuse;
  • a number that is not whole, such as 2.5, is sent as its text where the builder writes it to a column — a value of an insert, an upsert or a merge, a set — and to any parameter of a query file, which Postgres has typed. Postgres reads it as that type: an integer refuses it, invalid input syntax for type integer: "2.5", where Bun would send a float8 the column rounds to the even, 2, without an error, and a float8, a float4 or a numeric reads the number it is. Where the builder compares or computes with it, and in raw SQL, it is bound as Bun binds it, a float8: where("rank", ">", 2.5) means what it says, and a raw insert rounds;
  • an int4[] or a float4[], which Bun reads as an Int32Array or a Float32Array when a query has parameters, is read as an array, as it is without them.

database() writes a JavaScript array to a column of arrays as the literal Postgres reads, such as {"gift","glass"}. Bun binds an array as Postgres reads it only to an array of a type it knows: not of an enum, a domain, a composite type, a range of one’s own or an extension’s type, and not every array of json (Bun’s limits). A literal is read as the column’s own type, whatever it is. database() writes one wherever a query says the array goes to such a column:

  • a value of an insert, an upsert or a merge, and a set of an update;
  • a value compared with a column of arrays of the same query, such as where("tags", "@>", ["gift"]);
  • the argument of any() or all(), which only an array can be.
const
const gifts: {
id: bigint;
}[]
gifts
= await
const db: Kysely<DB>
db
.selectFrom("orders")
.select("id")
.where("tags", "@>", ["gift"])
.execute();
const
const some: {
id: bigint;
status: "new" | "paid" | "shipped";
}[]
some
= await
const db: Kysely<DB>
db
.selectFrom("orders")
.select(["id", "status"])
.where((
eb: ExpressionBuilder<DB, "orders">
eb
) =>
eb: ExpressionBuilder
<"id", "=", ExpressionWrapper<DB, "orders", bigint>>(lhs: "id", op: "=", rhs: ExpressionWrapper<DB, "orders", bigint>) => ExpressionWrapper<DB, "orders", SqlBool>

Creates a binary expression.

This function returns an

Expression

and can be used pretty much anywhere. See the examples for a couple of possible use cases.

Examples

A simple comparison:

const result = await db.selectFrom('person')
.selectAll()
.where((eb) => eb('first_name', '=', 'Jennifer'))
.execute()

The generated SQL (PostgreSQL):

select *
from "person"
where "first_name" = $1

By default the third argument is interpreted as a value. To pass in a column reference, you can use

ref

:

const result = await db.selectFrom('person')
.selectAll()
.where((eb) => eb('first_name', '=', eb.ref('last_name')))
.execute()

The generated SQL (PostgreSQL):

select *
from "person"
where "first_name" = "last_name"

In the following example eb is used to increment an integer column:

await db.updateTable('person')
.set((eb) => ({
age: eb('age', '+', 1)
}))
.where('id', '=', 3)
.execute()

The generated SQL (PostgreSQL):

update "person"
set "age" = "age" + $1
where "id" = $2

As always, expressions can be nested. Both the first and the third argument can be any expression:

const result = await db.selectFrom('person')
.selectAll()
.where((eb) => eb(
eb.fn<string>('lower', ['first_name']),
'in',
eb.selectFrom('pet')
.select('pet.name')
.where('pet.species', '=', 'cat')
))
.execute()

The generated SQL (PostgreSQL):

select *
from "person"
where lower("first_name") in (
select "pet"."name"
from "pet"
where "pet"."species" = $1
)

eb
("id", "=",
eb: ExpressionBuilder<DB, "orders">
eb
.
fn: FunctionModule<DB, "orders">

Returns a

FunctionModule

that can be used to write type safe function calls.

The difference between this and

Kysely.fn

is that this one is more type safe. You can only refer to columns visible to the part of the query you are building.

Kysely.fn

allows you to refer to columns in any table of the database even if it doesn't produce valid SQL.

const result = await db.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select((eb) => [
'person.id',
eb.fn.count('pet.id').as('pet_count')
])
.groupBy('person.id')
.having((eb) => eb.fn.count('pet.id'), '>', 10)
.execute()

The generated SQL (PostgreSQL):

select "person"."id", count("pet"."id") as "pet_count"
from "person"
inner join "pet" on "pet"."owner_id" = "person"."id"
group by "person"."id"
having count("pet"."id") > $1

fn
.any(
eb: ExpressionBuilder<DB, "orders">
eb
.val([1n, 2n, 3n]))))
.execute();

Elements are written as their column takes them: by the delimiter of the type, which is ; for a box, and as JSON for an array of json or jsonb. An array of arrays is written as one of more dimensions.

Anywhere else an array is bound as Bun binds it: as JSON into a jsonb column, and as a Postgres array where raw SQL, or the argument of another function, casts it to an array of a built-in type:

import { sql } from "kysely";
const
const labels: string[]
labels
= ["gift", "fragile"];
const {
const rows: {
tagged: boolean;
}[]

The rows returned by the query. This is always defined and is empty if the query returned no rows.

rows
} = await sql<{
tagged: boolean
tagged
: boolean }>`
select ${
const labels: string[]
labels
}::text[] && array['gift'] as tagged
`.execute(
const db: Kysely<DB>
db
);

An array of an enum, or of another type Bun does not know, takes a second cast from one it knows: ${moods}::text[]::mood[].

A value never becomes text of a query. The builder, sql`…` and the functions of query files send every value as a parameter, apart from the SQL, and Postgres reads it as data: a name such as '; drop table customers; -- is stored as it is. An array goes as one parameter too, the elements of its literal quoted and escaped.

What a parameter cannot carry is left to the code:

  • SQL made of input. sql.raw() and sql.lit() write their argument into the query as it is: never hand them input.
  • A name from input, such as the column a list is sorted by. A name is not a value, so it cannot be a parameter: in a query file, order by :column sorts by a constant. Kysely quotes a name it is given, but the name can be any column, so map the input to the names it may be.
  • set, which takes no parameter: sql`set local app.user_id = ${id}` fails with 42601. set_config() takes the value as one.
  • A pattern of like, where % and _ from input match anything. Escape them.
import { sql } from "kysely";
const
const sortable: {
readonly placed: "placed_at";
readonly total: "total";
}
sortable
= {
placed: "placed_at"
placed
: "placed_at",
total: "total"
total
: "total" } as const;
function openOrders(
sort: "total" | "placed"
sort
: keyof typeof
const sortable: {
readonly placed: "placed_at";
readonly total: "total";
}
sortable
) {
return
const db: Kysely<DB>
db
.selectFrom("orders")
.select(["id", "total", "placed_at"])
.where("status", "=", "new")
.orderBy(
const sortable: {
readonly placed: "placed_at";
readonly total: "total";
}
sortable
[
sort: "total" | "placed"
sort
], "desc")
.execute();
}
await
const db: Kysely<DB>
db
.transaction().execute(async (
trx: Transaction<DB>
trx
) => {
await sql`select set_config('app.user_id', ${
const userId: string
userId
}, true)`.execute(
trx: Transaction<DB>
trx
);
// the transaction's queries, and its policies, read current_setting('app.user_id')
});
const
const matching: {
email: string;
}[]
matching
= await
const db: Kysely<DB>
db
.selectFrom("customers")
.select("email")
.where("email", "like", `${
const typed: string
typed
.replaceAll(/[\\%_]/g, "\\$&")}%`)
.execute();

set_config() with true holds for the transaction, as set local does.

A query runs on Bun’s pool, which sends the queries of many callers down one connection without waiting for each answer. A query holds no connection of its own, and max, 10 by default, limits connections, not queries: the benchmark runs 32 queries at a time on 10 connections.

Three things reserve a connection for themselves:

  • a transaction, from its begin to its end;
  • the work of db.connection(), below;
  • a query given a signal with Kysely’s cancel query strategy, to cancel it by.

Reservations take turns, in the order they come, at one fewer connection than max, at least one. Bun hands a connection that comes free to what has waited longest, a query or a reservation, so were every connection theirs to take, a query that comes behind a burst of transactions — a health check included — would wait for each of them to start, and behind long ones for as long as they run. The connection left over is the pool’s.

A reservation inside another’s work, such as a transaction opened on db inside the callback of db.connection(), needs a turn of its own, and waits for it while holding the first. With max: 2 there is one turn, and it waits forever. Run the inner queries on what the work is handed — the trx of a transaction, the connection of connection() — and it needs no turn.

Queries of db share no session: two of them may run on different connections. Work that needs one — a set that later queries read, a temporary table, a session’s advisory lock — runs in db.connection(), whose queries share one connection, reserved for it:

import { sql } from "kysely";
await
const db: Kysely<DB>
db
.connection().execute(async (
session: Kysely<DB>
session
) => {
await sql`select pg_advisory_lock(${42})`.execute(
session: Kysely<DB>
session
);
try {
await closeTheDay(
session: Kysely<DB>
session
);
} finally {
await sql`select pg_advisory_unlock(${42})`.execute(
session: Kysely<DB>
session
);
}
});

The connection goes back to the pool as the work left it: Bun resets nothing. Undo what the work set for the session before it ends, or set it with set local inside a transaction, which ends with it. connection() keeps its session on a Kysely made from db too, by withSchema() or withPlugin(). A query the work runs on db itself, rather than on what it is handed, runs on the pool.

A query is given up with an AbortSignal, as Kysely takes it:

const
const totals: {
status: "new" | "paid" | "shipped";
total: string;
}[]
totals
= await
const db: Kysely<DB>
db
.selectFrom("orders")
.select(["status",
const db: Kysely<DB>
db
.
fn: FunctionModule<DB, keyof DB>

Returns a

FunctionModule

that can be used to write somewhat type-safe function calls.

const { count } = db.fn
await db.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select([
'id',
count('pet.id').as('person_count'),
])
.groupBy('person.id')
.having(count('pet.id'), '>', 10)
.execute()

The generated SQL (PostgreSQL):

select "person"."id", count("pet"."id") as "person_count"
from "person"
inner join "pet" on "pet"."owner_id" = "person"."id"
group by "person"."id"
having count("pet"."id") > $1

Why "somewhat" type-safe? Because the function calls are not bound to the current query context. They allow you to reference columns and tables that are not in the current query. E.g. remove the innerJoin from the previous query and TypeScript won't even complain.

If you want to make the function calls fully type-safe, you can use the

ExpressionBuilder.fn

getter for a query context-aware, stricter

FunctionModule

.

await db.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select((eb) => [
'person.id',
eb.fn.count('pet.id').as('pet_count')
])
.groupBy('person.id')
.having((eb) => eb.fn.count('pet.id'), '>', 10)
.execute()

fn
.sum<string>("total").as("total")])
.groupBy("status")
.execute({
signal?: AbortSignal | undefined

An optional signal that can be used to abort the execution of (async) operations.

This is useful for cancelling long-running queries, for example when the user navigates away from the page or closes the browser tab.

See

inflightQueryAbortStrategy

for handling of database side query.

signal
: AbortSignal.timeout(5_000),
inflightQueryAbortStrategy?: InflightQueryAbortStrategy | undefined

Controls what happens when the

signal

is aborted while a query is in-flight.

'ignore query' stops waiting for query results. The query continues running on the database server, and the connection is released back to the pool only after the in-flight query settles.

'cancel query' attempts to cancel the query on the database side (e.g. pg_cancel_backend in PostgreSQL or kill query in MySQL). This requires the dialect's connection to implement

DatabaseConnection.cancelQuery

. Otherwise, falls back to 'ignore query' with a warning. Writes (insert, update, delete) are not cancellable in most database engines, so your mileage may vary. Also, some databases do not cancel the running query immediately resulting in changes being committed sometimes - consider 'kill session' when you need stronger guarantees, at all costs.

'kill session' attempts to kill the database process/session the query is running in (e.g. pg_terminate_backend in PostgreSQL) and with it any running queries, transactions and obtained locks. This requires the dialect's connection to implement

DatabaseConnection.killSession

. Otherwise, falls back to 'cancel query' with a warning. Killing the session is very aggressive, and will require reconnection on the next database operation if there are no idle connections available in the pool.

Default is 'ignore query'.

inflightQueryAbortStrategy
: "cancel query",
});

The promise then fails with the signal’s reason. What happens to the query depends on where it was:

  • Waiting for a connection none can take it on, as when transactions reserve every one, it is taken out of Bun’s queue, and never runs. On a pool busy with other queries, Bun sends a query at once, down a connection behind theirs, so it is sent, as below: a write given up there may still be made. Make a write one that can run twice, with a key of its own, before trying it again.
  • Sent, with the default ignore query, it runs to its end on the server, and its answer is dropped: a write is made, and a transaction it runs in goes on.
  • Sent, with cancel query, a connection of the database’s own sends Postgres the cancel for its backend: Postgres stops it, and inside a transaction the transaction then fails to commit. Kysely prints the query’s own failure, `query` failed in the background after abortion, on the console.

kill session is not supported: Bun’s pool may wait forever to close a connection the server ended.

A limit the server holds for every query of the pool is statement_timeout, a setting Bun sends as each connection opens:

import { database } from "@tetsujs/sql";
import { schema } from "./db.generated";
export const
const db: Kysely<DB>
db
= database(schema, {
url: string

A postgres:// URL of the database: the server, the user and the database it names are the ones used. The database is given apart from the URL as well, since Bun 1.4.4 would take PGDATABASE over it.

url
:
const config: {
DATABASE_URL: string;
}
config
.
type DATABASE_URL: string
DATABASE_URL
,
connection?: Record<string, string | number | boolean> | undefined

Postgres client runtime configuration options

connection
: {
statement_timeout: string
statement_timeout
: "5s" },
});

A query that runs longer fails with 57014. The URL’s options parameter sets it as well, as ?options=-c%20statement_timeout%3D5s.