Skip to content

Commit ba37cbb

Browse files
koskimasigalklebanov
authored andcommitted
add reusable helpers recipe and implement missing expression features (#1085)
* add reusable helpers recipe and implement missing expression features * force node 22.4.1 in CI because of an npm bug
1 parent 93a0d15 commit ba37cbb

8 files changed

Lines changed: 377 additions & 37 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
fail-fast: false
1616
matrix:
17-
node-version: [18.x, 20.x, 22.x]
17+
node-version: [18.x, 20.x, 22.4.1]
1818

1919
steps:
2020
- uses: actions/checkout@v4
@@ -44,7 +44,7 @@ jobs:
4444
strategy:
4545
fail-fast: false
4646
matrix:
47-
node-version: [18.x, 20.x, 22.x]
47+
node-version: [18.x, 20.x, 22.4.1]
4848

4949
steps:
5050
- uses: actions/checkout@v4
@@ -79,7 +79,7 @@ jobs:
7979
- name: Use Node.js
8080
uses: actions/setup-node@v4
8181
with:
82-
node-version: 22.x
82+
node-version: 22.4.1
8383
cache: 'npm'
8484

8585
- name: Use Deno ${{ matrix.deno-version }}
@@ -114,7 +114,7 @@ jobs:
114114
- name: Use Node.js
115115
uses: actions/setup-node@v4
116116
with:
117-
node-version: 22.x
117+
node-version: 22.4.1
118118
cache: 'npm'
119119

120120
- name: Install dependencies
@@ -141,7 +141,7 @@ jobs:
141141
- name: Use Node.js
142142
uses: actions/setup-node@v4
143143
with:
144-
node-version: 22.x
144+
node-version: 22.4.1
145145
cache: 'npm'
146146

147147
- name: Install dependencies
@@ -163,7 +163,7 @@ jobs:
163163
- name: Use Node.js
164164
uses: actions/setup-node@v4
165165
with:
166-
node-version: 22.x
166+
node-version: 22.4.1
167167
cache: 'npm'
168168

169169
- name: Install dependencies
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Reusable helpers
2+
3+
:::info
4+
[Here's](https://kyse.link/qm67s) a playground link containing all the code in this recipe.
5+
:::
6+
7+
Let's say you want to write the following query:
8+
9+
```sql
10+
SELECT id, first_name
11+
FROM person
12+
WHERE upper(last_name) = $1
13+
```
14+
15+
Kysely doesn't have a built-in `upper` function but there are at least three ways you could write this:
16+
17+
```ts
18+
const lastName = 'STALLONE'
19+
20+
const persons = await db
21+
.selectFrom('person')
22+
.select(['id', 'first_name'])
23+
// 1. `sql` template tag. This is the least type-safe option.
24+
// You're providing the column name without any type-checking,
25+
// and plugins won't affect it.
26+
.where(
27+
sql<string>`upper(last_name)`, '=', lastName
28+
)
29+
// 2. `sql` template tag with `ref`. Anything passed to `ref`
30+
// gets type-checked against the accumulated query context.
31+
.where(({ eb, ref }) => eb(
32+
sql<string>`upper(${ref('last_name')})`, '=', lastName
33+
))
34+
// 3. The `fn` function helps you avoid missing parentheses/commas
35+
// errors and uses refs as 1st class arguments.
36+
.where(({ eb, fn }) => eb(
37+
fn<string>('upper', ['last_name']), '=', lastName
38+
))
39+
.execute()
40+
```
41+
42+
but each option could be more readable or type-safe.
43+
44+
Fortunately Kysely allows you to easily create composable, reusable and type-safe helper functions:
45+
46+
```ts
47+
import { Expression, sql } from 'kysely'
48+
49+
function upper(expr: Expression<string>) {
50+
return sql<string>`upper(${expr})`
51+
}
52+
53+
function lower(expr: Expression<string>) {
54+
return sql<string>`lower(${expr})`
55+
}
56+
57+
function concat(...exprs: Expression<string>[]) {
58+
return sql.join<string>(exprs, sql`||`)
59+
}
60+
```
61+
62+
Using the `upper` helper, our query would look like this:
63+
64+
```ts
65+
const lastName = 'STALLONE'
66+
67+
const persons = await db
68+
.selectFrom('person')
69+
.select(['id', 'first_name'])
70+
.where(({ eb, ref }) => eb(
71+
upper(ref('last_name')), '=', lastName
72+
))
73+
.execute()
74+
```
75+
76+
The recipe for helper functions is simple: take inputs as `Expression<T>` instances where `T` is the type of the expression. For example `upper` takes in any `string` expression since it transforms strings to upper case. If you implemented the `round` function, it'd take in `Expression<number>` since you can only round numbers.
77+
78+
The helper functions should then use the inputs to create an output that's also an `Expression`. Everything you can create using the expression builder is an instance of `Expression`. So is the output of the `sql` template tag and all methods under the `sql` object. Same goes for `SelectQueryBuilder` and pretty much everything else in Kysely. Everything's an expression.
79+
80+
See [this recipe](https://kysely.dev/docs/recipes/expressions) to learn more about expressions.
81+
82+
So we've learned that everything's an expression and that expressions are composable. Let's put this idea to use:
83+
84+
```ts
85+
const persons = await db
86+
.selectFrom('person')
87+
.select(['id', 'first_name'])
88+
.where(({ eb, ref, val }) => eb(
89+
concat(
90+
lower(ref('first_name')),
91+
val(' '),
92+
upper(ref('last_name'))
93+
),
94+
'=',
95+
'sylvester STALLONE'
96+
))
97+
.execute()
98+
```
99+
100+
So far we've only used our helper functions in the first argument of `where` but you can use them anywhere:
101+
102+
```ts
103+
const persons = await db
104+
.selectFrom('person')
105+
.innerJoin('pet', (join) => join.on(eb => eb(
106+
'person.first_name', '=', lower(eb.ref('pet.name'))
107+
)))
108+
.select(({ ref, val }) => [
109+
'first_name',
110+
// If you use a helper in `select`, you need to always provide an explicit
111+
// name for it using the `as` method.
112+
concat(ref('person.first_name'), val(' '), ref('pet.name')).as('name_with_pet')
113+
])
114+
.orderBy(({ ref }) => lower(ref('first_name')))
115+
.execute()
116+
```
117+
118+
## Reusable helpers using `ExpressionBuilder`
119+
120+
Here's an example of a helper function that uses the expression builder instead of raw SQL:
121+
122+
```ts
123+
import { Expression, expressionBuilder } from 'kysely'
124+
125+
function idsOfPersonsThatHaveDogNamed(name: Expression<string>) {
126+
const eb = expressionBuilder<DB>()
127+
128+
// A subquery that returns the identifiers of all persons
129+
// that have a dog named `name`.
130+
return eb
131+
.selectFrom('pet')
132+
.select('pet.owner_id')
133+
.where('pet.species', '=', 'dog')
134+
.where('pet.name', '=', name)
135+
}
136+
```
137+
138+
And here's how you could use it:
139+
140+
```ts
141+
const dogName = 'Doggo'
142+
143+
const persons = await db
144+
.selectFrom('person')
145+
.selectAll('person')
146+
.where((eb) => eb(
147+
'person.id', 'in', idsOfPersonsThatHaveDogNamed(eb.val(dogName))
148+
))
149+
.execute()
150+
```
151+
152+
Note that `idsOfPersonsThatHaveDogNamed` doesn't execute a separate query but instead returns a subquery expression that's compiled as a part of the parent query:
153+
154+
```sql
155+
select
156+
person.*
157+
from
158+
person
159+
where
160+
person.id in (
161+
select pet.owner_id
162+
from pet
163+
where pet.species = 'dog'
164+
and pet.name = ?
165+
)
166+
```
167+
168+
In all our examples we've used the following syntax:
169+
170+
```ts
171+
.where(eb => eb(left, operator, right))
172+
```
173+
174+
When the expression builder `eb` is used as a function, it creates a binary expression. All binary expressions with a comparison operator are represented as a `Expression<SqlBool>`. You don't always need to return `eb(left, operator, right)` from the callback though. Since `Expressions` are composable and reusable, you can return any `Expression<SqlBool>`.
175+
176+
This means you can create helpers like this:
177+
178+
```ts
179+
function isOlderThan(age: Expression<number>) {
180+
return sql<SqlBool>`age > ${age}`
181+
}
182+
```
183+
184+
```ts
185+
const persons = await db
186+
.selectFrom('person')
187+
.select(['id', 'first_name'])
188+
.where(({ val }) => isOlderThan(val(60)))
189+
.execute()
190+
```
191+
192+
## Dealing with nullable expressions
193+
194+
If you want your helpers to work with nullable expressions (nullable columns etc.), you can do something like this:
195+
196+
```ts
197+
import { Expression } from 'kysely'
198+
199+
// This function accepts both nullable and non-nullable string expressions.
200+
function toInt<T extends string | null>(expr: Expression<T>) {
201+
// This returns `Expression<number | null>` if `expr` is nullable
202+
// and `Expression<number>` otherwise.
203+
return sql<T extends null ? (number | null) : number>`(${expr})::integer`
204+
}
205+
```
206+
207+
## Passing select queries as expressions
208+
209+
Let's say we have the following query:
210+
211+
```ts
212+
const expr: Expression<{ name: string }> = db
213+
.selectFrom('pet')
214+
.select('pet.name')
215+
```
216+
217+
The expression type of our query is `Expression<{ name: string }>` but SQL allows you to use a query like that as an `Expression<string>`. In other words, SQL allows you to use single-column record types like scalars. Most of the time Kysely is able to automatically handle this case but with helper functions you need to use `$asScalar()` to convert the type. Here's an example:
218+
219+
```ts
220+
const persons = await db
221+
.selectFrom('person')
222+
.select((eb) => [
223+
'id',
224+
'first_name',
225+
upper(
226+
eb.selectFrom('pet')
227+
.select('name')
228+
.whereRef('person.id', '=', 'pet.owner_id')
229+
.limit(1)
230+
.$asScalar() // <-- This is needed
231+
.$notNull()
232+
).as('pet_name')
233+
])
234+
```
235+
236+
The subquery is an `Expression<{ name: string }>` but our `upper` function only accepts `Expression<string>`. That's why we need to call `$asScalar()`. `$asScalar()` has no effect on the generated SQL. It's simply a type-level helper.
237+
238+
We also used `$notNull()` in the example because our simple `upper` function doesn't support nullable expressions.

0 commit comments

Comments
 (0)