@bakery-framework/orm 2.0.0-alpha.12 → 2.0.0-alpha.14
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/package.json +2 -2
- package/src/adapters/base.ts +108 -0
- package/src/adapters/mysql.ts +9 -4
- package/src/adapters/pgsql.ts +14 -5
- package/src/adapters/sqlite.ts +137 -12
- package/src/orm/mutation.ts +13 -5
- package/src/orm/query.ts +25 -22
- package/src/schema-util.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.14",
|
|
4
4
|
"description": "Bakery database layer: adapters, query builder, schema sync and backup.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"bun": ">=1.4.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@bakery-framework/core": "^2.0.0-alpha.
|
|
57
|
+
"@bakery-framework/core": "^2.0.0-alpha.14"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -73,7 +73,33 @@ export namespace SQLAdapter {
|
|
|
73
73
|
export interface TableDataOptions extends FilterSortOptions {
|
|
74
74
|
page: number
|
|
75
75
|
pageSize: number
|
|
76
|
+
/**
|
|
77
|
+
* A total the caller already has, so the `COUNT(*)` can be skipped.
|
|
78
|
+
*
|
|
79
|
+
* **The count is almost the whole cost of a page.** Measured on a
|
|
80
|
+
* 200,000-row SQLite table with a page size of 50, reading page 101:
|
|
81
|
+
*
|
|
82
|
+
* count, no filter 11.7 ms rows, no filter 0.4 ms
|
|
83
|
+
* count, filtered 51.3 ms rows, filtered 1.6 ms
|
|
84
|
+
*
|
|
85
|
+
* 97% of the work either way. The rows come off an index and stop at the
|
|
86
|
+
* page; the count has to visit every row that matches, and a filtered
|
|
87
|
+
* count visits them a second time after the rows query already did.
|
|
88
|
+
*
|
|
89
|
+
* Opt-in and caller-asserted, because it trades a guarantee for that.
|
|
90
|
+
* Passing a total says "I counted, with exactly these filters" - an
|
|
91
|
+
* adapter cannot check it, and a wrong one shows a wrong row count and a
|
|
92
|
+
* wrong page count. It never affects which rows come back, so the
|
|
93
|
+
* failure mode is a stale readout rather than wrong data. Paging through
|
|
94
|
+
* a table while another writer inserts is exactly when it goes stale, and
|
|
95
|
+
* exactly when re-counting on every page is least worth it.
|
|
96
|
+
*
|
|
97
|
+
* Ignored unless it is a non-negative finite number, so a bad value
|
|
98
|
+
* degrades to the count rather than to a negative page total.
|
|
99
|
+
*/
|
|
100
|
+
knownTotal?: number
|
|
76
101
|
}
|
|
102
|
+
|
|
77
103
|
export interface NameRow {
|
|
78
104
|
name: string
|
|
79
105
|
}
|
|
@@ -202,6 +228,34 @@ export const DEFAULT_STREAM_CHUNK = 500
|
|
|
202
228
|
* offset pagination has, and exactly what `seek()` exists to avoid. What it
|
|
203
229
|
* does buy is the thing people reach for streaming to get: memory bounded by
|
|
204
230
|
* the chunk instead of by the result.
|
|
231
|
+
*
|
|
232
|
+
* **What it costs, and why it is not one number.** The statement is
|
|
233
|
+
* re-executed per chunk, so the total is the chunk count times whatever one
|
|
234
|
+
* window costs — and what one window costs depends entirely on whether the
|
|
235
|
+
* ordering can be served from an index. If it cannot, every chunk sorts the
|
|
236
|
+
* whole result and throws away the offset, which is quadratic in the chunk
|
|
237
|
+
* count. Measured on 100,000 rows, a chunk size of 500, so 200 statements:
|
|
238
|
+
*
|
|
239
|
+
* sqlite ORDER BY the primary key all 180 ms iterate 593 ms 3x
|
|
240
|
+
* sqlite ORDER BY an unindexed column all 275 ms iterate 35,687 ms 130x
|
|
241
|
+
* postgres ORDER BY the primary key all 83 ms iterate 1,590 ms 19x
|
|
242
|
+
* postgres ORDER BY an unindexed column all 126 ms iterate 12,767 ms 101x
|
|
243
|
+
*
|
|
244
|
+
* Thirty-five seconds to walk what `all()` returns in 275 ms. The backlog
|
|
245
|
+
* recorded a flat "393x", which is the right neighbourhood for the unindexed
|
|
246
|
+
* case and says nothing about the case a caller can fix: **order by something
|
|
247
|
+
* indexed and the cost collapses by 40x.**
|
|
248
|
+
*
|
|
249
|
+
* The two dialects differ for different reasons. Postgres pays a network round
|
|
250
|
+
* trip per chunk, which is why its indexed case is 19x where SQLite's is 3x.
|
|
251
|
+
* SQLite pays nothing for the trip and everything for the sort, which is why
|
|
252
|
+
* its unindexed case is the worst of the four.
|
|
253
|
+
*
|
|
254
|
+
* So: reach for this when the result does not fit in memory, not when it is
|
|
255
|
+
* merely large. If it fits, `all()` is between 3 and 130 times faster. If it
|
|
256
|
+
* does not, index the column you order by, and prefer `seek()` where the shape
|
|
257
|
+
* allows it — that is keyset pagination and it has neither the cost nor the
|
|
258
|
+
* skipped-row hazard described above.
|
|
205
259
|
*/
|
|
206
260
|
export function pagedIterate(
|
|
207
261
|
all: SQLAdapter.Executor['all'],
|
|
@@ -303,6 +357,47 @@ export abstract class SQLAdapter {
|
|
|
303
357
|
return DEFAULT_MAX_QUERY_PARAMS
|
|
304
358
|
}
|
|
305
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Whether a caller-supplied total can be used in place of a `COUNT(*)`.
|
|
362
|
+
*
|
|
363
|
+
* One rule in one place, because all three adapters answer it and a dialect
|
|
364
|
+
* that disagreed would be a bug rather than a capability. A static on the
|
|
365
|
+
* class rather than a function in the namespace: the namespace is declared
|
|
366
|
+
* above the class it merges with, so it can hold types and not values.
|
|
367
|
+
*/
|
|
368
|
+
static usableTotal(value: unknown): value is number {
|
|
369
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A cheap value that changes whenever the schema changes, or `null`.
|
|
374
|
+
*
|
|
375
|
+
* The capability is expressed by the return, not by a boolean beside it: a
|
|
376
|
+
* dialect that cannot answer this cheaply returns `null` and its callers
|
|
377
|
+
* fall back to asking the schema directly. That is the default here, so a
|
|
378
|
+
* new adapter is correct without implementing anything.
|
|
379
|
+
*
|
|
380
|
+
* **It must not move for ordinary DML.** A value that changed on every
|
|
381
|
+
* `INSERT` would still be *correct* as a cache key and useless as one,
|
|
382
|
+
* invalidating on the busiest thing a database does. SQLite's
|
|
383
|
+
* `PRAGMA schema_version` has exactly the right semantics, verified rather
|
|
384
|
+
* than assumed: it advances on `CREATE TABLE`, on `ALTER TABLE ... ADD
|
|
385
|
+
* COLUMN` and on `DROP TABLE`, and stays put across an `INSERT`.
|
|
386
|
+
*
|
|
387
|
+
* A method rather than one of the capability *getters* above because it
|
|
388
|
+
* costs a round trip. Those describe the dialect and are answered from
|
|
389
|
+
* nothing; this one asks the server.
|
|
390
|
+
*
|
|
391
|
+
* Postgres and MySQL return `null`. Neither exposes a single counter for
|
|
392
|
+
* this - Postgres has no schema-level equivalent of `schema_version`, and
|
|
393
|
+
* MySQL's `information_schema` would have to be aggregated, which is the
|
|
394
|
+
* expense being avoided. Adding one later is a per-adapter override and
|
|
395
|
+
* needs no change here or in any caller.
|
|
396
|
+
*/
|
|
397
|
+
async schemaFingerprint(): Promise<string | null> {
|
|
398
|
+
return null
|
|
399
|
+
}
|
|
400
|
+
|
|
306
401
|
constructor(
|
|
307
402
|
public readonly driver: SQLAdapter.Driver,
|
|
308
403
|
public readonly filename?: string,
|
|
@@ -1243,6 +1338,19 @@ export class DatabaseStatement {
|
|
|
1243
1338
|
values(...params: any[]) {
|
|
1244
1339
|
return this.connection.execute.values(this.sql, params)
|
|
1245
1340
|
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Walk the result a chunk at a time, holding only the chunk.
|
|
1343
|
+
*
|
|
1344
|
+
* **Not a cursor, and not cheap.** Bun cannot stream, so this pages: the
|
|
1345
|
+
* statement becomes a derived table and is re-executed once per 500 rows.
|
|
1346
|
+
* On 100,000 rows that is 200 statements, and the total depends on whether
|
|
1347
|
+
* the ordering is indexed — 3x `all()` on SQLite ordered by a primary key,
|
|
1348
|
+
* **130x** ordered by a column with no index (35.7 seconds against 275 ms).
|
|
1349
|
+
* The full table and the reasoning are on {@link pagedIterate}.
|
|
1350
|
+
*
|
|
1351
|
+
* Use it when the result does not fit in memory. When it does, `all()` is
|
|
1352
|
+
* faster by between 3 and 130 times.
|
|
1353
|
+
*/
|
|
1246
1354
|
iterate(...params: any[]) {
|
|
1247
1355
|
return this.connection.execute.iterate(this.sql, params)
|
|
1248
1356
|
}
|
package/src/adapters/mysql.ts
CHANGED
|
@@ -332,10 +332,15 @@ export class MySQLAdapter extends SQLAdapter {
|
|
|
332
332
|
new Set(cols.map(c => c.name)),
|
|
333
333
|
)
|
|
334
334
|
const tName = this.quote(tableName)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
335
|
+
// See `TableDataOptions.knownTotal`: the count is almost the whole cost
|
|
336
|
+
// of a page, and a caller that has already counted can say so.
|
|
337
|
+
const reuse = SQLAdapter.usableTotal(options.knownTotal)
|
|
338
|
+
const countRes = reuse
|
|
339
|
+
? null
|
|
340
|
+
: ((await this.query(
|
|
341
|
+
`SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
|
|
342
|
+
).get(...whereParams)) as SQLAdapter.CountRow)
|
|
343
|
+
const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
|
|
339
344
|
const rows = await this.query(
|
|
340
345
|
`SELECT * FROM ${tName}${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
|
341
346
|
).all(
|
package/src/adapters/pgsql.ts
CHANGED
|
@@ -57,7 +57,11 @@ export class PGAdapter extends SQLAdapter {
|
|
|
57
57
|
|
|
58
58
|
private static handleSpecial(
|
|
59
59
|
char: string,
|
|
60
|
-
|
|
60
|
+
// Underscored because it is deliberately unread: the branch that would
|
|
61
|
+
// have used it is the backslash branch, and the comment below is why
|
|
62
|
+
// there is not one. Kept in the signature so the shape matches
|
|
63
|
+
// `handleSpecial` in the other two adapters.
|
|
64
|
+
_nextChar: string | undefined,
|
|
61
65
|
state: PGSQLParserState,
|
|
62
66
|
): string | null {
|
|
63
67
|
// No backslash branch, deliberately. Postgres with
|
|
@@ -333,10 +337,15 @@ export class PGAdapter extends SQLAdapter {
|
|
|
333
337
|
new Set(cols.map(c => c.name)),
|
|
334
338
|
)
|
|
335
339
|
const tName = this.quote(tableName)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
+
// See `TableDataOptions.knownTotal`: the count is almost the whole cost
|
|
341
|
+
// of a page, and a caller that has already counted can say so.
|
|
342
|
+
const reuse = SQLAdapter.usableTotal(options.knownTotal)
|
|
343
|
+
const countRes = reuse
|
|
344
|
+
? null
|
|
345
|
+
: ((await this.query(
|
|
346
|
+
`SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
|
|
347
|
+
).get(...whereParams)) as SQLAdapter.CountRow)
|
|
348
|
+
const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
|
|
340
349
|
const rows = (await this.query(
|
|
341
350
|
`SELECT ctid::text AS rowid, * FROM ${tName}` +
|
|
342
351
|
`${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import { Logger, messageLogger } from '@bakery-framework/core/logger'
|
|
3
4
|
import { Case, Try } from '@bakery-framework/core/utils'
|
|
4
5
|
import { SQL } from 'bun'
|
|
5
6
|
import type * as SyncTypes from '../sync/types'
|
|
6
7
|
import { createExecutor, SQLAdapter } from './base'
|
|
7
8
|
|
|
9
|
+
// Convention 4: logging is data, declared in a table rather than formatted at
|
|
10
|
+
// the call site.
|
|
11
|
+
const MESSAGES = messageLogger(new Logger('database'), {
|
|
12
|
+
JOURNAL_WAL_REFUSED:
|
|
13
|
+
'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*) — running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
|
|
14
|
+
PRAGMA_FAILED:
|
|
15
|
+
'W A SQLite performance pragma was rejected: %r{error}%*. The database works; it is not tuned.',
|
|
16
|
+
} as const)
|
|
17
|
+
|
|
8
18
|
export class SQLiteAdapter extends SQLAdapter {
|
|
9
19
|
// SQLite's standard identifier quote. The base class defaults to MySQL's
|
|
10
20
|
// backtick, which SQLite only tolerates as a compatibility extension.
|
|
@@ -12,6 +22,20 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
12
22
|
|
|
13
23
|
protected readonly sql: SQL
|
|
14
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolves when the performance pragmas have finished, or failed loudly.
|
|
27
|
+
*
|
|
28
|
+
* They are deliberately not awaited by every query the way `ready` is —
|
|
29
|
+
* tuning is not correctness, and making the first statement of every process
|
|
30
|
+
* wait on six round trips to gain nothing is the wrong trade. But `close()`
|
|
31
|
+
* has to wait, because a handle closed underneath an in-flight pragma
|
|
32
|
+
* rejects with `Connection closed`, and that is indistinguishable at the
|
|
33
|
+
* catch from a filesystem genuinely refusing one. A short-lived adapter — a
|
|
34
|
+
* test, a one-shot script — would otherwise warn on every close about a
|
|
35
|
+
* failure that never happened.
|
|
36
|
+
*/
|
|
37
|
+
protected readonly tuned: Promise<unknown> = Promise.resolve()
|
|
38
|
+
|
|
15
39
|
/**
|
|
16
40
|
* Resolves once `foreign_keys` is on, and every query awaits it.
|
|
17
41
|
*
|
|
@@ -151,18 +175,105 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
151
175
|
|
|
152
176
|
if (ownsConnection && filename !== ':memory:') {
|
|
153
177
|
const cacheSize = import.meta.env.THREAD_WORKER ? -1000 : -10000
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
178
|
+
this.tuned = this.sql
|
|
179
|
+
.unsafe('PRAGMA journal_mode = WAL;')
|
|
180
|
+
.then((rows: unknown) => this.confirmWAL(rows, filename))
|
|
157
181
|
.then(() => this.sql.unsafe('PRAGMA synchronous = NORMAL;'))
|
|
158
182
|
.then(() => this.sql.unsafe('PRAGMA temp_store = memory;'))
|
|
159
183
|
.then(() => this.sql.unsafe(`PRAGMA cache_size = ${cacheSize};`))
|
|
160
184
|
.then(() => this.sql.unsafe('PRAGMA busy_timeout = 5000;'))
|
|
161
185
|
.then(() => this.sql.unsafe('PRAGMA mmap_size = 0;'))
|
|
162
|
-
|
|
186
|
+
// Not `catch(() => {})`. Every performance pragma failing silently is
|
|
187
|
+
// how a database ends up running at a fraction of its speed with
|
|
188
|
+
// nothing anywhere saying so, and convention 3 bans the bare form for
|
|
189
|
+
// exactly this. These are tuning, not correctness — `foreign_keys`
|
|
190
|
+
// above is awaited and is allowed to reject — so a failure is a
|
|
191
|
+
// warning rather than a throw.
|
|
192
|
+
.catch((error: Error) => {
|
|
193
|
+
MESSAGES.PRAGMA_FAILED({ error: error.message })
|
|
194
|
+
})
|
|
163
195
|
}
|
|
164
196
|
}
|
|
165
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Waits for the pragma chain before closing the handle. See `tuned`.
|
|
200
|
+
*/
|
|
201
|
+
override async close(): Promise<void> {
|
|
202
|
+
await Try.catch(this.tuned)
|
|
203
|
+
await super.close()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* **WAL first, DELETE only where WAL is actually refused.**
|
|
208
|
+
*
|
|
209
|
+
* The journal mode used to be `platform === 'win32' ? 'DELETE' : 'WAL'`, a
|
|
210
|
+
* rule with no recorded reason. The hypothesis behind it was that a Windows
|
|
211
|
+
* network path cannot host WAL, which is true — WAL needs a shared `-shm`
|
|
212
|
+
* mapping that SMB and WebDAV do not provide — but the rule applied to every
|
|
213
|
+
* Windows install, local disks included, where WAL works.
|
|
214
|
+
*
|
|
215
|
+
* Measured on this machine, four interleaved rounds against a CPU-bound
|
|
216
|
+
* control that stayed flat at 29-34 ms:
|
|
217
|
+
*
|
|
218
|
+
* single autocommit write DELETE 3629 us WAL 36 us 100x
|
|
219
|
+
* 100-row transaction DELETE 3.91 ms WAL 0.13 ms 29x
|
|
220
|
+
*
|
|
221
|
+
* An attempt and a check replaces the rule, which is what makes it safe to
|
|
222
|
+
* change without knowing why it was there. SQLite refuses WAL in two
|
|
223
|
+
* observable ways and both are handled: it returns the *unchanged* mode in
|
|
224
|
+
* the pragma's own result row (an in-memory database answers `memory`), or
|
|
225
|
+
* it throws. Both shapes were reproduced before this was written, so the
|
|
226
|
+
* fallback rests on a measurement rather than on the documentation.
|
|
227
|
+
*
|
|
228
|
+
* The same logic runs on the cache database in
|
|
229
|
+
* `@bakery-framework/core/cache/shared-db`. It is deliberately not shared:
|
|
230
|
+
* that site is synchronous `bun:sqlite` and this one is an async
|
|
231
|
+
* `Bun.SQL`, so the control flow has nothing in common and only the
|
|
232
|
+
* predicate would move — at the cost of a new published export subpath on a
|
|
233
|
+
* surface that was closed on purpose before 2.0.0.
|
|
234
|
+
*/
|
|
235
|
+
private async confirmWAL(rows: unknown, filename: string): Promise<void> {
|
|
236
|
+
const first = Array.isArray(rows) ? rows[0] : undefined
|
|
237
|
+
const answer = String(
|
|
238
|
+
(first as { journal_mode?: unknown } | undefined)?.journal_mode ?? '',
|
|
239
|
+
)
|
|
240
|
+
if (answer.toLowerCase() === 'wal') return
|
|
241
|
+
|
|
242
|
+
const fell = await this.sql.unsafe('PRAGMA journal_mode = DELETE;')
|
|
243
|
+
const mode = Array.isArray(fell)
|
|
244
|
+
? String((fell[0] as { journal_mode?: unknown })?.journal_mode ?? 'unknown')
|
|
245
|
+
: 'unknown'
|
|
246
|
+
MESSAGES.JOURNAL_WAL_REFUSED({
|
|
247
|
+
file: filename,
|
|
248
|
+
answer: answer || '(no row)',
|
|
249
|
+
mode,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `PRAGMA schema_version`, which is a counter SQLite bumps on every schema
|
|
255
|
+
* change and leaves alone for every row change.
|
|
256
|
+
*
|
|
257
|
+
* Measured on a 50-table database: the full introspection the explorer runs
|
|
258
|
+
* per write is **250 statements and 8.28 ms**, and this is **10.4 us** - 795x.
|
|
259
|
+
* The semantics were checked rather than taken from the documentation: the
|
|
260
|
+
* counter advances on `CREATE TABLE`, on `ALTER TABLE ... ADD COLUMN` and on
|
|
261
|
+
* `DROP TABLE`, and does not move for an `INSERT`.
|
|
262
|
+
*
|
|
263
|
+
* Prefixed with the driver and the file so two different SQLite databases
|
|
264
|
+
* cannot collide on the same small integer. A fresh database starts near
|
|
265
|
+
* zero, so "version 3" is a value many of them hold at once.
|
|
266
|
+
*/
|
|
267
|
+
override async schemaFingerprint(): Promise<string | null> {
|
|
268
|
+
const row = (await this.query('PRAGMA schema_version').get()) as
|
|
269
|
+
| { schema_version?: number }
|
|
270
|
+
| null
|
|
271
|
+
| undefined
|
|
272
|
+
const version = row?.schema_version
|
|
273
|
+
if (typeof version !== 'number') return null
|
|
274
|
+
return `sqlite:${this.filename ?? ''}:${version}`
|
|
275
|
+
}
|
|
276
|
+
|
|
166
277
|
private static resolveFilename(rawValue?: string | null): string {
|
|
167
278
|
const envVal = process.env.DATABASE_URL || process.env.SQLITE_PATH
|
|
168
279
|
// `Bakery.dataDir`, not a literal: the default database file has to follow
|
|
@@ -351,17 +462,23 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
351
462
|
|
|
352
463
|
const { page, pageSize } = options
|
|
353
464
|
|
|
465
|
+
// See `TableDataOptions.knownTotal`: the count is 97% of a page's cost,
|
|
466
|
+
// and a caller that has already counted can say so.
|
|
467
|
+
const reuse = SQLAdapter.usableTotal(options.knownTotal)
|
|
468
|
+
|
|
354
469
|
const [countRes, rows] = (await Promise.all([
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
470
|
+
reuse
|
|
471
|
+
? Promise.resolve(null)
|
|
472
|
+
: this.query(`SELECT COUNT(*) as count FROM ${tname}${whereSql}`).get(
|
|
473
|
+
...whereParams,
|
|
474
|
+
),
|
|
358
475
|
this.query(
|
|
359
476
|
`SELECT rowid AS rowid, * FROM ${tname}` +
|
|
360
477
|
`${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
|
361
478
|
).all(...whereParams, pageSize, (page - 1) * pageSize),
|
|
362
|
-
])) as [SQLAdapter.CountRow, any[]]
|
|
479
|
+
])) as [SQLAdapter.CountRow | null, any[]]
|
|
363
480
|
|
|
364
|
-
const totalRows = countRes?.count || 0
|
|
481
|
+
const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
|
|
365
482
|
return {
|
|
366
483
|
rows,
|
|
367
484
|
totalRows,
|
|
@@ -469,9 +586,13 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
469
586
|
const tName = Case.camel(table.name)
|
|
470
587
|
dbConstraints[tName] = {} as SyncTypes.TableConstraints
|
|
471
588
|
|
|
589
|
+
// Bound, not interpolated — the same conversion `hasCol` above
|
|
590
|
+
// records. This one survived it: `getConstraints` runs over every table
|
|
591
|
+
// the database reports, so a name carrying an apostrophe closed the
|
|
592
|
+
// literal and the rest of the statement went with it.
|
|
472
593
|
const cols = (await this.query(
|
|
473
|
-
|
|
474
|
-
).all()) as any[]
|
|
594
|
+
'SELECT * FROM pragma_table_info(?)',
|
|
595
|
+
).all(table.name)) as any[]
|
|
475
596
|
|
|
476
597
|
if (table.type === 'view') {
|
|
477
598
|
const match = table.sql.match(/AS\s+(.*)/is)
|
|
@@ -509,8 +630,12 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
509
630
|
return Object.fromEntries(
|
|
510
631
|
await Promise.all(
|
|
511
632
|
indexes.map(async idx => {
|
|
633
|
+
// Bound for the same reason as `getConstraints` above: an index
|
|
634
|
+
// name comes from `sqlite_master`, not from this codebase.
|
|
512
635
|
const raw = (
|
|
513
|
-
(await this.query(
|
|
636
|
+
(await this.query('SELECT * FROM pragma_index_info(?)').all(
|
|
637
|
+
idx.name,
|
|
638
|
+
)) as any[]
|
|
514
639
|
).map(c => String(c.name))
|
|
515
640
|
return [
|
|
516
641
|
Case.camel(idx.name),
|
package/src/orm/mutation.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
AppDBOptionals as DBOptionals,
|
|
8
8
|
AppDBSchema as DBSchema,
|
|
9
9
|
} from '../schema-registry'
|
|
10
|
-
import { evalOperands, qId } from '../schema-util'
|
|
10
|
+
import { evalOperands, nullComparison, qId } from '../schema-util'
|
|
11
11
|
import { DB } from './query'
|
|
12
12
|
|
|
13
13
|
export namespace Mutation {
|
|
@@ -475,8 +475,12 @@ export namespace Mutation {
|
|
|
475
475
|
if (c.operator === '') {
|
|
476
476
|
parts.push(i === 0 ? left : `${c.connector} ${left}`)
|
|
477
477
|
} else {
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
// `nullComparison` before `evalOperands`, and the order is the point:
|
|
479
|
+
// a null that becomes `IS NULL` must not also bind a parameter.
|
|
480
|
+
const nullOp = nullComparison(c.operator, c.right, c.isRightColumn)
|
|
481
|
+
const clauseStr = nullOp
|
|
482
|
+
? `${left} ${nullOp}`
|
|
483
|
+
: `${left} ${c.operator} ${evalOperands(c.right, params, c.isRightColumn)}`
|
|
480
484
|
parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
|
|
481
485
|
}
|
|
482
486
|
}
|
|
@@ -630,8 +634,12 @@ export namespace Mutation {
|
|
|
630
634
|
if (c.operator === '') {
|
|
631
635
|
parts.push(i === 0 ? left : `${c.connector} ${left}`)
|
|
632
636
|
} else {
|
|
633
|
-
|
|
634
|
-
|
|
637
|
+
// `nullComparison` before `evalOperands`, and the order is the point:
|
|
638
|
+
// a null that becomes `IS NULL` must not also bind a parameter.
|
|
639
|
+
const nullOp = nullComparison(c.operator, c.right, c.isRightColumn)
|
|
640
|
+
const clauseStr = nullOp
|
|
641
|
+
? `${left} ${nullOp}`
|
|
642
|
+
: `${left} ${c.operator} ${evalOperands(c.right, params, c.isRightColumn)}`
|
|
635
643
|
parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
|
|
636
644
|
}
|
|
637
645
|
}
|
package/src/orm/query.ts
CHANGED
|
@@ -2,18 +2,7 @@ import { Case } from '@bakery-framework/core/utils'
|
|
|
2
2
|
import { throws } from '@bakery-framework/core/utils/common'
|
|
3
3
|
import { getActiveDb, txStorage } from '../connection'
|
|
4
4
|
import type { AppDBSchema as DBSchema } from '../schema-registry'
|
|
5
|
-
import {
|
|
6
|
-
evalOperands,
|
|
7
|
-
isSafeIdentifier,
|
|
8
|
-
qId,
|
|
9
|
-
qRaw,
|
|
10
|
-
SQL_FUNCTIONS,
|
|
11
|
-
col as schemaCol,
|
|
12
|
-
ColumnRef as schemaColumnRef,
|
|
13
|
-
OperatorRef as schemaOperatorRef,
|
|
14
|
-
SQLFunctionRef as schemaSQLFunctionRef,
|
|
15
|
-
WindowRef as schemaWindowRef,
|
|
16
|
-
} from '../schema-util'
|
|
5
|
+
import { ColumnRef as schemaColumnRef, OperatorRef as schemaOperatorRef, SQLFunctionRef as schemaSQLFunctionRef, SQL_FUNCTIONS, WindowRef as schemaWindowRef, col as schemaCol, evalOperands, isSafeIdentifier, nullComparison, qId, qRaw } from '../schema-util'
|
|
17
6
|
import { Mutation } from './mutation'
|
|
18
7
|
|
|
19
8
|
export namespace DB {
|
|
@@ -501,6 +490,27 @@ export namespace DB {
|
|
|
501
490
|
])
|
|
502
491
|
}
|
|
503
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Walk the result a chunk at a time, holding only the chunk.
|
|
495
|
+
*
|
|
496
|
+
* **The name promises a cursor and the implementation is paging**, because
|
|
497
|
+
* Bun cannot stream: the statement becomes a derived table and is
|
|
498
|
+
* re-executed once per 500 rows. Two consequences, and the second is the
|
|
499
|
+
* one that surprises people.
|
|
500
|
+
*
|
|
501
|
+
* Chunk boundaries are only stable under a total order, so a row inserted
|
|
502
|
+
* or deleted mid-walk can be seen twice or missed. `seek()` is keyset
|
|
503
|
+
* pagination and has neither problem.
|
|
504
|
+
*
|
|
505
|
+
* And it is slow in proportion to how badly the ordering indexes.
|
|
506
|
+
* Measured on 100,000 rows: 3x `all()` on SQLite ordered by a primary key,
|
|
507
|
+
* **130x** ordered by a column with no index — 35.7 seconds against
|
|
508
|
+
* 275 ms. Postgres pays a round trip per chunk instead and lands at 19x
|
|
509
|
+
* and 101x. The table is on `pagedIterate` in `adapters/base.ts`.
|
|
510
|
+
*
|
|
511
|
+
* Worth it when the result does not fit in memory. Not worth it merely
|
|
512
|
+
* because the result is large.
|
|
513
|
+
*/
|
|
504
514
|
async *iterable(): AsyncIterable<P> {
|
|
505
515
|
const { sql, params } = this.parse()
|
|
506
516
|
for await (const row of getActiveDb()
|
|
@@ -1072,16 +1082,9 @@ export namespace DB {
|
|
|
1072
1082
|
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
1073
1083
|
return `${left} ${op}`
|
|
1074
1084
|
}
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
// passes an explicit null means the SQL spelling of it. Only literal
|
|
1079
|
-
// values: a column reference on the right is a different comparison and
|
|
1080
|
-
// is left alone.
|
|
1081
|
-
if (rightArg === null && !isRightColumn) {
|
|
1082
|
-
if (op === '=') return `${left} IS NULL`
|
|
1083
|
-
if (op === '!=' || op === '<>') return `${left} IS NOT NULL`
|
|
1084
|
-
}
|
|
1085
|
+
// One rule, three callers — see `nullComparison`.
|
|
1086
|
+
const nullOp = nullComparison(op, rightArg, isRightColumn)
|
|
1087
|
+
if (nullOp) return `${left} ${nullOp}`
|
|
1085
1088
|
if (op === 'BETWEEN' && Array.isArray(rightArg)) {
|
|
1086
1089
|
const min = evalOperands(rightArg[0], params, false)
|
|
1087
1090
|
const max = evalOperands(rightArg[1], params, false)
|
package/src/schema-util.ts
CHANGED
|
@@ -465,3 +465,35 @@ export function old(
|
|
|
465
465
|
_transform: transform,
|
|
466
466
|
})
|
|
467
467
|
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The SQL spelling of a comparison against an explicit `null`, or `null` when
|
|
471
|
+
* the comparison is an ordinary one.
|
|
472
|
+
*
|
|
473
|
+
* A bound NULL can never satisfy `=` or `<>`: three-valued logic makes both
|
|
474
|
+
* UNKNOWN for every row, so `where(col, null)` matched nothing while reporting
|
|
475
|
+
* success. On a `SELECT` that is an empty result; on a `DELETE` or an
|
|
476
|
+
* `UPDATE` it is a write that silently does nothing and answers zero changes,
|
|
477
|
+
* which a caller cannot tell from a genuine conflict.
|
|
478
|
+
*
|
|
479
|
+
* **This lives here, not in a `WHERE` builder, because there are three of
|
|
480
|
+
* them.** `formatClause` in `orm/query.ts` and the two `evalWhere` copies in
|
|
481
|
+
* `orm/mutation.ts` each compile a clause, and the copies have now diverged
|
|
482
|
+
* twice: once over the one-argument `where(DB.raw\`…\`)` form, which the
|
|
483
|
+
* comment in `DeleteExecutable.evalWhere` records, and once over this rule,
|
|
484
|
+
* which reached the builder and neither mutation path. Both were silent. One
|
|
485
|
+
* function three callers share cannot drift a third time.
|
|
486
|
+
*
|
|
487
|
+
* A column reference on the right is a different comparison and is left alone.
|
|
488
|
+
*/
|
|
489
|
+
export function nullComparison(
|
|
490
|
+
operator: string,
|
|
491
|
+
right: unknown,
|
|
492
|
+
isRightColumn?: boolean,
|
|
493
|
+
): string | null {
|
|
494
|
+
if (right !== null || isRightColumn) return null
|
|
495
|
+
const op = operator.toUpperCase()
|
|
496
|
+
if (op === '=') return 'IS NULL'
|
|
497
|
+
if (op === '!=' || op === '<>') return 'IS NOT NULL'
|
|
498
|
+
return null
|
|
499
|
+
}
|