arn-db 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +829 -0
- package/README.md +265 -2
- package/dist/index.cjs +1112 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +899 -0
- package/dist/index.d.mts +899 -0
- package/dist/index.mjs +1098 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +48 -4
package/GUIDE.md
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
# arn-db guide
|
|
2
|
+
|
|
3
|
+
Recipes organised by "I want to…". Every example runs against the test database
|
|
4
|
+
in this repo (`db/init.sql`), and the outputs shown are the real results:
|
|
5
|
+
`test/guide.test.ts` checks them on every test run.
|
|
6
|
+
|
|
7
|
+
The example tables:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
users id, name, email, age, active, meta jsonb, settings json, tags text[], scores jsonb
|
|
11
|
+
posts id, user_id → users, reviewer_id → users, title, published (user_N has N % 3 posts)
|
|
12
|
+
comments id, post_id → posts, author_id → users, body
|
|
13
|
+
labels id, name
|
|
14
|
+
post_labels post_id → posts, label_id → labels (posts ↔ labels)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Contents:
|
|
18
|
+
[Setup](#setup) ·
|
|
19
|
+
[Selecting](#selecting) ·
|
|
20
|
+
[Filtering](#filtering) ·
|
|
21
|
+
[Ordering and random](#ordering-and-random) ·
|
|
22
|
+
[Related tables](#related-tables) ·
|
|
23
|
+
[Searching across 3+ tables](#searching-across-3-tables) ·
|
|
24
|
+
[Searching arrays](#searching-arrays) ·
|
|
25
|
+
[Counting](#counting) ·
|
|
26
|
+
[Aggregates and grouping](#aggregates-and-grouping) ·
|
|
27
|
+
[Insert, update, delete](#insert-update-delete) ·
|
|
28
|
+
[Changing jsonb and arrays](#changing-jsonb-and-arrays) ·
|
|
29
|
+
[Upsert and find-or-create](#upsert-and-find-or-create) ·
|
|
30
|
+
[Writing related rows](#writing-related-rows) ·
|
|
31
|
+
[Transactions](#transactions) ·
|
|
32
|
+
[Read replicas](#read-replicas) ·
|
|
33
|
+
[Raw SQL fragments](#raw-sql-fragments) ·
|
|
34
|
+
[Errors](#errors) ·
|
|
35
|
+
[Not supported yet](#not-supported-yet)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
Set two environment variables (in the app's `.env` or its container env):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
ARN_DB_URL=https://your-api.example.com/v2/raw
|
|
45
|
+
ARN_DB_KEY=<your API key>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then import the ready-made `db` anywhere. It has everything: `db.from()`,
|
|
49
|
+
`db.transaction()`, `db.ops.*` and `db.rel.*`.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { db } from 'arn-db'
|
|
53
|
+
|
|
54
|
+
await db.from('users').update({ scores: db.ops.append(4) }).eq('id', id)
|
|
55
|
+
await db.from('posts').insert({ title, user_id, comments: db.rel.create([{ body, author_id }]) })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`db` reads the variables on its first query, not on import, so loading dotenv
|
|
59
|
+
later in startup still works. If they aren't set yet, it loads `./.env` from the
|
|
60
|
+
working directory first (Node 20.12+). Variables that are already set always win.
|
|
61
|
+
A missing variable throws on that first query.
|
|
62
|
+
|
|
63
|
+
Inside `transaction()` the same helpers are on `tx`: `tx.from()`, `tx.ref()`,
|
|
64
|
+
`tx.ops.*`, `tx.rel.*`. (`import { ops, rel } from 'arn-db'` still works too.)
|
|
65
|
+
|
|
66
|
+
For another key, another server, or other options, make your own client:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createClient } from 'arn-db'
|
|
70
|
+
|
|
71
|
+
const reports = createClient(process.env.REPORTS_DB_URL!, process.env.REPORTS_DB_KEY!, { readFrom: 'replica' })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every call resolves to the same shape and never throws:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const { data, error, count, status } = await db.from('users').select()
|
|
78
|
+
if (error) {
|
|
79
|
+
// error.message, error.code, error.details
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Server-side only: requests are POSTs with an `apikey` header; the API refuses browsers.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Selecting
|
|
88
|
+
|
|
89
|
+
| I want | Write |
|
|
90
|
+
|---|---|
|
|
91
|
+
| all columns | `select()` or `select('*')` |
|
|
92
|
+
| some columns | `select('id, name, email')` |
|
|
93
|
+
| a column under another name | `select('id, fullName:name')` |
|
|
94
|
+
| a value inside jsonb, as text | `select('city:meta->>city')` |
|
|
95
|
+
| a value inside jsonb, keeping its type | `select('score:meta->score')` |
|
|
96
|
+
| deeper inside jsonb | `select('zip:meta->address->>zip')` |
|
|
97
|
+
| converted to a type | `select('score:meta->>score::int')` |
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
await db.from('users')
|
|
101
|
+
.select('name, city:meta->>city, zip:meta->address->>zip, score:meta->score')
|
|
102
|
+
.eq('name', 'user_7')
|
|
103
|
+
.single()
|
|
104
|
+
// { name: 'user_7', city: 'Paris', zip: '1007', score: 7 }
|
|
105
|
+
// ^ ->> gives text ^ -> keeps the number
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**One row or many:**
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
.single() // one object, or null if no row (never an error for 0 or many: it adds LIMIT 1)
|
|
112
|
+
.limit(1) // always an array: [ {...} ], or [] if no row
|
|
113
|
+
.range(10, 19) // rows 10..19 (0-based, inclusive)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`single()` can't be combined with `limit()` or `range()`. With several matches
|
|
117
|
+
it returns the first one in your `order()`, so look rows up by a unique column
|
|
118
|
+
(or add `order()`) when it matters which one you get.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Filtering
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
.eq('active', true) .neq('name', 'x')
|
|
126
|
+
.gt('age', 30) .gte('age', 30) .lt('age', 30) .lte('age', 30)
|
|
127
|
+
.like('name', 'user\\_1%') .ilike('email', '%@EXAMPLE.com') // _ and % are wildcards
|
|
128
|
+
.in('name', ['user_3', 'user_5'])
|
|
129
|
+
.is('age', null) // also true / false
|
|
130
|
+
.match({ active: true, age: 30 }) // several eq at once
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Filters combine with AND (for OR and NOT see [groups](#or-not-and-groups)). Filters work on jsonb paths too:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
.eq('meta->>city', 'Tokyo') // text inside jsonb
|
|
137
|
+
.eq('meta->vip', true) // keeps the JSON type
|
|
138
|
+
.eq('meta->address->>zip', 1042) // deeper
|
|
139
|
+
.gte('meta->>score::int', 98) // cast for number comparison
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
await db.from('users').select('name, score:meta->>score::int')
|
|
144
|
+
.gte('meta->>score::int', 98).order('meta->>score::int')
|
|
145
|
+
// [{ name: 'user_98', score: 98 }, { name: 'user_99', score: 99 }, { name: 'user_100', score: 100 }]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Without `::int`, `->>` compares as text, so `'9' > '10'`.
|
|
149
|
+
|
|
150
|
+
### Comparing two columns, and the database time
|
|
151
|
+
|
|
152
|
+
`db.col(name)` is another column of the same row; `db.now()` is the database
|
|
153
|
+
server's `now()`. Both work with `eq`, `neq`, `gt`, `gte`, `lt` and `lte`, also inside groups.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
.lt('age', db.col('meta->>limit::int')) // age < (meta->>limit)::int
|
|
157
|
+
.lt(db.col('age').ifNull(0), db.col('meta->>limit::int')) // COALESCE(age, 0) < ...
|
|
158
|
+
.lt('age', db.col('meta->>limit::int').ifNull(100)) // ifNull on the right side
|
|
159
|
+
.gte(db.col('age').ifNull(0), 5) // ifNull with a plain value
|
|
160
|
+
|
|
161
|
+
.gt('created_at', db.now().minus('1 day')) // the last 24 hours
|
|
162
|
+
.lte('sent_at', db.now().minus('1 hour'))
|
|
163
|
+
.lt('expires_at', db.now().plus('30 minutes'))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- Intervals are one number and one unit: `second`, `minute`, `hour`, `day`, `week`,
|
|
167
|
+
`month` or `year` (plural is fine). One `plus()` or `minus()` per `db.now()`.
|
|
168
|
+
- `db.col()` takes columns of the same table only (jsonb paths and casts are fine).
|
|
169
|
+
- `db.now()` also works as a value: `update({ last_read_time: db.now() })`, or in
|
|
170
|
+
`insert()` when every row uses the same `db.now()`. The time comes from the
|
|
171
|
+
database, so app servers with different clocks agree.
|
|
172
|
+
|
|
173
|
+
### OR, NOT and groups
|
|
174
|
+
|
|
175
|
+
Chained filters are AND. `or()`, `and()` and `not()` take a callback that
|
|
176
|
+
builds one grouped condition; groups nest as deep as you need.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// age > 60 OR city = 'Dhaka'
|
|
180
|
+
.or((q) => q.gt('age', 60).eq('meta->>city', 'Dhaka'))
|
|
181
|
+
|
|
182
|
+
// active AND (Tokyo OR Paris) AND age < 25
|
|
183
|
+
.eq('active', true).or((q) => q.eq('meta->>city', 'Tokyo').eq('meta->>city', 'Paris')).lt('age', 25)
|
|
184
|
+
|
|
185
|
+
// role = 'admin' OR (age >= 18 AND verified)
|
|
186
|
+
.or((q) => q.eq('role', 'admin').and((q) => q.gte('age', 18).is('verified', true)))
|
|
187
|
+
|
|
188
|
+
// NOT: a short form for one condition, a callback for several (joined with AND)
|
|
189
|
+
.not('deleted_at', 'is', null)
|
|
190
|
+
.not((q) => q.eq('active', true).gte('age', 40)) // NOT (active AND age >= 40)
|
|
191
|
+
.not((q) => q.or((q) => q.eq('meta->>city', 'Tokyo').eq('meta->>city', 'Berlin'))) // neither
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- Inside `or()` the conditions are joined with OR; inside `and()` and `not()` with AND.
|
|
195
|
+
- Every filter method works inside a group: jsonb paths, casts, `in`, `contains`, `match`, `filter`.
|
|
196
|
+
- Groups work in `count()`, `update()`, `delete()` and transactions (`tx.ref()` too).
|
|
197
|
+
- NULL is neither true nor false, as in Postgres: a row with a null `reviewer_id`
|
|
198
|
+
matches neither `.eq('reviewer_id', id)` nor `.not('reviewer_id', 'eq', id)`.
|
|
199
|
+
Add `.is('reviewer_id', null)` in an `or()` to keep those rows.
|
|
200
|
+
- All conditions in one group must be on the same table. A group on
|
|
201
|
+
`posts.*` filters the related posts, like `.eq('posts.published', true)`
|
|
202
|
+
(add `!inner` to also drop parents without a match):
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
await db.from('users').select('name, posts!user_id(title)')
|
|
206
|
+
.or((q) => q.eq('posts.title', 'user_5_post_2').eq('posts.published', true))
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Mixing tables in one group (`name = 'x' OR posts.published`) is an error for now.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Ordering and random
|
|
214
|
+
|
|
215
|
+
**Several orders:** chain them; they apply in the order written. Ascending is the default.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await db.from('users').select('name, active, age')
|
|
219
|
+
.in('name', ['user_4', 'user_8', 'user_12', 'user_54', 'user_58'])
|
|
220
|
+
.order('active', { ascending: false }) // 1st: active first
|
|
221
|
+
.order('age', { ascending: false }) // 2nd: then oldest
|
|
222
|
+
.order('name') // 3rd: then by name
|
|
223
|
+
// [
|
|
224
|
+
// { name: 'user_58', active: true, age: 26 },
|
|
225
|
+
// { name: 'user_54', active: true, age: 22 },
|
|
226
|
+
// { name: 'user_12', active: false, age: 30 },
|
|
227
|
+
// { name: 'user_8', active: false, age: 26 },
|
|
228
|
+
// { name: 'user_4', active: false, age: 22 },
|
|
229
|
+
// ]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Options: `{ ascending: false, nullsFirst: true }`. Columns can be jsonb paths: `.order('meta->>score::int')`.
|
|
233
|
+
|
|
234
|
+
**Random:**
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
.random() // shuffle everything
|
|
238
|
+
.random().limit(5) // 5 random rows
|
|
239
|
+
.order('active', { ascending: false }).random() // active first, shuffled within each group
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Random pages:** plain `random()` reshuffles on every request, so page 2 can
|
|
243
|
+
repeat page 1. Give a seed (e.g. a session id) for a stable order:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
await db.from('posts').select().random({ seed: sessionId }).range(0, 9) // page 1
|
|
247
|
+
await db.from('posts').select().random({ seed: sessionId }).range(10, 19) // page 2: no repeats, no gaps
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Same seed gives the same order; another seed gives another shuffle.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Related tables
|
|
255
|
+
|
|
256
|
+
Write the related table's name in `select()`, with its columns in brackets.
|
|
257
|
+
The table name becomes the key, or an alias if you give one.
|
|
258
|
+
|
|
259
|
+
### Reading the select string
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
.select('name, posts!user_id!inner(title, labels(name))')
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
| Piece | Meaning |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `name` | a column of the main table |
|
|
268
|
+
| `posts(...)` | also bring rows from `posts`, under the key `posts` |
|
|
269
|
+
| `!user_id` | which link: `posts` has two FKs to `users` (`user_id`, `reviewer_id`) |
|
|
270
|
+
| `!inner` | only keep main rows that have a match (like INNER JOIN) |
|
|
271
|
+
| `title` | a column of each post |
|
|
272
|
+
| `labels(name)` | for each post, also bring its labels |
|
|
273
|
+
| `author:users(...)` | an alias: the key is `author` instead of `users` |
|
|
274
|
+
|
|
275
|
+
### Directions
|
|
276
|
+
|
|
277
|
+
**Many-to-one** (post → its author): an object, or null.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
await db.from('posts').select('title, author:users!user_id(name)').eq('title', 'user_5_post_1').single()
|
|
281
|
+
// { title: 'user_5_post_1', author: { name: 'user_5' } }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**One-to-many** (user → their posts): an array, `[]` when none.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
await db.from('users').select('name, posts!user_id(title)').eq('name', 'user_5')
|
|
288
|
+
.order('title', { referencedTable: 'posts' }).single()
|
|
289
|
+
// { name: 'user_5', posts: [{ title: 'user_5_post_1' }, { title: 'user_5_post_2' }] }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Many-to-many** (posts ↔ labels through `post_labels`): an array; the junction is found automatically.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
await db.from('posts').select('title, labels(name)').eq('title', 'user_5_post_2')
|
|
296
|
+
.order('name', { referencedTable: 'labels' }).single()
|
|
297
|
+
// { title: 'user_5_post_2', labels: [{ name: 'go' }, { name: 'node' }] }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Nested, any depth:**
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
await db.from('users')
|
|
304
|
+
.select('name, posts!user_id(title, comments(body, author:users(name)), labels(name))')
|
|
305
|
+
.eq('name', 'user_5').order('title', { referencedTable: 'posts' }).single()
|
|
306
|
+
// {
|
|
307
|
+
// name: 'user_5',
|
|
308
|
+
// posts: [
|
|
309
|
+
// { title: 'user_5_post_1',
|
|
310
|
+
// comments: [{ body: 'comment on user_5_post_1', author: { name: 'user_2' } }],
|
|
311
|
+
// labels: [{ name: 'sql' }] },
|
|
312
|
+
// { title: 'user_5_post_2', comments: [], labels: [{ name: 'go' }, { name: 'node' }] },
|
|
313
|
+
// ],
|
|
314
|
+
// }
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**All columns of a relation:** `select('*, posts!user_id(*)')`.
|
|
318
|
+
|
|
319
|
+
**When do I need `!hint`?** Only when two tables have more than one link. If you
|
|
320
|
+
forget, the error tells you the choices:
|
|
321
|
+
`more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
|
|
322
|
+
|
|
323
|
+
**A table that points to itself** (`categories.parent_id -> categories`): the
|
|
324
|
+
table name gives the children, the FK column gives the parent. The same keys
|
|
325
|
+
work for `rel.*` writes (`categories: db.rel.create([...])`, `parent_id: db.rel.connect(id)`).
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
await db.from('categories').select('name, parent:parent_id(name), children:categories(name)').eq('name', 'go').single()
|
|
329
|
+
// { name: 'go', parent: { name: 'tech' }, children: [{ name: 'generics' }] }
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Filtering, ordering and limiting inside a relation
|
|
333
|
+
|
|
334
|
+
Use `relation.column` (the alias, if you gave one):
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// filter the related rows only: every user is still returned
|
|
338
|
+
await db.from('users').select('name, posts!user_id(title)')
|
|
339
|
+
.in('name', ['user_3', 'user_5']).eq('posts.published', false).order('name')
|
|
340
|
+
// [{ name: 'user_3', posts: [] }, { name: 'user_5', posts: [{ title: 'user_5_post_2' }] }]
|
|
341
|
+
|
|
342
|
+
// add !inner: users without a matching post are dropped too
|
|
343
|
+
await db.from('users').select('name, posts!user_id!inner(title)')
|
|
344
|
+
.in('name', ['user_3', 'user_5']).eq('posts.published', false).order('name')
|
|
345
|
+
// [{ name: 'user_5', posts: [{ title: 'user_5_post_2' }] }]
|
|
346
|
+
|
|
347
|
+
// filter the main rows through a many-to-one relation (alias + !inner)
|
|
348
|
+
await db.from('posts').select('title, author:users!user_id!inner(name)')
|
|
349
|
+
.eq('author.name', 'user_8').order('title')
|
|
350
|
+
// [{ title: 'user_8_post_1', author: { name: 'user_8' } }, { title: 'user_8_post_2', author: { name: 'user_8' } }]
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Order, limit and random inside a relation take `referencedTable`:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
await db.from('users').select('name, posts!user_id(title)').eq('name', 'user_5')
|
|
357
|
+
.order('title', { ascending: false, referencedTable: 'posts' })
|
|
358
|
+
.limit(1, { referencedTable: 'posts' })
|
|
359
|
+
.single()
|
|
360
|
+
// { name: 'user_5', posts: [{ title: 'user_5_post_2' }] } newest-titled post only
|
|
361
|
+
|
|
362
|
+
.random({ referencedTable: 'posts' }).limit(3, { referencedTable: 'posts' }) // 3 random posts per user
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Deeper levels use dots: `.eq('posts.comments.body', 'x')`, `{ referencedTable: 'posts.comments' }`.
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Searching across 3+ tables
|
|
370
|
+
|
|
371
|
+
Chain: `users` (1) ← `posts` (2) ← `comments` (3). Search comments for `user_5_post`:
|
|
372
|
+
|
|
373
|
+
**A) Start from the table you search, go up to the parents.** Simplest.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
await db.from('comments')
|
|
377
|
+
.select('body, post:posts(title, author:users!user_id(name))')
|
|
378
|
+
.ilike('body', '%user_5_post%')
|
|
379
|
+
// [{ body: 'comment on user_5_post_1', post: { title: 'user_5_post_1', author: { name: 'user_5' } } }]
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**B) Start from the top; show only what matched.** `!inner` at each level.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
await db.from('users')
|
|
386
|
+
.select('name, posts!user_id!inner(title, comments!inner(body))')
|
|
387
|
+
.ilike('posts.comments.body', '%user_5_post%')
|
|
388
|
+
// [{ name: 'user_5', posts: [{ title: 'user_5_post_1', comments: [{ body: 'comment on user_5_post_1' }] }] }]
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**C) Find with a deep search, but show everything for the rows found.** Embed
|
|
392
|
+
the same relation twice: one alias only for searching, one for showing.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
await db.from('users')
|
|
396
|
+
.select('name, match:posts!user_id!inner(comments!inner(id)), posts!user_id(title, comments(body))')
|
|
397
|
+
.ilike('match.comments.body', '%user_5_post%')
|
|
398
|
+
.order('title', { referencedTable: 'posts' })
|
|
399
|
+
// [{
|
|
400
|
+
// name: 'user_5',
|
|
401
|
+
// match: [ ... ], // only used for the search; ignore it
|
|
402
|
+
// posts: [
|
|
403
|
+
// { title: 'user_5_post_1', comments: [{ body: 'comment on user_5_post_1' }] },
|
|
404
|
+
// { title: 'user_5_post_2', comments: [] }, // not matching, still shown
|
|
405
|
+
// ],
|
|
406
|
+
// }]
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
| I want | Use |
|
|
410
|
+
|---|---|
|
|
411
|
+
| the matching deep rows, with their parents | A |
|
|
412
|
+
| parents, showing only what matched | B |
|
|
413
|
+
| parents found by a deep search, with all their data | C |
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Searching arrays
|
|
418
|
+
|
|
419
|
+
Works on jsonb/json arrays (any element type) and Postgres arrays like `text[]`.
|
|
420
|
+
For a column holding `["one", "two"]`:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
.overlaps('scores', ['one', 'three']) // has ANY of them -> match
|
|
424
|
+
.contains('scores', ['one', 'two']) // has ALL of them -> match
|
|
425
|
+
.containedBy('scores', ['one', 'two', 'three']) // has nothing else -> match
|
|
426
|
+
.eq('scores', ['one', 'two']) // exactly, same order -> match
|
|
427
|
+
.overlaps('meta->tags', ['red']) // an array inside a jsonb object
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
jsonb objects: `.contains('meta', { city: 'Dhaka', vip: true })` finds rows whose
|
|
431
|
+
`meta` has those keys and values.
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Counting
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
const { data } = await db.from('users').count() // data: 100
|
|
439
|
+
const { data } = await db.from('users').eq('active', true).count() // data: 75
|
|
440
|
+
|
|
441
|
+
// Supabase form: no rows, count only
|
|
442
|
+
const { data, count } = await db.from('users').select('*', { count: 'exact', head: true }).eq('active', false)
|
|
443
|
+
// data: null, count: 25
|
|
444
|
+
|
|
445
|
+
// rows and the total together (for "page 1 of N")
|
|
446
|
+
const { data, count } = await db.from('posts').select('*', { count: 'exact' }).range(0, 9)
|
|
447
|
+
|
|
448
|
+
// per related row
|
|
449
|
+
await db.from('users').select('name, posts!user_id(count)').in('name', ['user_3', 'user_5']).order('name')
|
|
450
|
+
// [{ name: 'user_3', posts: [{ count: 0 }] }, { name: 'user_5', posts: [{ count: 2 }] }]
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Filters on relations apply: `.eq('posts.published', true)` counts only published posts.
|
|
454
|
+
|
|
455
|
+
## Aggregates and grouping
|
|
456
|
+
|
|
457
|
+
One value over the matching rows; `data` is the value, or `null` when no rows match:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
const { data: newest } = await db.from('posts').eq('user_id', id).max('created_at')
|
|
461
|
+
await db.from('users').like('name', 'user\\_%').min('age')
|
|
462
|
+
await db.from('users').sum('age')
|
|
463
|
+
await db.from('users').avg('age')
|
|
464
|
+
await db.from('users').count({ distinct: 'name' }) // count(DISTINCT name)
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
These take filters (and `.replica()`), and work as transaction steps.
|
|
468
|
+
|
|
469
|
+
Per group, in `select()`: `count()`, `sum(col)`, `avg(col)`, `min(col)`, `max(col)`,
|
|
470
|
+
`count(distinct col)`, each with an optional `name:` alias. Every plain column in the select must be in `groupBy()`,
|
|
471
|
+
and `order()` can sort by an aggregate's name:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
await db.from('users')
|
|
475
|
+
.select('team:meta->>team, n:count(), total:sum(age), oldest:max(age)', { count: 'exact' })
|
|
476
|
+
.groupBy('meta->>team')
|
|
477
|
+
.order('total', { ascending: false })
|
|
478
|
+
// with the four users test/merge.test.ts adds (teams red: ages 10, 30; blue: 20, null):
|
|
479
|
+
// data: [{ team: 'red', n: 2, total: 40, oldest: 30 }, { team: 'blue', n: 2, total: 20, oldest: 20 }]
|
|
480
|
+
// count: 2 (groups)
|
|
481
|
+
|
|
482
|
+
await db.from('users').select('count(), max(age)') // [{ count: 100, max: ... }]: one row, no groupBy
|
|
483
|
+
await db.from('posts').select('user_id').distinct() // SELECT DISTINCT
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Aggregates, `groupBy()` and `distinct()` don't combine with related tables in the
|
|
487
|
+
same select. For `HAVING`, use `.having(db.raw('count(*) >= ?', [2]))` (see
|
|
488
|
+
[Raw SQL fragments](#raw-sql-fragments)).
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Insert, update, delete
|
|
493
|
+
|
|
494
|
+
Writes follow Postgres: every matching row is written. Add `.returning()` to get
|
|
495
|
+
the written rows back, like SQL `RETURNING`.
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
await db.from('users').insert({ name, email }) // data: null, count: 1
|
|
499
|
+
await db.from('users').insert({ name, email }).returning() // data: [{ id, name, email, ... }]
|
|
500
|
+
await db.from('users').insert([row1, row2]).returning('id') // data: [{ id }, { id }]
|
|
501
|
+
|
|
502
|
+
await db.from('users').update({ name: 'New' }).eq('active', false) // count: rows changed
|
|
503
|
+
await db.from('users').update({ age: db.ops.increment() }).eq('id', id).returning('age') // [{ age: 31 }]
|
|
504
|
+
|
|
505
|
+
await db.from('posts').delete().eq('user_id', id).returning('id') // every deleted row
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
- `returning()` is **always an array**: one row is `[row]`, none is `[]`. It takes `'*'`
|
|
509
|
+
(the default) or column names; related tables are read with a normal `select()` afterwards.
|
|
510
|
+
- Without `returning()`, `data` is `null` and `count` is the number of rows written
|
|
511
|
+
(Postgres' `INSERT 0 2` / `UPDATE 3` / `DELETE 1`).
|
|
512
|
+
- `update()` and `delete()` need at least one filter, so a mistake can't change the whole table.
|
|
513
|
+
- Writes take filters on their own table only. `select()`, `single()`, `order()`, `random()`,
|
|
514
|
+
`limit()` and `range()` are for reads.
|
|
515
|
+
- `id`, `created_at`, `updated_at` come from the database; `updated_at` changes on every update.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Changing jsonb and arrays
|
|
520
|
+
|
|
521
|
+
Atomic: the change happens inside Postgres, so two requests at once don't overwrite each other.
|
|
522
|
+
|
|
523
|
+
| Before | Update | After |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| `[1,2,3]` | `{ scores: db.ops.append(4) }` | `[1,2,3,4]` |
|
|
526
|
+
| `[1,2,3]` | `{ scores: db.ops.append(4, 5) }` | `[1,2,3,4,5]` |
|
|
527
|
+
| `[1,2,3]` | `{ scores: db.ops.append([4, 5]) }` | `[1,2,3,[4,5]]` (one element) |
|
|
528
|
+
| `[1,2,3]` | `{ scores: db.ops.prepend(0) }` | `[0,1,2,3]` |
|
|
529
|
+
| `[1,2,3]` | `{ scores: db.ops.remove(2) }` | `[1,3]` (every 2 removed) |
|
|
530
|
+
| `[1,2,3]` | `{ scores: db.ops.set(9).at(0) }` | `[9,2,3]` |
|
|
531
|
+
| `[1,2,3]` | `{ scores: db.ops.unset().at(0) }` | `[2,3]` |
|
|
532
|
+
| `[1,2,3]` | `{ scores: [7, 8] }` | `[7,8]` (full replace) |
|
|
533
|
+
| `{a:1}` | `{ meta: db.ops.merge({ b: 2 }) }` | `{a:1,b:2}` |
|
|
534
|
+
| `{a:1}` | `{ meta: { b: 2 } }` | `{b:2}` (full replace) |
|
|
535
|
+
| `{tags:['x']}` | `{ meta: db.ops.append('y').at('tags') }` | `{tags:['x','y']}` |
|
|
536
|
+
| `{n:1}` | `{ meta: db.ops.increment(5).at('n') }` | `{n:6}` |
|
|
537
|
+
| `{a:1,b:2}` | `{ meta: db.ops.unset().at('b') }` | `{a:1}` |
|
|
538
|
+
|
|
539
|
+
Also:
|
|
540
|
+
- **Postgres arrays** (`text[]`): `db.ops.append`, `db.ops.prepend`, `db.ops.remove`.
|
|
541
|
+
- **Numbers**: `{ age: db.ops.increment() }`, `db.ops.increment(-1)`.
|
|
542
|
+
- **Combining:** `update({ name: 'x', age: db.ops.increment(), scores: db.ops.append(4) })` changes all three in one statement.
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Upsert and find-or-create
|
|
547
|
+
|
|
548
|
+
One row or many (up to 5,000 per call). `onConflict` columns need a unique constraint (default: the primary key).
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
// insert, or UPDATE the existing row with these values
|
|
552
|
+
await db.from('users').upsert({ email, name }, { onConflict: 'email' }).returning()
|
|
553
|
+
|
|
554
|
+
// find-or-create: insert, or return the existing row UNCHANGED
|
|
555
|
+
const { data } = await db.from('users')
|
|
556
|
+
.upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true })
|
|
557
|
+
.returning('id')
|
|
558
|
+
data[0].id // returning() is always an array
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
Safe when many requests run at once: they all get the same row, with no duplicates and no errors.
|
|
562
|
+
Unlike Supabase, `ignoreDuplicates` returns the existing row, so transaction steps can use it.
|
|
563
|
+
|
|
564
|
+
### Choosing what changes: `merge`
|
|
565
|
+
|
|
566
|
+
Without `merge`, an existing row gets every column you send (except the conflict key).
|
|
567
|
+
With `merge`, only the listed columns change and the rest keep their stored value:
|
|
568
|
+
|
|
569
|
+
| `merge` value | Existing row's column becomes |
|
|
570
|
+
|---|---|
|
|
571
|
+
| `'set'` | the incoming value |
|
|
572
|
+
| `'add'` | stored + incoming (NULL counts as 0) |
|
|
573
|
+
| `'append'` / `'prepend'` | stored and incoming jsonb or Postgres arrays joined |
|
|
574
|
+
| `db.ops.increment(1)`, `db.ops.merge({...})`, ... | the operator applied to the stored value |
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
// counters per post, 2,000 rows at a time
|
|
578
|
+
await db.from('posts_stats').upsert(batch, {
|
|
579
|
+
onConflict: 'post_id',
|
|
580
|
+
merge: { views: 'add', likes: 'add', last_viewer_email: 'set', last_viewer_id: 'set' },
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// a new row is inserted as sent; an existing one gets name set and age + 1
|
|
584
|
+
await db.from('users').upsert({ email, name, age: 0 }, { onConflict: 'email', merge: { name: 'set', age: db.ops.increment(1) } })
|
|
585
|
+
|
|
586
|
+
merge: ['name', 'email'] // shorthand: 'set' for each
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
- Each conflict key may appear once per call; two rows with the same key are an error
|
|
590
|
+
(`upsert: rows 0 and 2 have the same email ["a"]; ...`). Combine them first.
|
|
591
|
+
- A column with `'set'`, `'add'`, `'append'` or `'prepend'` must be in the rows; an
|
|
592
|
+
operator doesn't need to be.
|
|
593
|
+
- For "set this only when ...", add an `update()` step with that filter in the same
|
|
594
|
+
transaction:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
await db.transaction((tx) => [
|
|
598
|
+
tx.from('clients').upsert(row, { onConflict: 'user_name', merge: { subject: 'set', total_code_leads: tx.ops.increment(1) } }),
|
|
599
|
+
tx.from('clients').update({ total_sent: 100 }).eq('user_name', row.user_name).not('shared_email', 'is', null),
|
|
600
|
+
])
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Writing related rows
|
|
606
|
+
|
|
607
|
+
Put `rel.*` under the related table's name (with `!hint` when needed). The whole
|
|
608
|
+
`insert()`/`update()` runs in one transaction: if anything fails, nothing is saved.
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
await db.from('posts').insert({
|
|
612
|
+
title: 'Hello',
|
|
613
|
+
user_id: userId,
|
|
614
|
+
comments: db.rel.create([{ body: 'first', author_id: userId }]), // post_id filled in for you
|
|
615
|
+
labels: db.rel.connect([labelId]), // adds the post_labels row
|
|
616
|
+
}).returning('id')
|
|
617
|
+
|
|
618
|
+
await db.from('posts').update({
|
|
619
|
+
title: 'New title',
|
|
620
|
+
labels: db.rel.set([labelA, labelB]), // exactly these labels
|
|
621
|
+
comments: db.rel.update({ id: commentId }, { body: 'edited' }),
|
|
622
|
+
}).eq('id', postId)
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
| | one-to-many (`comments`) | many-to-many (`labels`) | many-to-one (`'users!user_id'`) |
|
|
626
|
+
|---|---|---|---|
|
|
627
|
+
| `db.rel.create(rows)` | add children | create + link | create, set the FK |
|
|
628
|
+
| `db.rel.upsert(row, { onConflict })` | add or update a child | upsert + link | upsert, set the FK |
|
|
629
|
+
| `db.rel.connectOrCreate(row, { onConflict })` | find or create, move here | find or create + link | find or create, set the FK |
|
|
630
|
+
| `db.rel.connect(keys)` | move children here | link | set the FK |
|
|
631
|
+
| `db.rel.disconnect(keys)` | set child FK to null | unlink | set the FK to null |
|
|
632
|
+
| `db.rel.set(keys)` | exactly these children | exactly these links | — |
|
|
633
|
+
| `db.rel.update(match, values)` | update matching children | update matching linked rows | — |
|
|
634
|
+
| `db.rel.delete(match?)` | delete matching children | delete matching linked rows | — |
|
|
635
|
+
|
|
636
|
+
Created rows can contain `rel.*` themselves (any depth). `connect` with a key that
|
|
637
|
+
doesn't exist fails and rolls everything back.
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Transactions
|
|
642
|
+
|
|
643
|
+
Several steps, one transaction: all are saved, or none.
|
|
644
|
+
|
|
645
|
+
**Named steps (an object).** The keys name the steps and become the keys of `data`:
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
const { data, error } = await db.transaction((tx) => ({
|
|
649
|
+
user: tx.from('users').upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true }).returning('id'),
|
|
650
|
+
post: tx.from('posts').upsert({ user_id: tx.ref('user.id'), title }, { onConflict: 'user_id,title', ignoreDuplicates: true }).returning('id'),
|
|
651
|
+
link: tx.from('post_labels').upsert({ post_id: tx.ref('post.id'), label_id }, { ignoreDuplicates: true }),
|
|
652
|
+
total: tx.from('posts').eq('user_id', tx.ref('user.id')).count(),
|
|
653
|
+
}))
|
|
654
|
+
data.user[0].id // found or created
|
|
655
|
+
data.link // null (no returning())
|
|
656
|
+
data.total // a number
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
**Array form,** naming steps with `.as()`; `data` is an array in the same order:
|
|
660
|
+
|
|
661
|
+
```ts
|
|
662
|
+
await db.transaction((tx) => [
|
|
663
|
+
tx.from('posts').insert({ title, user_id }).returning('id').as('post'),
|
|
664
|
+
tx.from('comments').update({ body: 'x' }).eq('post_id', tx.ref('post.id')),
|
|
665
|
+
])
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Rules:
|
|
669
|
+
- Steps run top to bottom; `tx.ref('name.column')` reads an earlier step's row, in values or filters.
|
|
670
|
+
A step that returned one row (or `.single()`) is read as `name.column`; with several rows,
|
|
671
|
+
pick one: `tx.ref('posts.0.id')`.
|
|
672
|
+
- Any step can be select, insert, upsert, update, delete or count, with `ops.*` and `rel.*`.
|
|
673
|
+
- Return the steps; don't `await` them inside the callback.
|
|
674
|
+
- Step names are words without dots. Number-like keys (`'1'`) are rejected because JavaScript reorders them.
|
|
675
|
+
- On failure: `error.step` (0-based) and `error.stepName` say which step failed; every step is rolled back.
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## Read replicas
|
|
680
|
+
|
|
681
|
+
When the API server has read replicas (`REPLICA_URLS`), reads can use them.
|
|
682
|
+
Reads use the primary unless you ask: a replica can be a moment behind, so a
|
|
683
|
+
row you just wrote may not be there yet.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
// one query
|
|
687
|
+
await db.from('posts').select('title').eq('published', true).replica()
|
|
688
|
+
|
|
689
|
+
// every read by default; primary() for a read that must see your own write
|
|
690
|
+
const db = createClient(url, key, { readFrom: 'replica' })
|
|
691
|
+
await db.from('users').insert({ name, email })
|
|
692
|
+
await db.from('users').select().eq('email', email).primary()
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
- Only reads (`select()`, `count()`) use a replica. Writes and every step of
|
|
696
|
+
`transaction()` run on the primary; `.replica()` on them is an error.
|
|
697
|
+
- Without a healthy replica (none configured, down, or behind by more than
|
|
698
|
+
`REPLICA_MAX_LAG`) the read runs on the primary instead, with no error. A read
|
|
699
|
+
that fails because the replica went away is retried on the primary.
|
|
700
|
+
- With several replicas, each read goes to the one with the fewest busy connections.
|
|
701
|
+
- The `X-Served-By` response header says who answered: `primary` or `replica-1`, `replica-2`, ...
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
## Raw SQL fragments
|
|
706
|
+
|
|
707
|
+
When the builder has no method for it, `db.raw(sql, bindings?)` adds one SQL
|
|
708
|
+
expression to the query (same as `import { raw }`, and `tx.raw` in
|
|
709
|
+
transactions). It is knex's `whereRaw` / `orderByRaw` / `havingRaw` /
|
|
710
|
+
`knex.raw`: a piece of a query, never a whole statement. `test/raw.test.ts`
|
|
711
|
+
checks these against the test database.
|
|
712
|
+
|
|
713
|
+
```ts
|
|
714
|
+
const n = db.raw('substring(name from 6)::int') // user_42 -> 42
|
|
715
|
+
|
|
716
|
+
// where(): AND (<fragment>)
|
|
717
|
+
await db.from('users').select('name').where(db.raw('substring(name from 6)::int <= ?', [3])).order('name')
|
|
718
|
+
// [{ name: 'user_1' }, { name: 'user_2' }, { name: 'user_3' }]
|
|
719
|
+
|
|
720
|
+
// inside or() / and() / not() it is one condition of the group
|
|
721
|
+
await db.from('users').select('name')
|
|
722
|
+
.or((q) => q.where(db.raw('substring(name from 6)::int = ?', [1])).eq('name', 'user_2'))
|
|
723
|
+
// user_1, user_2
|
|
724
|
+
|
|
725
|
+
// array binding: = any(?)
|
|
726
|
+
await db.from('users').select('name').where(db.raw('lower(trim(email)) = any(?)', [['user_1@example.com', 'user_7@example.com']]))
|
|
727
|
+
|
|
728
|
+
// jsonb ? operator: \? (written '\\?' in JS) is a literal ?
|
|
729
|
+
await db.from('users').where(db.raw('meta \\? ?', ['vip'])).count() // 100
|
|
730
|
+
|
|
731
|
+
// order()
|
|
732
|
+
await db.from('users').select('name').order(n, { ascending: false }).limit(3)
|
|
733
|
+
// user_100, user_99, user_98
|
|
734
|
+
|
|
735
|
+
// select(): extra arguments are raw columns, each needs .as(name); options go last
|
|
736
|
+
await db.from('users').select('name', db.raw('char_length(name)').as('name_len')).eq('name', 'user_42').single()
|
|
737
|
+
// { name: 'user_42', name_len: 7 }
|
|
738
|
+
|
|
739
|
+
// having(), with groupBy()
|
|
740
|
+
await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
|
|
741
|
+
// 33 groups, each n: 2
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
As values in `insert()` / `update()` / `upsert()` rows, and in upsert `merge`,
|
|
745
|
+
where `excluded.col` is the incoming value and `table.col` the stored one:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
await db.from('users').update({ age: db.raw('age + ?', [5]) }).eq('email', email).returning('age') // 10 -> [{ age: 15 }]
|
|
749
|
+
|
|
750
|
+
await db.from('users').upsert({ email, name: 'second', age: 5 }, {
|
|
751
|
+
onConflict: 'email',
|
|
752
|
+
merge: { age: db.raw('CASE WHEN excluded.age IS NOT NULL AND users.age IS NOT NULL THEN users.age + excluded.age ELSE ? END', [100]) },
|
|
753
|
+
})
|
|
754
|
+
// stored age 10 -> 15; name stays (only merge columns change); with age: null -> 100
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
Rules:
|
|
758
|
+
|
|
759
|
+
- Each `?` is one binding, passed as a query parameter, so values are never
|
|
760
|
+
pasted into the SQL. The count must match `bindings`, else a 400 error
|
|
761
|
+
(`raw fragment: 2 placeholders but 1 binding`). Strings, numbers, booleans and
|
|
762
|
+
`null` pass through; an array becomes a Postgres array (cast it if needed:
|
|
763
|
+
`?::text[]`); an object becomes jsonb (`?::jsonb`). A `?` inside `'quoted
|
|
764
|
+
text'` is not a binding.
|
|
765
|
+
- `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator.
|
|
766
|
+
- Fragments see the main table only: its columns, plain or qualified with the
|
|
767
|
+
table's name (`users.age`, `"users".age`), and `excluded.col` in `merge`.
|
|
768
|
+
Related tables are not visible, and `order(raw)` with `referencedTable` is an error.
|
|
769
|
+
- The API key must be allowed to use raw fragments. Otherwise every request
|
|
770
|
+
(or transaction) that contains one gets 403, code `RAW_NOT_ALLOWED`.
|
|
771
|
+
- The server rejects with 400, code `RAW_FRAGMENT`: `;`, `--` and `/*`
|
|
772
|
+
comments, dollar quoting (`$$`), the keywords `select`, `insert`, `update`,
|
|
773
|
+
`delete`, `with`, `union`, `into`, `copy`, `grant`, `drop`, `alter`,
|
|
774
|
+
`create`, `truncate`, `execute`, `do`, `call` outside quoted text, an empty
|
|
775
|
+
fragment or one over 2,000 characters, and more than 100 bindings.
|
|
776
|
+
Functions, `CASE WHEN`, casts, `interval '1 hour'`, operators, `count(*)`
|
|
777
|
+
and `excluded.x` are fine.
|
|
778
|
+
- In the client, `raw()` throws when `sql` is not a non-empty string or
|
|
779
|
+
`bindings` not an array. `select()` with a raw column lacking `.as()`, and
|
|
780
|
+
`where()` / `having()` with something other than `raw()`, resolve to `{ error }`
|
|
781
|
+
without sending.
|
|
782
|
+
|
|
783
|
+
Moving from knex:
|
|
784
|
+
|
|
785
|
+
| knex | arn-db |
|
|
786
|
+
|---|---|
|
|
787
|
+
| `.whereRaw(sql, b)`, `.andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
|
|
788
|
+
| `.orWhereRaw(sql, b)` | `.or((q) => q.eq(...).where(db.raw(sql, b)))` |
|
|
789
|
+
| `.orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending, nullsFirst })` |
|
|
790
|
+
| `.select('id', knex.raw('char_length(title) as n'))` | `.select('id', db.raw('char_length(title)').as('n'))` |
|
|
791
|
+
| `{ total: knex.raw('total + ?', [1]) }` | `{ total: db.raw('total + ?', [1]) }` |
|
|
792
|
+
| `.onConflict('email').merge({ total: knex.raw('…') })` | `.upsert(rows, { onConflict: 'email', merge: { total: db.raw('…') } })` |
|
|
793
|
+
| `.havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
## Errors
|
|
798
|
+
|
|
799
|
+
`error` has `message`, and usually `code` and `details`:
|
|
800
|
+
|
|
801
|
+
| `code` | Meaning | Example |
|
|
802
|
+
|---|---|---|
|
|
803
|
+
| `23505` | duplicate value in a unique column | email already used |
|
|
804
|
+
| `23503` | foreign key: the referenced row doesn't exist | `db.rel.connect(unknownId)` |
|
|
805
|
+
| `23502` | a required (NOT NULL) column is missing | insert without `body` |
|
|
806
|
+
| `22P02` | value has the wrong format | `.eq('id', 'abc')` on a uuid |
|
|
807
|
+
| `42703` | column doesn't exist | `select('nope')` → `column _t0.nope does not exist` (`_t0` is the main table) |
|
|
808
|
+
| `42P10` | `onConflict` columns have no unique constraint | `upsert(…, { onConflict: 'title' })` |
|
|
809
|
+
| `RAW_FRAGMENT` | (400) a `db.raw()` fragment the server refuses | `db.raw('1; drop table x')` |
|
|
810
|
+
| `RAW_NOT_ALLOWED` | (403) the API key may not use raw fragments | |
|
|
811
|
+
| `401` | missing or wrong API key | |
|
|
812
|
+
| `403` | request came from a browser | |
|
|
813
|
+
|
|
814
|
+
Only setup mistakes throw: `createClient()` without a key, `db.raw()` without a
|
|
815
|
+
SQL string, and `db`'s first query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
|
|
816
|
+
|
|
817
|
+
Errors without a code are checks done before the database runs anything, for
|
|
818
|
+
example: `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
## Not supported yet
|
|
823
|
+
|
|
824
|
+
- Sorting main rows by a related table's column (e.g. posts by author name). Sorting *inside* a relation works.
|
|
825
|
+
- OR across tables in one group (e.g. `name = 'x' OR posts.published`): each `or()` group is on one table.
|
|
826
|
+
- Full-text search and calling Postgres functions (`rpc`) as methods. Column
|
|
827
|
+
functions like `lower(trim(email))` and `having` work through
|
|
828
|
+
[raw fragments](#raw-sql-fragments).
|
|
829
|
+
- `db.col()` across tables, and as a value in `insert()` / `update()`.
|