@bakery-framework/orm 1.0.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.
@@ -0,0 +1,692 @@
1
+ import { Try } from '@bakery-framework/core/utils'
2
+ import { throws } from '@bakery-framework/core/utils/common'
3
+ import { DEFAULT_MAX_QUERY_PARAMS, type SQLAdapter } from '../adapters'
4
+ import { getActiveDb, txStorage } from '../connection'
5
+ import type {
6
+ AppViews,
7
+ AppDBOptionals as DBOptionals,
8
+ AppDBSchema as DBSchema,
9
+ } from '../schema-registry'
10
+ import { evalOperands, qId } from '../schema-util'
11
+ import { DB } from './query'
12
+
13
+ export namespace Mutation {
14
+ /**
15
+ * What may be written to: a declared table that is not a view.
16
+ *
17
+ * There used to be a `| (string & {})` member here, for autocomplete on the
18
+ * literals while still accepting any string. It also made the rest of the
19
+ * type decorative — `Exclude<…, AppViews>` never rejected anything, so a
20
+ * `DB.Insert.into('some_view')` compiled and failed at the database instead,
21
+ * and so did a typo'd table name.
22
+ *
23
+ * Strict now, and it costs nothing when no schema is registered: `DBSchema`
24
+ * falls back to `MapOf<MapOf<any>>` there, whose `keyof` is `string`, so an
25
+ * unregistered app is exactly as permissive as before. Registering a schema
26
+ * is what opts you in.
27
+ */
28
+ export type Tables = Exclude<keyof DBSchema, AppViews>
29
+ export type MapOf<T> = Record<string, T>
30
+
31
+ export type ValidOptionals<T extends keyof DBSchema> =
32
+ T extends keyof DBOptionals
33
+ ? Extract<DBOptionals[T], keyof DBSchema[T]>
34
+ : never
35
+
36
+ export type Prettify<T> = { [K in keyof T]: T[K] } & {}
37
+
38
+ /**
39
+ * `RETURNING` takes an identifier list and is interpolated, not bound — the
40
+ * same position `orderBy` and `groupBy` guard with `safeColumn`. It was the
41
+ * one identifier writer on Insert/Update/Delete with no guard at all, so
42
+ * `.returning('* FROM users; DROP TABLE t --')` was emitted verbatim.
43
+ *
44
+ * Validated at the call site rather than in `parse()` so a bad list fails
45
+ * where it was written, which is what `orderBy` does with its direction.
46
+ */
47
+ function safeReturning(cols: string): string {
48
+ return String(cols)
49
+ .split(',')
50
+ .map(part => DB.safeColumn(part.trim()))
51
+ .join(', ')
52
+ }
53
+
54
+ export type InsertSchema<T extends Tables> = T extends keyof DBSchema
55
+ ? Prettify<
56
+ Omit<DBSchema[T], ValidOptionals<T>> &
57
+ Partial<Pick<DBSchema[T], ValidOptionals<T>>>
58
+ >
59
+ : MapOf<unknown>
60
+
61
+ export type UpdateSchema<T extends Tables> = Partial<InsertSchema<T>>
62
+
63
+ export type ColumnTarget<T extends Tables> = T extends keyof DBSchema
64
+ ? keyof DBSchema[T] & string
65
+ : string
66
+
67
+ export type QualifiedColumnTarget<T extends Tables> = T extends keyof DBSchema
68
+ ?
69
+ | `${Extract<T, string>}.${Extract<keyof DBSchema[T], string>}`
70
+ | ColumnTarget<T>
71
+ : string
72
+
73
+ /**
74
+ * What a write returns — the adapter's own result, not a copy of it.
75
+ *
76
+ * It *was* a copy: an identical `{lastInsertRowid, changes}` declared here as
77
+ * well as on `SQLAdapter`. Identical today is the whole problem — that is the
78
+ * state `SQLAdapter.ColumnConstraint` was in before it fell behind
79
+ * `sync/types.ts` and started erasing fields at the cast.
80
+ */
81
+ export type RunResult = SQLAdapter.RunResult
82
+
83
+ export class Insert<T extends Tables = any> {
84
+ constructor(private _table: string) {}
85
+ static into<T extends Tables>(table: T): Insert<T> {
86
+ return new Insert(table as string)
87
+ }
88
+
89
+ /**
90
+ * The rows to insert — as a spread, or as one array.
91
+ *
92
+ * Both forms exist because the variadic signature alone made the obvious
93
+ * call wrong in a way nothing announced: `values(rows)` bound the *array*
94
+ * as a single record, and the only symptom was
95
+ * `table big has no column named 0`. An array is never a record, so the two
96
+ * forms cannot be confused, and anything that is neither — a mixed
97
+ * `values(rows, extra)`, a primitive — is rejected by name rather than
98
+ * turned into columns called `0` and `1`.
99
+ */
100
+ values(records: InsertSchema<T>[]): InsertExecutable
101
+ values(...records: InsertSchema<T>[]): InsertExecutable
102
+ values(...args: (InsertSchema<T> | InsertSchema<T>[])[]): InsertExecutable {
103
+ const records =
104
+ args.length === 1 && Array.isArray(args[0])
105
+ ? (args[0] as InsertSchema<T>[])
106
+ : (args as InsertSchema<T>[])
107
+
108
+ for (const record of records) {
109
+ if (
110
+ record === null ||
111
+ typeof record !== 'object' ||
112
+ Array.isArray(record)
113
+ ) {
114
+ throws(
115
+ 'values() takes records: values(row), values(rowA, rowB) or ' +
116
+ 'values(rows). Got a ' +
117
+ (Array.isArray(record) ? 'nested array' : typeof record) +
118
+ ' — if you meant to pass an array of rows, pass it as the only ' +
119
+ 'argument.',
120
+ )
121
+ }
122
+ }
123
+
124
+ return new InsertExecutable(this._table, records as MapOf<unknown>[])
125
+ }
126
+ }
127
+
128
+ export class InsertExecutable {
129
+ private _returning?: string
130
+ private _conflict?: { cols: string[]; update: string[] | null }
131
+
132
+ constructor(
133
+ private _table: string,
134
+ private _records: MapOf<unknown>[],
135
+ ) {}
136
+
137
+ returning(cols: string = '*'): this {
138
+ this._returning = safeReturning(cols)
139
+ return this
140
+ }
141
+
142
+ /**
143
+ * Insert, or update the row that is already there.
144
+ *
145
+ * `cols` names the unique columns that decide "already there" — a primary
146
+ * key or a unique index. Without an upsert the only way to express this is
147
+ * to check and then branch, which is a **race**: two requests both see no
148
+ * row and both insert.
149
+ *
150
+ * DB.Insert.into('users').values({ email, name }).upsert(['email'])
151
+ *
152
+ * By default every inserted column except the conflict columns is
153
+ * overwritten. Pass a second argument to narrow that — `upsert(['email'],
154
+ * ['name'])` leaves everything else as it was — or an empty array for
155
+ * insert-if-absent, which becomes `DO NOTHING`.
156
+ *
157
+ * MySQL ignores `cols` because `ON DUPLICATE KEY UPDATE` fires on *any*
158
+ * unique key and takes no conflict target. They are still required, since
159
+ * Postgres and SQLite cannot express the statement without them and a
160
+ * schema that works on one dialect should work on all three.
161
+ */
162
+ upsert(cols: string[], update?: string[]): this {
163
+ if (!cols.length) throws('upsert() needs at least one conflict column')
164
+ this._conflict = { cols, update: update ?? null }
165
+ return this
166
+ }
167
+
168
+ /** The column list: every record's keys, unioned, in first-seen order. */
169
+ private columnKeys(): string[] {
170
+ const keySet = new Set<string>()
171
+ for (const record of this._records) {
172
+ for (const key of Object.keys(record)) {
173
+ keySet.add(key)
174
+ }
175
+ }
176
+ return Array.from(keySet)
177
+ }
178
+
179
+ /**
180
+ * How many records fit in one statement, under the adapter's ceiling.
181
+ *
182
+ * The ceiling comes from the adapter because only it knows its dialect, but
183
+ * a statement can be *rendered* without a connection — `parse()` is how the
184
+ * tests read the SQL, and the stub adapter in `orm.test.ts` implements two
185
+ * members. An unreachable or silent adapter falls back to the same default
186
+ * the base class publishes rather than making `parse()` require a database.
187
+ */
188
+ private batchSize(columnCount: number): number {
189
+ const declared = Try.return(
190
+ () => Number(getActiveDb().maxQueryParams),
191
+ Number.NaN,
192
+ )
193
+ const ceiling =
194
+ Number.isFinite(declared) && declared > 0
195
+ ? declared
196
+ : DEFAULT_MAX_QUERY_PARAMS
197
+ return Math.max(1, Math.floor(ceiling / Math.max(1, columnCount)))
198
+ }
199
+
200
+ private statement(
201
+ records: MapOf<unknown>[],
202
+ keys: string[],
203
+ ): { sql: string; params: any[] } {
204
+ const columns = keys.map(k => qId(k)).join(', ')
205
+ const placeholderGroup = `(${Array(keys.length).fill('?').join(', ')})`
206
+ const placeholders = Array(records.length)
207
+ .fill(placeholderGroup)
208
+ .join(', ')
209
+
210
+ const params = records.flatMap(record => keys.map(k => record[k] ?? null))
211
+
212
+ const retSql = this._returning ? ` RETURNING ${this._returning}` : ''
213
+ // Rebuilt per batch, not hoisted: each batch is a whole statement, so an
214
+ // upsert whose conflict clause only rode on the first one would upsert
215
+ // 10,922 rows and then raise a unique violation on the next batch.
216
+ const conflictSql = this.conflictClause(keys)
217
+
218
+ return {
219
+ sql: `INSERT INTO ${qId(this._table)} (${columns}) VALUES ${placeholders}${conflictSql}${retSql}`,
220
+ params,
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The insert as one statement per batch — the form the executors run.
226
+ *
227
+ * A single `INSERT … VALUES (…),(…),…` carries three parameters per row, so
228
+ * it stops being a legal statement somewhere around eleven thousand rows.
229
+ * Past that the drivers do not report a limit, they report a *wrapped*
230
+ * count (`expected 54464 values, received 120000`) or `too many SQL
231
+ * variables`, neither of which names the actual problem. Batching under the
232
+ * adapter's ceiling is the only way `values(rows)` can mean what it reads
233
+ * like for an arbitrary `rows`.
234
+ *
235
+ * The column list is computed once, across every record, so every batch
236
+ * inserts the same columns in the same order — a per-batch union would
237
+ * change the shape of the statement halfway through the insert.
238
+ *
239
+ * **`RETURNING` is accumulated, not refused.** Refusing is the
240
+ * safe-looking option and the wrong one: the batches run sequentially
241
+ * inside one transaction, so concatenating each batch's rows in batch order
242
+ * reproduces the sequence a single statement would have produced. Ordering
243
+ * *within* a batch is whatever the dialect gives — Postgres does not
244
+ * promise `RETURNING` follows `VALUES` order — but that is equally true
245
+ * unchunked, so batching neither adds nor removes a guarantee. Refusing
246
+ * would have cost the main reason to write `.values(rows).returning('id')`
247
+ * at all: getting the generated ids of a bulk import back.
248
+ */
249
+ parseAll(): { sql: string; params: any[] }[] {
250
+ if (this._records.length === 0) throws('Empty insert')
251
+ const keys = this.columnKeys()
252
+ const size = this.batchSize(keys.length)
253
+
254
+ if (this._records.length <= size) {
255
+ return [this.statement(this._records, keys)]
256
+ }
257
+
258
+ const batches: { sql: string; params: any[] }[] = []
259
+ for (let i = 0; i < this._records.length; i += size) {
260
+ batches.push(this.statement(this._records.slice(i, i + size), keys))
261
+ }
262
+ return batches
263
+ }
264
+
265
+ /**
266
+ * The insert as one statement.
267
+ *
268
+ * Throws rather than returning the first batch when the records do not fit
269
+ * in one: a caller holding `{sql, params}` executes it themselves, and
270
+ * quietly handing back a third of their rows is the failure mode this whole
271
+ * change exists to remove. `parseAll()` is the honest answer for that case.
272
+ */
273
+ parse(): { sql: string; params: any[] } {
274
+ const batches = this.parseAll()
275
+ if (batches.length > 1) {
276
+ throws(
277
+ `Insert of ${this._records.length} records needs ${batches.length} ` +
278
+ `statements to stay under the parameter ceiling; parse() returns ` +
279
+ `one. Use run()/array()/fetch(), which batch inside a single ` +
280
+ `transaction, or parseAll() for the statements themselves.`,
281
+ )
282
+ }
283
+ return batches[0]!
284
+ }
285
+
286
+ /**
287
+ * Run every batch, in one transaction when there is more than one.
288
+ *
289
+ * The transaction is what keeps a batched insert meaning what the unbatched
290
+ * one meant: all rows or none. It is opened only when a batch boundary
291
+ * exists — one statement is already atomic — and only when the caller is
292
+ * not already inside `DB.transaction`, where the outer transaction is
293
+ * already the atomic unit.
294
+ *
295
+ * That second condition is now an economy rather than a requirement: since
296
+ * `SQLAdapter.transaction` nests through `SAVEPOINT`, wrapping anyway would
297
+ * work. It would just buy a savepoint per bulk insert that can never roll
298
+ * back independently of the transaction enclosing it.
299
+ */
300
+ private async runBatches<R>(
301
+ batches: { sql: string; params: any[] }[],
302
+ each: (db: SQLAdapter, sql: string, params: any[]) => Promise<R>,
303
+ ): Promise<R[]> {
304
+ const exec = async (db: SQLAdapter) => {
305
+ const results: R[] = []
306
+ // Sequential on purpose: they share one connection, and a batch that
307
+ // fails has to leave the ones after it unattempted.
308
+ for (const batch of batches) {
309
+ results.push(await each(db, batch.sql, batch.params))
310
+ }
311
+ return results
312
+ }
313
+
314
+ const db = getActiveDb()
315
+ if (batches.length === 1 || txStorage.getStore()) return exec(db)
316
+ return db.transaction(tx => exec(tx))
317
+ }
318
+
319
+ // `execute` directly rather than `query(sql).run(...params)`: the
320
+ // statement-level API spreads its parameters as arguments, and a batch
321
+ // carries tens of thousands of them.
322
+ private static all(db: SQLAdapter, sql: string, params: any[]) {
323
+ return Promise.resolve(db.execute.all(sql, params))
324
+ }
325
+
326
+ /**
327
+ * The upsert clause, in the dialect of the active connection.
328
+ *
329
+ * Two shapes, not three: Postgres and SQLite share
330
+ * `ON CONFLICT (…) DO UPDATE SET col = excluded.col`, while MySQL spells it
331
+ * `ON DUPLICATE KEY UPDATE col = VALUES(col)` and takes no conflict target
332
+ * at all — it fires on whichever unique key was violated.
333
+ *
334
+ * Every identifier goes through `qId`, and no value is interpolated: the
335
+ * new row's values are already bound as the INSERT's parameters, and both
336
+ * dialects refer back to them by name rather than repeating them.
337
+ */
338
+ private conflictClause(insertedKeys: string[]): string {
339
+ if (!this._conflict) return ''
340
+ const { cols, update } = this._conflict
341
+
342
+ // Default: everything inserted except the columns that identify the row.
343
+ const targets = (
344
+ update ?? insertedKeys.filter(k => !cols.includes(k))
345
+ ).filter(k => insertedKeys.includes(k))
346
+
347
+ // The adapter spells it. This used to branch on `driver === 'mysql'`
348
+ // here, which put one dialect's syntax in the shared query builder — the
349
+ // thing every other difference (quote character, placeholder ceiling,
350
+ // date expression, foreign-key clause) is kept out of it for.
351
+ return getActiveDb().upsertClause(cols, targets)
352
+ }
353
+
354
+ async array<R = any>(): Promise<R[]> {
355
+ const perBatch = await this.runBatches(
356
+ this.parseAll(),
357
+ InsertExecutable.all,
358
+ )
359
+ // Batch order is insertion order, so the concatenation is the sequence a
360
+ // single statement would have returned.
361
+ return perBatch.flatMap(rows => (rows || []) as R[])
362
+ }
363
+
364
+ async fetch<R = any>(): Promise<R | undefined> {
365
+ // Every batch still runs — `fetch()` means "insert, and hand me a row
366
+ // back", not "insert the first batch". `Executor.get` is defined as
367
+ // `all(…)[0]`, so this is the same row it would have produced.
368
+ const rows = await this.array<R>()
369
+ return (rows[0] as R) || undefined
370
+ }
371
+
372
+ first = this.fetch
373
+
374
+ async run(): Promise<RunResult> {
375
+ const results = await this.runBatches(
376
+ this.parseAll(),
377
+ (db, sql, params) => Promise.resolve(db.execute.run(sql, params)),
378
+ )
379
+ if (results.length === 1) return results[0]!
380
+
381
+ const changes = results.reduce((n, r) => n + Number(r?.changes ?? 0), 0)
382
+ // `changes` sums, because it answers "how many rows did this insert
383
+ // write". `lastInsertRowid` cannot sum, and the dialects do not agree
384
+ // what it means for a multi-row insert — so the adapter says which end of
385
+ // the batched run carries its answer, rather than this file knowing.
386
+ const pick =
387
+ getActiveDb().batchInsertIdPosition === 'first'
388
+ ? results[0]!
389
+ : results[results.length - 1]!
390
+ return { lastInsertRowid: pick?.lastInsertRowid ?? null, changes }
391
+ }
392
+
393
+ then<TR1 = RunResult, TR2 = never>(
394
+ onf?: ((v: RunResult) => TR1 | PromiseLike<TR1>) | null,
395
+ onr?: ((r: any) => TR2 | PromiseLike<TR2>) | null,
396
+ ): Promise<TR1 | TR2> {
397
+ return this.run().then(onf, onr)
398
+ }
399
+ }
400
+
401
+ export class Update<T extends Tables = any> {
402
+ constructor(private _table: string) {}
403
+ static table<T extends Tables>(table: T): Update<T> {
404
+ return new Update(table as string)
405
+ }
406
+
407
+ set(data: UpdateSchema<T>): UpdateWithWhere<T> {
408
+ return new UpdateWithWhere(this._table, data as MapOf<unknown>)
409
+ }
410
+ }
411
+
412
+ export class UpdateWithWhere<T extends Tables = any> {
413
+ constructor(
414
+ private _table: string,
415
+ private _data: MapOf<unknown>,
416
+ ) {}
417
+
418
+ where<C extends QualifiedColumnTarget<T>>(
419
+ column: C,
420
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
421
+ ): UpdateExecutable<T>
422
+ where(column: any, valueOrRef?: any): UpdateExecutable<T> {
423
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
424
+ return new UpdateExecutable(this._table, this._data, parsed)
425
+ }
426
+ }
427
+
428
+ export class UpdateExecutable<T extends Tables = any> {
429
+ private _clauses: Array<{
430
+ connector: 'AND' | 'OR'
431
+ left: any
432
+ operator: string
433
+ right: any
434
+ isRightColumn?: boolean
435
+ }> = []
436
+
437
+ constructor(
438
+ private _table: string,
439
+ private _data: MapOf<unknown>,
440
+ initialWhere: {
441
+ left: any
442
+ operator: string
443
+ right: any
444
+ isRightColumn?: boolean
445
+ },
446
+ ) {
447
+ this._clauses.push({ connector: 'AND', ...initialWhere })
448
+ }
449
+
450
+ and<C extends QualifiedColumnTarget<T>>(
451
+ column: C,
452
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
453
+ ): this
454
+ and(column: any, valueOrRef?: any): this {
455
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
456
+ this._clauses.push({ connector: 'AND', ...parsed })
457
+ return this
458
+ }
459
+
460
+ or<C extends QualifiedColumnTarget<T>>(
461
+ column: C,
462
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
463
+ ): this
464
+ or(column: any, valueOrRef?: any): this {
465
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
466
+ this._clauses.push({ connector: 'OR', ...parsed })
467
+ return this
468
+ }
469
+
470
+ private evalWhere(params: any[]): string {
471
+ const parts: string[] = []
472
+ for (let i = 0; i < this._clauses.length; i++) {
473
+ const c = this._clauses[i]!
474
+ const left = evalOperands(c.left, params, true)
475
+ if (c.operator === '') {
476
+ parts.push(i === 0 ? left : `${c.connector} ${left}`)
477
+ } else {
478
+ const right = evalOperands(c.right, params, c.isRightColumn)
479
+ const clauseStr = `${left} ${c.operator} ${right}`
480
+ parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
481
+ }
482
+ }
483
+ return parts.join(' ')
484
+ }
485
+
486
+ private _returning?: string
487
+
488
+ returning(cols: string = '*'): this {
489
+ this._returning = safeReturning(cols)
490
+ return this
491
+ }
492
+
493
+ parse(): { sql: string; params: any[] } {
494
+ const params: any[] = []
495
+ const setClauses = Object.keys(this._data)
496
+ .map(key => {
497
+ params.push(this._data[key])
498
+ return `${qId(key)} = ?`
499
+ })
500
+ .join(', ')
501
+
502
+ const whereSql = this.evalWhere(params)
503
+ const retSql = this._returning ? ` RETURNING ${this._returning}` : ''
504
+ return {
505
+ sql: `UPDATE ${qId(this._table)} SET ${setClauses} WHERE ${whereSql}${retSql}`,
506
+ params,
507
+ }
508
+ }
509
+
510
+ async array<R = any>(): Promise<R[]> {
511
+ const { sql, params } = this.parse()
512
+ const results = (await getActiveDb()
513
+ .query(sql)
514
+ .all(...params)) as R[]
515
+ return results || []
516
+ }
517
+
518
+ async fetch<R = any>(): Promise<R | undefined> {
519
+ const { sql, params } = this.parse()
520
+ const result = await getActiveDb()
521
+ .query(sql)
522
+ .get(...params)
523
+ return (result as R) || undefined
524
+ }
525
+
526
+ first = this.fetch
527
+
528
+ async exists(): Promise<boolean> {
529
+ const params: any[] = []
530
+ const whereSql = this.evalWhere(params)
531
+ const result = await getActiveDb()
532
+ .query(`SELECT 1 FROM ${qId(this._table)} WHERE ${whereSql} LIMIT 1`)
533
+ .get(...params)
534
+ return !!result
535
+ }
536
+
537
+ async run(): Promise<RunResult> {
538
+ const { sql, params } = this.parse()
539
+ return await getActiveDb()
540
+ .query(sql)
541
+ .run(...params)
542
+ }
543
+
544
+ then<TR1 = RunResult, TR2 = never>(
545
+ onf?: ((v: RunResult) => TR1 | PromiseLike<TR1>) | null,
546
+ onr?: ((r: any) => TR2 | PromiseLike<TR2>) | null,
547
+ ): Promise<TR1 | TR2> {
548
+ return this.run().then(onf, onr)
549
+ }
550
+ }
551
+
552
+ export class Delete<T extends Tables = any> {
553
+ constructor(private _table: string) {}
554
+ static from<T extends Tables>(table: T): Delete<T> {
555
+ return new Delete(table as string)
556
+ }
557
+
558
+ where<C extends QualifiedColumnTarget<T>>(
559
+ column: C,
560
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
561
+ ): DeleteExecutable<T>
562
+ where(column: any, valueOrRef?: any): DeleteExecutable<T> {
563
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
564
+ return new DeleteExecutable(this._table, parsed)
565
+ }
566
+ }
567
+
568
+ export class DeleteExecutable<T extends Tables = any> {
569
+ private _returning?: string
570
+
571
+ private _clauses: Array<{
572
+ connector: 'AND' | 'OR'
573
+ left: any
574
+ operator: string
575
+ right: any
576
+ isRightColumn?: boolean
577
+ }> = []
578
+
579
+ constructor(
580
+ private _table: string,
581
+ initialWhere: {
582
+ left: any
583
+ operator: string
584
+ right: any
585
+ isRightColumn?: boolean
586
+ },
587
+ ) {
588
+ this._clauses.push({ connector: 'AND', ...initialWhere })
589
+ }
590
+
591
+ returning(cols: string = '*'): this {
592
+ this._returning = safeReturning(cols)
593
+ return this
594
+ }
595
+
596
+ and<C extends QualifiedColumnTarget<T>>(
597
+ column: C,
598
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
599
+ ): this
600
+ and(column: any, valueOrRef?: any): this {
601
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
602
+ this._clauses.push({ connector: 'AND', ...parsed })
603
+ return this
604
+ }
605
+
606
+ or<C extends QualifiedColumnTarget<T>>(
607
+ column: C,
608
+ valueOrRef?: DB.WhereValue<QualifiedColumnTarget<T>>,
609
+ ): this
610
+ or(column: any, valueOrRef?: any): this {
611
+ const parsed = DB.parseWhereArgs(column, valueOrRef)
612
+ this._clauses.push({ connector: 'OR', ...parsed })
613
+ return this
614
+ }
615
+
616
+ private evalWhere(params: any[]): string {
617
+ const parts: string[] = []
618
+ for (let i = 0; i < this._clauses.length; i++) {
619
+ const c = this._clauses[i]!
620
+ const left = evalOperands(c.left, params, true)
621
+ // `parseWhereArgs` emits `operator: ''` for the one-argument form —
622
+ // `where(DB.raw`…`)` or `where(<subquery>)` — where the left operand
623
+ // is the whole condition and there is no right one. This branch is a
624
+ // copy of `UpdateExecutable.evalWhere` above, which is a copy of
625
+ // `formatClause` in query.ts; it was the copy that never got it. The
626
+ // failure was silent rather than loud: `evalOperands(undefined)` binds
627
+ // rather than throwing, so the clause came out as
628
+ // `(LOWER(email) = ?) ?` with a stray `undefined` pushed onto
629
+ // `params` *ahead* of every later clause's value, shifting them all.
630
+ if (c.operator === '') {
631
+ parts.push(i === 0 ? left : `${c.connector} ${left}`)
632
+ } else {
633
+ const right = evalOperands(c.right, params, c.isRightColumn)
634
+ const clauseStr = `${left} ${c.operator} ${right}`
635
+ parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
636
+ }
637
+ }
638
+ return parts.join(' ')
639
+ }
640
+
641
+ parse(): { sql: string; params: any[] } {
642
+ const params: any[] = []
643
+ const whereSql = this.evalWhere(params)
644
+ const retSql = this._returning ? ` RETURNING ${this._returning}` : ''
645
+ return {
646
+ sql: `DELETE FROM ${qId(this._table)} WHERE ${whereSql}${retSql}`,
647
+ params,
648
+ }
649
+ }
650
+
651
+ async array<R = any>(): Promise<R[]> {
652
+ const { sql, params } = this.parse()
653
+ const results = (await getActiveDb()
654
+ .query(sql)
655
+ .all(...params)) as R[]
656
+ return results || []
657
+ }
658
+
659
+ async fetch<R = any>(): Promise<R | undefined> {
660
+ const { sql, params } = this.parse()
661
+ const result = await getActiveDb()
662
+ .query(sql)
663
+ .get(...params)
664
+ return (result as R) || undefined
665
+ }
666
+
667
+ first = this.fetch
668
+
669
+ async exists(): Promise<boolean> {
670
+ const params: any[] = []
671
+ const whereSql = this.evalWhere(params)
672
+ const result = await getActiveDb()
673
+ .query(`SELECT 1 FROM ${qId(this._table)} WHERE ${whereSql} LIMIT 1`)
674
+ .get(...params)
675
+ return !!result
676
+ }
677
+
678
+ async run(): Promise<RunResult> {
679
+ const { sql, params } = this.parse()
680
+ return await getActiveDb()
681
+ .query(sql)
682
+ .run(...params)
683
+ }
684
+
685
+ then<TR1 = RunResult, TR2 = never>(
686
+ onf?: ((v: RunResult) => TR1 | PromiseLike<TR1>) | null,
687
+ onr?: ((r: any) => TR2 | PromiseLike<TR2>) | null,
688
+ ): Promise<TR1 | TR2> {
689
+ return this.run().then(onf, onr)
690
+ }
691
+ }
692
+ }