@rwillians/exto 0.1.2 → 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/README.md +207 -153
- package/dist/cjs/bun-postgres.js +36 -11
- package/dist/cjs/bun-sqlite.js +9 -3
- package/dist/cjs/index.js +418 -159
- package/dist/cjs/standard-schema.js +10 -1
- package/dist/esm/bun-postgres.js +36 -11
- package/dist/esm/bun-sqlite.js +9 -3
- package/dist/esm/index.js +414 -159
- package/dist/esm/standard-schema.js +8 -0
- package/dist/types/bun-postgres.d.ts +1 -0
- package/dist/types/bun-sqlite.d.ts +1 -0
- package/dist/types/index.d.ts +205 -82
- package/dist/types/standard-schema.d.ts +6 -0
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# exto
|
|
2
2
|
|
|
3
|
-
A teeny tiny, type-safe
|
|
4
|
-
|
|
3
|
+
A teeny tiny, type-safe query builder for [Bun](https://bun.sh), with
|
|
4
|
+
adapters for SQLite and PostgreSQL.
|
|
5
5
|
|
|
6
6
|
```ts
|
|
7
|
-
import { create, from, into, table } from '@rwillians/exto';
|
|
7
|
+
import { create, from, into, table, t } from '@rwillians/exto';
|
|
8
8
|
import * as sqlite from '@rwillians/exto/bun-sqlite';
|
|
9
9
|
|
|
10
|
-
const users = table('users',
|
|
11
|
-
id: t.
|
|
10
|
+
const users = table('users', {
|
|
11
|
+
id: t.uuidv7().generated().primaryKey(),
|
|
12
12
|
name: t.string(),
|
|
13
13
|
email: t.citext().unique(),
|
|
14
|
-
createdAt: t.
|
|
15
|
-
})
|
|
14
|
+
createdAt: t.timestamp().default(() => new Date()),
|
|
15
|
+
});
|
|
16
16
|
|
|
17
17
|
const db = sqlite.connect('./db.sqlite');
|
|
18
18
|
await create.table(users, { ifNotExists: true }).onto(db);
|
|
@@ -22,19 +22,16 @@ await into(users)
|
|
|
22
22
|
.insert(db);
|
|
23
23
|
|
|
24
24
|
const jane = await from(users)
|
|
25
|
-
.where(
|
|
26
|
-
.select(
|
|
25
|
+
.where(users.email, '=', 'JANE@example.com')
|
|
26
|
+
.select({ id: users.id, name: users.name })
|
|
27
27
|
.one(db);
|
|
28
|
-
// → { id:
|
|
28
|
+
// → { id: string, name: string } | null
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
31
|
+
- Columns, operators and values are type-checked and autocompleted.
|
|
32
|
+
- Values are validated before any SQL runs.
|
|
33
33
|
- No singletons: you always pass the database explicitly.
|
|
34
34
|
|
|
35
|
-
> [!NOTE]
|
|
36
|
-
> exto covers single-table queries for now. See [what's not supported yet](#not-supported-yet).
|
|
37
|
-
|
|
38
35
|
|
|
39
36
|
## Install
|
|
40
37
|
|
|
@@ -43,85 +40,96 @@ bun add @rwillians/exto
|
|
|
43
40
|
```
|
|
44
41
|
|
|
45
42
|
|
|
46
|
-
##
|
|
43
|
+
## Connecting
|
|
47
44
|
|
|
48
45
|
```ts
|
|
49
|
-
import
|
|
50
|
-
import
|
|
46
|
+
import * as sqlite from '@rwillians/exto/bun-sqlite';
|
|
47
|
+
import * as pg from '@rwillians/exto/bun-postgres';
|
|
51
48
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
parentId: t.uuid().nullable(),
|
|
55
|
-
status: t.enum(['queued', 'running', 'done']),
|
|
56
|
-
payload: t.json(z.record(z.string(), z.any())),
|
|
57
|
-
attempts: t.integer().default(0),
|
|
58
|
-
scheduledAt: t.datetime(),
|
|
59
|
-
}));
|
|
60
|
-
|
|
61
|
-
type Task = typeof tasks.infer; // a row
|
|
62
|
-
type TaskInsert = typeof tasks.inferForInsert; // what `.values()` takes
|
|
63
|
-
type TaskUpdate = typeof tasks.inferForUpdate; // what `set` takes
|
|
64
|
-
```
|
|
49
|
+
const db = sqlite.connect(':memory:');
|
|
50
|
+
const db = pg.connect('postgres://user:pass@localhost:5432/app');
|
|
65
51
|
|
|
66
|
-
|
|
67
|
-
(
|
|
68
|
-
|
|
69
|
-
| builder | TS type | postgres | sqlite |
|
|
70
|
-
| :--------------------------------- | :-------------------- | :----------------- | :---------------------- |
|
|
71
|
-
| `t.bigint()` | `bigint` | `BIGINT` | `INTEGER` |
|
|
72
|
-
| `t.binary()` | `Uint8Array` | `BYTEA` | `BLOB` |
|
|
73
|
-
| `t.boolean()` | `boolean` | `BOOLEAN` | `INTEGER` |
|
|
74
|
-
| `t.citext()` | `string` | `CITEXT` | `TEXT COLLATE NOCASE` |
|
|
75
|
-
| `t.date()` | `string` (YYYY-MM-DD) | `DATE` | `TEXT` |
|
|
76
|
-
| `t.datetime()` | `Date` | `TIMESTAMPTZ` | `INTEGER` |
|
|
77
|
-
| `t.decimal({ precision, scale }?)` | `number` | `NUMERIC(p, s)` | `NUMERIC` |
|
|
78
|
-
| `t.enum([...values])` | union of values | `VARCHAR(n)` | `TEXT` |
|
|
79
|
-
| `t.float()` | `number` | `DOUBLE PRECISION` | `REAL` |
|
|
80
|
-
| `t.integer()` | `number` | `BIGINT` | `INTEGER` |
|
|
81
|
-
| `t.json(schema)` | schema's output | `JSONB` | `TEXT` |
|
|
82
|
-
| `t.sequence()` | `number` | `BIGINT` identity | `INTEGER` autoincrement |
|
|
83
|
-
| `t.string({ size? })` | `string` | `VARCHAR(size)` | `TEXT` |
|
|
84
|
-
| `t.text()` | `string` | `TEXT` | `TEXT` |
|
|
85
|
-
| `t.uuid()` | `string` | `UUID` | `TEXT` |
|
|
86
|
-
|
|
87
|
-
| modifier | effect |
|
|
88
|
-
| :-------------- | :---------------------------------------------- |
|
|
89
|
-
| `.primaryKey()` | primary key |
|
|
90
|
-
| `.nullable()` | accepts `null`, optional on insert |
|
|
91
|
-
| `.default(v)` | value or function used when omitted on insert |
|
|
92
|
-
| `.unique()` | unique constraint |
|
|
93
|
-
| `.generated()` | value generated on insert (`t.uuid()` → UUIDv7) |
|
|
52
|
+
// log every statement
|
|
53
|
+
const db = sqlite.connect(':memory:', { loggers: [console] });
|
|
54
|
+
```
|
|
94
55
|
|
|
95
|
-
`t.json()` takes any [Standard Schema](https://standardschema.dev) (zod,
|
|
96
|
-
valibot, arktype, ...). `t.citext()` needs `CREATE EXTENSION citext` on
|
|
97
|
-
postgres (see [`exec`](#migrations)). `t.decimal()` values are JS numbers;
|
|
98
|
-
precision and scale only shape postgres' column.
|
|
99
56
|
|
|
100
|
-
|
|
57
|
+
## Tables
|
|
101
58
|
|
|
102
59
|
```ts
|
|
103
|
-
import {
|
|
60
|
+
import { embedded, table, t, type Infer } from '@rwillians/exto';
|
|
104
61
|
|
|
105
|
-
const
|
|
62
|
+
const tasks = table('tasks', {
|
|
63
|
+
id: t.uuidv7().generated().primaryKey(),
|
|
64
|
+
parentId: t.uuidv7().optional(),
|
|
65
|
+
status: t.enum(['queued', 'running', 'done']),
|
|
66
|
+
attempts: t.integer().unsigned().default(0),
|
|
67
|
+
maxAttempts: t.integer().unsigned().default(3),
|
|
68
|
+
payload: embedded.one({
|
|
69
|
+
kind: t.string(),
|
|
70
|
+
params: t.record().default({}),
|
|
71
|
+
}),
|
|
72
|
+
events: embedded.many({
|
|
73
|
+
kind: t.string(),
|
|
74
|
+
timestamp: t.timestamp().default(() => new Date()),
|
|
75
|
+
}),
|
|
76
|
+
scheduledTo: t.timestamp().default(() => new Date()),
|
|
77
|
+
});
|
|
106
78
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}));
|
|
79
|
+
type Task = Infer<'select', typeof tasks>; // a row
|
|
80
|
+
type TaskInsert = Infer<'insert', typeof tasks>; // what `.values()` takes
|
|
81
|
+
type TaskUpdate = Infer<'update', typeof tasks>; // what `set` takes
|
|
111
82
|
```
|
|
112
83
|
|
|
84
|
+
Keys are camelCase in TypeScript and snake_case in the database
|
|
85
|
+
(`scheduledTo` → `scheduled_to`).
|
|
86
|
+
|
|
87
|
+
| builder | TS type | postgres | sqlite |
|
|
88
|
+
| :--------------------------------- | :------------------------ | :----------------- | :---------------------- |
|
|
89
|
+
| `t.bigint()` | `bigint` | `BIGINT` | `INTEGER` |
|
|
90
|
+
| `t.binary()` | `Uint8Array` | `BYTEA` | `BLOB` |
|
|
91
|
+
| `t.boolean()` | `boolean` | `BOOLEAN` | `INTEGER` |
|
|
92
|
+
| `t.citext()` | `string` | `CITEXT` | `TEXT COLLATE NOCASE` |
|
|
93
|
+
| `t.date()` | `string` (YYYY-MM-DD) | `DATE` | `TEXT` |
|
|
94
|
+
| `t.decimal({ precision, scale }?)` | `number` | `NUMERIC(p, s)` | `NUMERIC` |
|
|
95
|
+
| `t.enum([...values])` | union of values | `VARCHAR(n)` | `TEXT` |
|
|
96
|
+
| `t.float()` | `number` | `DOUBLE PRECISION` | `REAL` |
|
|
97
|
+
| `t.integer()` | `number` | `BIGINT` | `INTEGER` |
|
|
98
|
+
| `t.json(schema)` | schema's output | `JSONB` | `TEXT` |
|
|
99
|
+
| `t.record()` | `Record<string, unknown>` | `JSONB` | `TEXT` |
|
|
100
|
+
| `t.sequence()` | `number` | `BIGINT` identity | `INTEGER` autoincrement |
|
|
101
|
+
| `t.string({ size? })` | `string` | `VARCHAR(size)` | `TEXT` |
|
|
102
|
+
| `t.text()` | `string` | `TEXT` | `TEXT` |
|
|
103
|
+
| `t.timestamp()` | `Date` | `TIMESTAMPTZ` | `INTEGER` |
|
|
104
|
+
| `t.uuidv7()` | `string` | `UUID` | `TEXT` |
|
|
105
|
+
| `embedded.one({ ... })` | an object | `JSONB` | `TEXT` |
|
|
106
|
+
| `embedded.many({ ... })` | an array of objects | `JSONB` | `TEXT` |
|
|
107
|
+
|
|
108
|
+
| modifier | effect |
|
|
109
|
+
| :-------------- | :-------------------------------------------- |
|
|
110
|
+
| `.primaryKey()` | primary key |
|
|
111
|
+
| `.optional()` | accepts `null`, optional on insert |
|
|
112
|
+
| `.default(v)` | value or function used when omitted on insert |
|
|
113
|
+
| `.unique()` | unique constraint |
|
|
114
|
+
| `.generated()` | a UUIDv7 generated on insert (`t.uuidv7()`) |
|
|
115
|
+
| `.unsigned()` | rejects negatives (`t.integer()`) |
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
`t.json()` takes any [Standard Schema](https://standardschema.dev) (zod,
|
|
118
|
+
valibot, arktype, ...). `t.citext()` needs `CREATE EXTENSION citext` on
|
|
119
|
+
postgres (see [migrations](#migrations)).
|
|
120
|
+
|
|
121
|
+
### Custom types
|
|
115
122
|
|
|
116
123
|
```ts
|
|
117
|
-
import
|
|
118
|
-
import
|
|
124
|
+
import { defineType } from '@rwillians/exto';
|
|
125
|
+
import { z } from 'zod';
|
|
119
126
|
|
|
120
|
-
const
|
|
121
|
-
const db = pg.connect('postgres://user:pass@localhost:5432/app');
|
|
127
|
+
const email = () => defineType({ primitive: 'STRING', schema: z.email() });
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
const users = table('users', {
|
|
130
|
+
id: t.uuidv7().generated().primaryKey(),
|
|
131
|
+
email: email(),
|
|
132
|
+
});
|
|
125
133
|
```
|
|
126
134
|
|
|
127
135
|
|
|
@@ -138,52 +146,48 @@ const migrate = defineMigrations({
|
|
|
138
146
|
},
|
|
139
147
|
'0002': async (db) => {
|
|
140
148
|
await create.table(tasks).onto(db);
|
|
141
|
-
await create.index(tasks, ['status', '
|
|
149
|
+
await create.index(tasks, ['status', 'scheduledTo']).onto(db);
|
|
142
150
|
},
|
|
143
151
|
});
|
|
144
152
|
|
|
145
153
|
await migrate(db);
|
|
146
154
|
```
|
|
147
155
|
|
|
148
|
-
Migrations run in key order, each in its own transaction, and only once
|
|
156
|
+
Migrations run in key order, each in its own transaction, and only once:
|
|
157
|
+
applied ones are tracked in the `schema_migrations` table.
|
|
149
158
|
|
|
150
159
|
|
|
151
160
|
## Insert
|
|
152
161
|
|
|
153
162
|
```ts
|
|
154
|
-
import { into } from '@rwillians/exto';
|
|
163
|
+
import { into, parse } from '@rwillians/exto';
|
|
155
164
|
|
|
156
|
-
const
|
|
165
|
+
const task: TaskInsert = { status: 'queued', payload: { kind: 'Prune' }, events: [] };
|
|
157
166
|
|
|
158
167
|
const inserted: Task[] = await into(tasks)
|
|
159
|
-
.values(
|
|
160
|
-
.values([
|
|
168
|
+
.values(task)
|
|
169
|
+
.values([task, task])
|
|
161
170
|
.insert(db);
|
|
162
171
|
|
|
163
|
-
await into(tasks).values(
|
|
164
|
-
await into(tasks).values(
|
|
172
|
+
await into(tasks).values(task).insert(db, { returning: ['id', tasks.status] }); // → Pick<Task, 'id' | 'status'>[]
|
|
173
|
+
await into(tasks).values(task).insert(db, { returning: false }); // → undefined
|
|
174
|
+
|
|
175
|
+
// validate untrusted input, filling in defaults
|
|
176
|
+
const row: TaskInsert = parse(tasks, JSON.parse(body));
|
|
165
177
|
```
|
|
166
178
|
|
|
167
179
|
`insert`, `update` and `delete` all take `returning`. It defaults to `true`,
|
|
168
180
|
except for `delete`, which defaults to `false`:
|
|
169
181
|
|
|
170
|
-
| `returning`
|
|
171
|
-
|
|
|
172
|
-
| `true`
|
|
173
|
-
| `[...keys]` | only those
|
|
174
|
-
| `false`
|
|
182
|
+
| `returning` | result |
|
|
183
|
+
| :--------------------- | :---------- |
|
|
184
|
+
| `true` | full rows |
|
|
185
|
+
| `[...keys or columns]` | only those |
|
|
186
|
+
| `false` | `undefined` |
|
|
175
187
|
|
|
176
188
|
### On conflict
|
|
177
189
|
|
|
178
190
|
```ts
|
|
179
|
-
const users = table('users', t => ({
|
|
180
|
-
id: t.sequence().primaryKey(),
|
|
181
|
-
email: t.citext().unique(),
|
|
182
|
-
name: t.string(),
|
|
183
|
-
logins: t.integer().default(0),
|
|
184
|
-
banned: t.boolean().default(false),
|
|
185
|
-
}));
|
|
186
|
-
|
|
187
191
|
const rows = [{ email: 'jane@example.com', name: 'Jane' }];
|
|
188
192
|
|
|
189
193
|
// skip rows that conflict
|
|
@@ -212,53 +216,128 @@ await into(users).values(rows).insert(db, {
|
|
|
212
216
|
|
|
213
217
|
## Select
|
|
214
218
|
|
|
219
|
+
A table is shaped like its columns: `tasks.status` references a column.
|
|
220
|
+
|
|
215
221
|
```ts
|
|
216
222
|
import { expr, from } from '@rwillians/exto';
|
|
217
223
|
|
|
218
|
-
const
|
|
219
|
-
.where(
|
|
220
|
-
.where(
|
|
221
|
-
.orderBy([
|
|
224
|
+
const due: Task[] = await from(tasks)
|
|
225
|
+
.where(tasks.status, '=', 'queued')
|
|
226
|
+
.where(tasks.scheduledTo, '<', new Date()) // multiple `where`s are ANDed
|
|
227
|
+
.orderBy([tasks.scheduledTo, [tasks.attempts, 'DESC']])
|
|
222
228
|
.limit(10)
|
|
223
229
|
.offset(20)
|
|
224
230
|
.all(db);
|
|
225
231
|
|
|
226
|
-
const queued = from(tasks).where(
|
|
232
|
+
const queued = from(tasks).where(tasks.status, '=', 'queued');
|
|
227
233
|
|
|
228
|
-
await queued.one(db);
|
|
229
|
-
await queued.
|
|
230
|
-
await queued.
|
|
231
|
-
await queued.count(db); // → number
|
|
234
|
+
await queued.one(db); // → Task | null
|
|
235
|
+
await queued.exists(db); // → boolean
|
|
236
|
+
await queued.count(db); // → number
|
|
232
237
|
```
|
|
233
238
|
|
|
234
|
-
| operator | value
|
|
235
|
-
| :------------------------------------- |
|
|
236
|
-
| `'='` `'!='` `'<'` `'<='` `'>'` `'>='` | the column's type
|
|
237
|
-
| `'
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
| operator | value |
|
|
240
|
+
| :------------------------------------- | :---------------- |
|
|
241
|
+
| `'='` `'!='` `'<'` `'<='` `'>'` `'>='` | the column's type |
|
|
242
|
+
| `'is'` `'is not'` | `null` |
|
|
243
|
+
|
|
244
|
+
### Picking columns
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
await from(tasks).select({ id: tasks.id, when: tasks.scheduledTo }).all(db);
|
|
248
|
+
// → { id: string, when: Date }[]
|
|
249
|
+
|
|
250
|
+
await from(tasks).select({ ...tasks, parent: tasks.parentId }).all(db);
|
|
251
|
+
// → (Task & { parent: string | null })[]
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Comparing columns
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
await from(tasks).where(tasks.attempts, '<', tasks.maxAttempts).all(db);
|
|
258
|
+
```
|
|
240
259
|
|
|
241
260
|
### OR and grouping
|
|
242
261
|
|
|
243
262
|
```ts
|
|
244
263
|
await from(tasks)
|
|
245
264
|
.where(expr.or([
|
|
246
|
-
expr(
|
|
265
|
+
expr(tasks.status, '=', 'queued'),
|
|
247
266
|
expr.and([
|
|
248
|
-
expr(
|
|
249
|
-
expr(
|
|
267
|
+
expr(tasks.status, '=', 'running'),
|
|
268
|
+
expr(tasks.attempts, '>', 3),
|
|
250
269
|
]),
|
|
251
270
|
]))
|
|
252
271
|
.all(db);
|
|
253
|
-
// WHERE ("status" = ? OR ("status" = ? AND "attempts" > ?))
|
|
254
272
|
```
|
|
255
273
|
|
|
274
|
+
### Embedded fields
|
|
275
|
+
|
|
276
|
+
Fields of `embedded.one()` columns are referenced like columns:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
await from(tasks)
|
|
280
|
+
.where(tasks.payload.kind, '=', 'Prune')
|
|
281
|
+
.select({ kind: tasks.payload.kind, params: tasks.payload.params })
|
|
282
|
+
.all(db);
|
|
283
|
+
// → { kind: string, params: Record<string, unknown> }[]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Raw SQL fragments
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import { f } from '@rwillians/exto';
|
|
290
|
+
|
|
291
|
+
await from(tasks).where(f`upper(${tasks.payload.kind})`, '=', 'PRUNE').all(db);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Interpolated columns are rendered in place, and any other value is passed as
|
|
295
|
+
a parameter.
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
## Group by
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
await from(tasks)
|
|
302
|
+
.where(tasks.status, '!=', 'queued')
|
|
303
|
+
.groupBy([tasks.status])
|
|
304
|
+
.select({
|
|
305
|
+
status: tasks.status,
|
|
306
|
+
tasks: expr.count(tasks.id),
|
|
307
|
+
attempts: expr.sum(tasks.attempts),
|
|
308
|
+
})
|
|
309
|
+
.all(db);
|
|
310
|
+
// → { status: Task['status'], tasks: number, attempts: number }[]
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
## Joins
|
|
315
|
+
|
|
316
|
+
Alias a table to query it more than once:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const parent = tasks.as('parent');
|
|
320
|
+
const child = tasks.as('child');
|
|
321
|
+
|
|
322
|
+
await from(parent)
|
|
323
|
+
.leftJoin(child, expr(parent.id, '=', child.parentId))
|
|
324
|
+
.where(parent.parentId, 'is', null)
|
|
325
|
+
.groupBy([parent.id])
|
|
326
|
+
.select({ ...parent, children: expr.count(child.id) })
|
|
327
|
+
.all(db);
|
|
328
|
+
// → (Task & { children: number })[]
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Columns of a left-joined table are nullable: selecting `child.id` gives
|
|
332
|
+
`string | null`. A query only takes columns of the tables it reads from, so
|
|
333
|
+
referencing `child` before joining it is a type error.
|
|
334
|
+
|
|
256
335
|
|
|
257
336
|
## Update
|
|
258
337
|
|
|
259
338
|
```ts
|
|
260
339
|
const updated: Pick<Task, 'id'>[] = await from(tasks)
|
|
261
|
-
.where(
|
|
340
|
+
.where(tasks.status, '=', 'queued')
|
|
262
341
|
.update(db, {
|
|
263
342
|
set: { status: 'running' },
|
|
264
343
|
inc: { attempts: 1 },
|
|
@@ -270,12 +349,12 @@ const updated: Pick<Task, 'id'>[] = await from(tasks)
|
|
|
270
349
|
## Delete
|
|
271
350
|
|
|
272
351
|
```ts
|
|
273
|
-
await from(tasks).where(
|
|
274
|
-
await from(tasks).where(
|
|
352
|
+
await from(tasks).where(tasks.status, '=', 'done').delete(db); // → undefined
|
|
353
|
+
await from(tasks).where(tasks.status, '=', 'done').delete(db, { returning: true }); // → Task[]
|
|
275
354
|
```
|
|
276
355
|
|
|
277
|
-
`update` and `delete` refuse `orderBy`, `limit` and
|
|
278
|
-
touch more rows than the query suggests.
|
|
356
|
+
`update` and `delete` refuse joins, `groupBy`, `orderBy`, `limit` and
|
|
357
|
+
`offset`, so they never touch more rows than the query suggests.
|
|
279
358
|
|
|
280
359
|
|
|
281
360
|
## Transactions
|
|
@@ -295,53 +374,28 @@ The transaction rolls back if the callback throws.
|
|
|
295
374
|
|
|
296
375
|
## Validation
|
|
297
376
|
|
|
298
|
-
Rows and changes are validated against the columns before any SQL runs:
|
|
299
|
-
|
|
300
377
|
```ts
|
|
301
378
|
import { ValidationError } from '@rwillians/exto';
|
|
302
379
|
|
|
303
|
-
const people = table('people',
|
|
304
|
-
id: t.
|
|
380
|
+
const people = table('people', {
|
|
381
|
+
id: t.uuidv7().generated().primaryKey(),
|
|
305
382
|
name: t.string({ size: 5 }),
|
|
306
383
|
age: t.integer(),
|
|
307
|
-
})
|
|
384
|
+
});
|
|
308
385
|
|
|
309
386
|
try {
|
|
310
387
|
await into(people).values({ name: 'Jane Doe', age: 1.5 }).insert(db);
|
|
311
388
|
} catch (error) {
|
|
312
389
|
if (error instanceof ValidationError) {
|
|
313
390
|
error.message; // Failed validation:
|
|
314
|
-
// -
|
|
315
|
-
// -
|
|
316
|
-
error.issues; // [{ message, path: [
|
|
391
|
+
// - name: must be at most 5 characters long
|
|
392
|
+
// - age: must be an integer
|
|
393
|
+
error.issues; // [{ message, path: ['name'] }, ...]
|
|
317
394
|
}
|
|
318
395
|
}
|
|
319
396
|
```
|
|
320
397
|
|
|
321
398
|
|
|
322
|
-
## Not supported yet
|
|
323
|
-
|
|
324
|
-
exto is young, and it focuses on queries against a single table. Every query
|
|
325
|
-
reads from one table only, so joins aren't possible yet. Neither is nesting one
|
|
326
|
-
query inside another, whether as a subquery (`WHERE id IN (SELECT ...)`) or as
|
|
327
|
-
a common table expression (`WITH ... SELECT`).
|
|
328
|
-
|
|
329
|
-
Selects return columns as they're stored: `.select()` takes column keys only,
|
|
330
|
-
so computed values such as `attempts * 2` can't be selected. Aggregation stops
|
|
331
|
-
at `.count()`, too. There's no `.groupBy()` to group rows, and no `.having()`
|
|
332
|
-
to filter those groups.
|
|
333
|
-
|
|
334
|
-
All of these are planned. Until then, `db.execute()` runs raw SQL and returns
|
|
335
|
-
the rows as the driver gives them, without exto's decoding:
|
|
336
|
-
|
|
337
|
-
```ts
|
|
338
|
-
const totals = await db.execute(
|
|
339
|
-
'SELECT status, COUNT(*) AS total FROM tasks GROUP BY status',
|
|
340
|
-
[],
|
|
341
|
-
);
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
|
|
345
399
|
## License
|
|
346
400
|
|
|
347
401
|
[MIT](LICENSE)
|
package/dist/cjs/bun-postgres.js
CHANGED
|
@@ -2,29 +2,49 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.dialect = exports.connect = void 0;
|
|
4
4
|
const bun_1 = require("bun");
|
|
5
|
-
const utils_1 = require("./utils");
|
|
6
5
|
const TYPES = {
|
|
7
6
|
BIGINT: () => 'BIGINT',
|
|
8
7
|
BINARY: () => 'BYTEA',
|
|
9
8
|
BOOLEAN: () => 'BOOLEAN',
|
|
10
9
|
CITEXT: () => 'CITEXT',
|
|
11
10
|
DATE: () => 'DATE',
|
|
12
|
-
|
|
13
|
-
DECIMAL: ({ precision, scale }) => [precision, scale].every(Number.isInteger) ? `NUMERIC(${precision}, ${scale})`
|
|
14
|
-
: [precision, scale].some(Number.isInteger) ? (0, utils_1.raise)('precision and scale are mutually inclusive, either you provide both or neither')
|
|
15
|
-
: 'NUMERIC',
|
|
11
|
+
DECIMAL: ({ precision, scale }) => precision !== undefined && scale !== undefined ? `NUMERIC(${precision}, ${scale})` : 'NUMERIC',
|
|
16
12
|
FLOAT: () => 'DOUBLE PRECISION',
|
|
17
13
|
INTEGER: () => 'BIGINT',
|
|
18
14
|
JSON: () => 'JSONB',
|
|
19
15
|
SEQUENCE: () => 'BIGINT',
|
|
20
16
|
STRING: ({ length = 255 }) => `VARCHAR(${length})`,
|
|
21
17
|
TEXT: () => 'TEXT',
|
|
18
|
+
TIMESTAMP: () => 'TIMESTAMPTZ',
|
|
22
19
|
UUID: () => 'UUID',
|
|
23
20
|
};
|
|
24
21
|
const identity = () => ({
|
|
25
22
|
encode: (value) => value,
|
|
26
23
|
decode: (value) => value,
|
|
27
24
|
});
|
|
25
|
+
// Bun.SQL reads bigints (BIGINT columns, COUNT and SUM) and NUMERICs as
|
|
26
|
+
// strings
|
|
27
|
+
const numeric = {
|
|
28
|
+
encode: (value) => value,
|
|
29
|
+
decode: (value) => Number(value),
|
|
30
|
+
};
|
|
31
|
+
// `#>>` reads text, so fields of other types are cast back to theirs
|
|
32
|
+
const CASTS = {
|
|
33
|
+
BIGINT: 'BIGINT',
|
|
34
|
+
BOOLEAN: 'BOOLEAN',
|
|
35
|
+
DECIMAL: 'NUMERIC',
|
|
36
|
+
FLOAT: 'DOUBLE PRECISION',
|
|
37
|
+
INTEGER: 'DOUBLE PRECISION',
|
|
38
|
+
SEQUENCE: 'BIGINT',
|
|
39
|
+
};
|
|
40
|
+
const jsonPath = (path) => `'{${path.map(key => `"${key.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\'', '\'\'')}"`).join(',')}}'`;
|
|
41
|
+
const json = (sql, path, type) => {
|
|
42
|
+
if (type === 'JSON')
|
|
43
|
+
return `${sql} #> ${jsonPath(path)}`;
|
|
44
|
+
const text = `${sql} #>> ${jsonPath(path)}`;
|
|
45
|
+
return CASTS[type] ? `(${text})::${CASTS[type]}` : text;
|
|
46
|
+
};
|
|
47
|
+
// Bun.SQL sends objects and arrays as JSON, and reads JSONB already parsed
|
|
28
48
|
const dialect = {
|
|
29
49
|
name: 'postgres',
|
|
30
50
|
placeholder: (index) => `$${index}`,
|
|
@@ -32,20 +52,21 @@ const dialect = {
|
|
|
32
52
|
generated: (col) => col.type === 'SEQUENCE'
|
|
33
53
|
? 'GENERATED ALWAYS AS IDENTITY'
|
|
34
54
|
: undefined,
|
|
55
|
+
json,
|
|
35
56
|
codecs: {
|
|
36
57
|
BIGINT: { encode: (value) => value, decode: (value) => BigInt(value) },
|
|
37
|
-
BINARY:
|
|
58
|
+
BINARY: { encode: (value) => value, decode: (value) => new Uint8Array(value) },
|
|
38
59
|
BOOLEAN: identity(),
|
|
39
60
|
CITEXT: identity(),
|
|
40
61
|
DATE: { encode: (value) => value, decode: (value) => value instanceof Date ? value.toISOString().slice(0, 10) : value },
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
INTEGER: { encode: (value) => value, decode: (value) => Number(value) },
|
|
62
|
+
DECIMAL: numeric,
|
|
63
|
+
FLOAT: numeric,
|
|
64
|
+
INTEGER: numeric,
|
|
45
65
|
JSON: identity(),
|
|
46
|
-
SEQUENCE:
|
|
66
|
+
SEQUENCE: numeric,
|
|
47
67
|
STRING: identity(),
|
|
48
68
|
TEXT: identity(),
|
|
69
|
+
TIMESTAMP: { encode: (value) => value, decode: (value) => new Date(value) },
|
|
49
70
|
UUID: identity(),
|
|
50
71
|
},
|
|
51
72
|
};
|
|
@@ -63,6 +84,10 @@ class BunPostgres {
|
|
|
63
84
|
async execute(sql, params) {
|
|
64
85
|
return await this.sql.unsafe(sql, params);
|
|
65
86
|
}
|
|
87
|
+
async close() {
|
|
88
|
+
await this.sql.close();
|
|
89
|
+
}
|
|
90
|
+
// nested transactions are savepoints of the outer one
|
|
66
91
|
async transaction(fn) {
|
|
67
92
|
const callback = (tx) => fn(new BunPostgres(tx, this.loggers, true));
|
|
68
93
|
const result = this.inTransaction
|
package/dist/cjs/bun-sqlite.js
CHANGED
|
@@ -8,7 +8,6 @@ const TYPES = {
|
|
|
8
8
|
BOOLEAN: 'INTEGER',
|
|
9
9
|
CITEXT: 'TEXT COLLATE NOCASE',
|
|
10
10
|
DATE: 'TEXT',
|
|
11
|
-
DATETIME: 'INTEGER',
|
|
12
11
|
DECIMAL: 'NUMERIC',
|
|
13
12
|
FLOAT: 'REAL',
|
|
14
13
|
INTEGER: 'INTEGER',
|
|
@@ -16,6 +15,7 @@ const TYPES = {
|
|
|
16
15
|
SEQUENCE: 'INTEGER',
|
|
17
16
|
STRING: 'TEXT',
|
|
18
17
|
TEXT: 'TEXT',
|
|
18
|
+
TIMESTAMP: 'INTEGER',
|
|
19
19
|
UUID: 'TEXT',
|
|
20
20
|
};
|
|
21
21
|
const identity = () => ({
|
|
@@ -28,6 +28,8 @@ const numeric = {
|
|
|
28
28
|
encode: (value) => value,
|
|
29
29
|
decode: (value) => Number(value),
|
|
30
30
|
};
|
|
31
|
+
// sqlite's JSON paths quote each key, so that any key is a valid one
|
|
32
|
+
const jsonPath = (path) => `'$${path.map(key => `."${key.replaceAll('"', '\\"').replaceAll('\'', '\'\'')}"`).join('')}'`;
|
|
31
33
|
const dialect = {
|
|
32
34
|
name: 'sqlite',
|
|
33
35
|
placeholder: () => '?',
|
|
@@ -39,13 +41,13 @@ const dialect = {
|
|
|
39
41
|
throw new Error(`SQLite can only generate sequences for primary keys, column "${col.name}" isn't one`);
|
|
40
42
|
return undefined;
|
|
41
43
|
},
|
|
44
|
+
json: (sql, path, type) => `${sql} ${type === 'JSON' ? '->' : '->>'} ${jsonPath(path)}`,
|
|
42
45
|
codecs: {
|
|
43
46
|
BIGINT: { encode: (value) => value, decode: (value) => BigInt(value) },
|
|
44
|
-
BINARY:
|
|
47
|
+
BINARY: { encode: (value) => value, decode: (value) => new Uint8Array(value) },
|
|
45
48
|
BOOLEAN: { encode: (value) => (value ? 1 : 0), decode: (value) => Number(value) === 1 },
|
|
46
49
|
CITEXT: identity(),
|
|
47
50
|
DATE: identity(),
|
|
48
|
-
DATETIME: { encode: (value) => value.getTime(), decode: (value) => new Date(Number(value)) },
|
|
49
51
|
DECIMAL: numeric,
|
|
50
52
|
FLOAT: numeric,
|
|
51
53
|
INTEGER: numeric,
|
|
@@ -53,6 +55,7 @@ const dialect = {
|
|
|
53
55
|
SEQUENCE: numeric,
|
|
54
56
|
STRING: identity(),
|
|
55
57
|
TEXT: identity(),
|
|
58
|
+
TIMESTAMP: { encode: (value) => value.getTime(), decode: (value) => new Date(Number(value)) },
|
|
56
59
|
UUID: identity(),
|
|
57
60
|
},
|
|
58
61
|
};
|
|
@@ -71,6 +74,9 @@ class BunSQLite {
|
|
|
71
74
|
// without safe integers, bun:sqlite rounds anything beyond 2^53
|
|
72
75
|
return this.conn.prepare(sql).safeIntegers(true).all(...params);
|
|
73
76
|
}
|
|
77
|
+
async close() {
|
|
78
|
+
this.conn.close();
|
|
79
|
+
}
|
|
74
80
|
// bun:sqlite's own `transaction()` is synchronous and would commit before an
|
|
75
81
|
// async callback settles, hence the manual BEGIN/COMMIT (SAVEPOINT if nested)
|
|
76
82
|
async transaction(fn) {
|