@strav/database 0.1.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.
Files changed (39) hide show
  1. package/package.json +46 -0
  2. package/src/database/database.ts +181 -0
  3. package/src/database/domain/context.ts +84 -0
  4. package/src/database/domain/index.ts +17 -0
  5. package/src/database/domain/manager.ts +274 -0
  6. package/src/database/domain/wrapper.ts +105 -0
  7. package/src/database/index.ts +32 -0
  8. package/src/database/introspector.ts +446 -0
  9. package/src/database/migration/differ.ts +308 -0
  10. package/src/database/migration/file_generator.ts +125 -0
  11. package/src/database/migration/index.ts +18 -0
  12. package/src/database/migration/runner.ts +145 -0
  13. package/src/database/migration/sql_generator.ts +378 -0
  14. package/src/database/migration/tracker.ts +86 -0
  15. package/src/database/migration/types.ts +189 -0
  16. package/src/database/query_builder.ts +1034 -0
  17. package/src/database/seeder.ts +31 -0
  18. package/src/helpers/identity.ts +12 -0
  19. package/src/helpers/index.ts +1 -0
  20. package/src/index.ts +5 -0
  21. package/src/orm/base_model.ts +427 -0
  22. package/src/orm/decorators.ts +290 -0
  23. package/src/orm/index.ts +3 -0
  24. package/src/providers/database_provider.ts +25 -0
  25. package/src/providers/index.ts +1 -0
  26. package/src/schema/database_representation.ts +124 -0
  27. package/src/schema/define_association.ts +60 -0
  28. package/src/schema/define_schema.ts +46 -0
  29. package/src/schema/domain_discovery.ts +83 -0
  30. package/src/schema/field_builder.ts +160 -0
  31. package/src/schema/field_definition.ts +69 -0
  32. package/src/schema/index.ts +22 -0
  33. package/src/schema/naming.ts +19 -0
  34. package/src/schema/postgres.ts +109 -0
  35. package/src/schema/registry.ts +187 -0
  36. package/src/schema/representation_builder.ts +482 -0
  37. package/src/schema/type_builder.ts +115 -0
  38. package/src/schema/types.ts +35 -0
  39. package/tsconfig.json +5 -0
@@ -0,0 +1,1034 @@
1
+ import { DateTime } from 'luxon'
2
+ import { toSnakeCase } from '@stravigor/kernel/helpers/strings'
3
+ import type BaseModel from '../orm/base_model'
4
+ import { ModelNotFoundError } from '@stravigor/kernel/exceptions/errors'
5
+ import { getReferenceMeta, getAssociates, getCasts } from '../orm/decorators'
6
+ import type { ReferenceMetadata, AssociateMetadata, CastDefinition } from '../orm/decorators'
7
+ import { hydrateRow } from '../orm/base_model'
8
+ import Database from './database'
9
+ import { getCurrentSchema, hasSchemaContext } from './domain/context'
10
+
11
+ type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Pagination
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface PaginationMeta {
18
+ page: number
19
+ perPage: number
20
+ total: number
21
+ lastPage: number
22
+ from: number
23
+ to: number
24
+ }
25
+
26
+ export interface PaginationResult<T> {
27
+ data: T[]
28
+ meta: PaginationMeta
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Internal types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type WhereBoolean = 'AND' | 'OR'
36
+
37
+ type WhereClause =
38
+ | { kind: 'comparison'; column: string; operator: string; value: unknown; boolean: WhereBoolean }
39
+ | { kind: 'in' | 'not_in'; column: string; values: unknown[]; boolean: WhereBoolean }
40
+ | { kind: 'null' | 'not_null'; column: string; boolean: WhereBoolean }
41
+ | { kind: 'between'; column: string; low: unknown; high: unknown; boolean: WhereBoolean }
42
+ | { kind: 'raw'; sql: string; params: unknown[]; boolean: WhereBoolean }
43
+ | { kind: 'group'; clauses: WhereClause[]; boolean: WhereBoolean }
44
+
45
+ interface JoinClause {
46
+ type: 'LEFT JOIN' | 'INNER JOIN' | 'RIGHT JOIN'
47
+ table: string
48
+ alias: string
49
+ leftCol: string
50
+ operator: string
51
+ rightCol: string
52
+ }
53
+
54
+ interface OrderByClause {
55
+ column: string
56
+ direction: 'ASC' | 'DESC'
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // JoinBuilder
61
+ // ---------------------------------------------------------------------------
62
+
63
+ class JoinBuilder<T extends BaseModel> {
64
+ constructor(
65
+ private parent: QueryBuilder<T>,
66
+ private model: typeof BaseModel,
67
+ private joinType: 'LEFT JOIN' | 'INNER JOIN' | 'RIGHT JOIN'
68
+ ) {}
69
+
70
+ on(leftColumn: string, operator: string, rightColumn: string): QueryBuilder<T> {
71
+ return this.parent._addJoin({
72
+ type: this.joinType,
73
+ table: this.model.tableName,
74
+ alias: this.model.name,
75
+ leftCol: leftColumn,
76
+ operator,
77
+ rightCol: rightColumn,
78
+ })
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // QueryBuilder
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export default class QueryBuilder<T extends BaseModel> {
87
+ private modelClass: ModelStatic<T>
88
+ private primaryTable: string
89
+ private models: Map<string, typeof BaseModel> = new Map()
90
+ private trx: any | null = null
91
+
92
+ private wheres: WhereClause[] = []
93
+ private havings: WhereClause[] = []
94
+ private joins: JoinClause[] = []
95
+ private orderBys: OrderByClause[] = []
96
+ private groupBys: string[] = []
97
+ private selectColumns: string[] = []
98
+ private limitValue: number | null = null
99
+ private offsetValue: number | null = null
100
+ private isDistinct: boolean = false
101
+ private includeTrashed: boolean = false
102
+ private isOnlyTrashed: boolean = false
103
+ private eagerLoads: string[] = []
104
+
105
+ constructor(modelClass: ModelStatic<T>, trx?: any) {
106
+ this.modelClass = modelClass
107
+ this.primaryTable = modelClass.tableName
108
+ this.models.set(modelClass.name, modelClass)
109
+ this.trx = trx ?? null
110
+ }
111
+
112
+ /** The SQL connection — transaction if provided, otherwise the default pool. */
113
+ private get connection() {
114
+ // If we have a transaction, it should already be tenant-aware if needed
115
+ if (this.trx) {
116
+ return this.trx
117
+ }
118
+
119
+ // Use the tenant-aware SQL from the model's database
120
+ return this.modelClass.db.sql
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // WHERE
125
+ // ---------------------------------------------------------------------------
126
+
127
+ where(
128
+ column: string | ((q: QueryBuilder<T>) => void),
129
+ operatorOrValue?: unknown,
130
+ value?: unknown
131
+ ): this {
132
+ if (typeof column === 'function') {
133
+ const sub = new QueryBuilder<T>(this.modelClass)
134
+ column(sub)
135
+ this.wheres.push({ kind: 'group', clauses: sub.wheres, boolean: 'AND' })
136
+ return this
137
+ }
138
+ if (value === undefined) {
139
+ this.wheres.push({
140
+ kind: 'comparison',
141
+ column,
142
+ operator: '=',
143
+ value: operatorOrValue,
144
+ boolean: 'AND',
145
+ })
146
+ } else {
147
+ this.wheres.push({
148
+ kind: 'comparison',
149
+ column,
150
+ operator: operatorOrValue as string,
151
+ value,
152
+ boolean: 'AND',
153
+ })
154
+ }
155
+ return this
156
+ }
157
+
158
+ whereIn(column: string, values: unknown[]): this {
159
+ this.wheres.push({ kind: 'in', column, values, boolean: 'AND' })
160
+ return this
161
+ }
162
+
163
+ whereNotIn(column: string, values: unknown[]): this {
164
+ this.wheres.push({ kind: 'not_in', column, values, boolean: 'AND' })
165
+ return this
166
+ }
167
+
168
+ whereNull(column: string): this {
169
+ this.wheres.push({ kind: 'null', column, boolean: 'AND' })
170
+ return this
171
+ }
172
+
173
+ whereNotNull(column: string): this {
174
+ this.wheres.push({ kind: 'not_null', column, boolean: 'AND' })
175
+ return this
176
+ }
177
+
178
+ whereBetween(column: string, low: unknown, high: unknown): this {
179
+ this.wheres.push({ kind: 'between', column, low, high, boolean: 'AND' })
180
+ return this
181
+ }
182
+
183
+ whereRaw(sql: string, params: unknown[] = []): this {
184
+ this.wheres.push({ kind: 'raw', sql, params, boolean: 'AND' })
185
+ return this
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // OR WHERE
190
+ // ---------------------------------------------------------------------------
191
+
192
+ orWhere(
193
+ column: string | ((q: QueryBuilder<T>) => void),
194
+ operatorOrValue?: unknown,
195
+ value?: unknown
196
+ ): this {
197
+ if (typeof column === 'function') {
198
+ const sub = new QueryBuilder<T>(this.modelClass)
199
+ column(sub)
200
+ this.wheres.push({ kind: 'group', clauses: sub.wheres, boolean: 'OR' })
201
+ return this
202
+ }
203
+ if (value === undefined) {
204
+ this.wheres.push({
205
+ kind: 'comparison',
206
+ column,
207
+ operator: '=',
208
+ value: operatorOrValue,
209
+ boolean: 'OR',
210
+ })
211
+ } else {
212
+ this.wheres.push({
213
+ kind: 'comparison',
214
+ column,
215
+ operator: operatorOrValue as string,
216
+ value,
217
+ boolean: 'OR',
218
+ })
219
+ }
220
+ return this
221
+ }
222
+
223
+ orWhereIn(column: string, values: unknown[]): this {
224
+ this.wheres.push({ kind: 'in', column, values, boolean: 'OR' })
225
+ return this
226
+ }
227
+
228
+ orWhereNotIn(column: string, values: unknown[]): this {
229
+ this.wheres.push({ kind: 'not_in', column, values, boolean: 'OR' })
230
+ return this
231
+ }
232
+
233
+ orWhereNull(column: string): this {
234
+ this.wheres.push({ kind: 'null', column, boolean: 'OR' })
235
+ return this
236
+ }
237
+
238
+ orWhereNotNull(column: string): this {
239
+ this.wheres.push({ kind: 'not_null', column, boolean: 'OR' })
240
+ return this
241
+ }
242
+
243
+ orWhereBetween(column: string, low: unknown, high: unknown): this {
244
+ this.wheres.push({ kind: 'between', column, low, high, boolean: 'OR' })
245
+ return this
246
+ }
247
+
248
+ orWhereRaw(sql: string, params: unknown[] = []): this {
249
+ this.wheres.push({ kind: 'raw', sql, params, boolean: 'OR' })
250
+ return this
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // HAVING
255
+ // ---------------------------------------------------------------------------
256
+
257
+ having(column: string, operatorOrValue: unknown, value?: unknown): this {
258
+ if (value === undefined) {
259
+ this.havings.push({
260
+ kind: 'comparison',
261
+ column,
262
+ operator: '=',
263
+ value: operatorOrValue,
264
+ boolean: 'AND',
265
+ })
266
+ } else {
267
+ this.havings.push({
268
+ kind: 'comparison',
269
+ column,
270
+ operator: operatorOrValue as string,
271
+ value,
272
+ boolean: 'AND',
273
+ })
274
+ }
275
+ return this
276
+ }
277
+
278
+ havingRaw(sql: string, params: unknown[] = []): this {
279
+ this.havings.push({ kind: 'raw', sql, params, boolean: 'AND' })
280
+ return this
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // JOIN
285
+ // ---------------------------------------------------------------------------
286
+
287
+ leftJoin(model: typeof BaseModel): JoinBuilder<T> {
288
+ this.models.set(model.name, model)
289
+ return new JoinBuilder(this, model, 'LEFT JOIN')
290
+ }
291
+
292
+ innerJoin(model: typeof BaseModel): JoinBuilder<T> {
293
+ this.models.set(model.name, model)
294
+ return new JoinBuilder(this, model, 'INNER JOIN')
295
+ }
296
+
297
+ rightJoin(model: typeof BaseModel): JoinBuilder<T> {
298
+ this.models.set(model.name, model)
299
+ return new JoinBuilder(this, model, 'RIGHT JOIN')
300
+ }
301
+
302
+ /** @internal Called by JoinBuilder to register a completed join. */
303
+ _addJoin(join: JoinClause): this {
304
+ this.joins.push(join)
305
+ return this
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // SELECT, ORDER, GROUP, LIMIT, OFFSET
310
+ // ---------------------------------------------------------------------------
311
+
312
+ select(...columns: string[]): this {
313
+ this.selectColumns.push(...columns)
314
+ return this
315
+ }
316
+
317
+ orderBy(column: string, direction: 'asc' | 'desc' | 'ASC' | 'DESC' = 'ASC'): this {
318
+ this.orderBys.push({ column, direction: direction.toUpperCase() as 'ASC' | 'DESC' })
319
+ return this
320
+ }
321
+
322
+ groupBy(...columns: string[]): this {
323
+ this.groupBys.push(...columns)
324
+ return this
325
+ }
326
+
327
+ limit(n: number): this {
328
+ this.limitValue = n
329
+ return this
330
+ }
331
+
332
+ offset(n: number): this {
333
+ this.offsetValue = n
334
+ return this
335
+ }
336
+
337
+ distinct(): this {
338
+ this.isDistinct = true
339
+ return this
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Soft deletes
344
+ // ---------------------------------------------------------------------------
345
+
346
+ withTrashed(): this {
347
+ this.includeTrashed = true
348
+ return this
349
+ }
350
+
351
+ onlyTrashed(): this {
352
+ this.isOnlyTrashed = true
353
+ return this
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Eager loading
358
+ // ---------------------------------------------------------------------------
359
+
360
+ with(...relations: string[]): this {
361
+ this.eagerLoads.push(...relations)
362
+ return this
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Scopes
367
+ // ---------------------------------------------------------------------------
368
+
369
+ scope(name: string): this {
370
+ const scopes = (this.modelClass as any).scopes
371
+ if (!scopes || typeof scopes[name] !== 'function') {
372
+ throw new Error(`Unknown scope "${name}" on ${this.modelClass.name}`)
373
+ }
374
+ scopes[name](this)
375
+ return this
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Terminal methods — read
380
+ // ---------------------------------------------------------------------------
381
+
382
+ async all(): Promise<T[]> {
383
+ const { sql, params } = this.build('select')
384
+ const rows = await this.connection.unsafe(sql, params)
385
+ const Model = this.modelClass as any
386
+ const results = rows.map((row: Record<string, unknown>) => Model.hydrate(row) as T)
387
+ await this.eagerLoad(results)
388
+ return results
389
+ }
390
+
391
+ async first(): Promise<T | null> {
392
+ const savedLimit = this.limitValue
393
+ this.limitValue = 1
394
+ const results = await this.all()
395
+ this.limitValue = savedLimit
396
+ return results[0] ?? null
397
+ }
398
+
399
+ async firstOrFail(): Promise<T> {
400
+ const result = await this.first()
401
+ if (!result) {
402
+ throw new ModelNotFoundError(this.modelClass.name)
403
+ }
404
+ return result
405
+ }
406
+
407
+ async count(): Promise<number> {
408
+ const { sql, params } = this.build('count')
409
+ const rows = await this.connection.unsafe(sql, params)
410
+ return Number(rows[0]?.count ?? 0)
411
+ }
412
+
413
+ async exists(): Promise<boolean> {
414
+ return (await this.count()) > 0
415
+ }
416
+
417
+ async paginate(page: number = 1, perPage: number = 15): Promise<PaginationResult<T>> {
418
+ const currentPage = Math.max(1, Math.floor(page))
419
+
420
+ const total = await this.count()
421
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
422
+
423
+ const savedLimit = this.limitValue
424
+ const savedOffset = this.offsetValue
425
+ this.limitValue = perPage
426
+ this.offsetValue = (currentPage - 1) * perPage
427
+ const data = await this.all()
428
+ this.limitValue = savedLimit
429
+ this.offsetValue = savedOffset
430
+
431
+ const from = total > 0 ? (currentPage - 1) * perPage + 1 : 0
432
+ const to = Math.min(currentPage * perPage, total)
433
+
434
+ return {
435
+ data,
436
+ meta: { page: currentPage, perPage, total, lastPage, from, to },
437
+ }
438
+ }
439
+
440
+ async pluck<V = unknown>(column: string): Promise<V[]> {
441
+ const savedColumns = [...this.selectColumns]
442
+ this.selectColumns = [column]
443
+ const { sql, params } = this.build('select')
444
+ this.selectColumns = savedColumns
445
+
446
+ const rows = await this.connection.unsafe(sql, params)
447
+ const snakeCol = toSnakeCase(column.includes('.') ? column.split('.')[1]! : column)
448
+ return rows.map((row: Record<string, unknown>) => row[snakeCol] as V)
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Terminal methods — aggregates
453
+ // ---------------------------------------------------------------------------
454
+
455
+ async sum(column: string): Promise<number> {
456
+ return this.aggregate('SUM', column)
457
+ }
458
+
459
+ async avg(column: string): Promise<number> {
460
+ return this.aggregate('AVG', column)
461
+ }
462
+
463
+ async min(column: string): Promise<number> {
464
+ return this.aggregate('MIN', column)
465
+ }
466
+
467
+ async max(column: string): Promise<number> {
468
+ return this.aggregate('MAX', column)
469
+ }
470
+
471
+ private async aggregate(fn: string, column: string): Promise<number> {
472
+ const col = this.resolveColumn(column)
473
+ const savedColumns = [...this.selectColumns]
474
+ const savedDistinct = this.isDistinct
475
+ this.selectColumns = [`${fn}(${col}) AS "result"`]
476
+ this.isDistinct = false
477
+
478
+ const { sql, params } = this.build('select')
479
+ this.selectColumns = savedColumns
480
+ this.isDistinct = savedDistinct
481
+
482
+ const rows = await this.connection.unsafe(sql, params)
483
+ return Number(rows[0]?.result ?? 0)
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Terminal methods — mutations
488
+ // ---------------------------------------------------------------------------
489
+
490
+ async update(data: Record<string, unknown>): Promise<number> {
491
+ const { sql, params } = this.buildUpdate(data)
492
+ const result = await this.connection.unsafe(sql, params)
493
+ return result.count
494
+ }
495
+
496
+ async delete(): Promise<number> {
497
+ if (this.modelClass.softDeletes && !this.includeTrashed) {
498
+ return this.update({ deletedAt: DateTime.now() })
499
+ }
500
+ const { sql, params } = this.buildDelete()
501
+ const result = await this.connection.unsafe(sql, params)
502
+ return result.count
503
+ }
504
+
505
+ async forceDelete(): Promise<number> {
506
+ const { sql, params } = this.buildDelete()
507
+ const result = await this.connection.unsafe(sql, params)
508
+ return result.count
509
+ }
510
+
511
+ async increment(column: string, amount: number = 1): Promise<number> {
512
+ return this.adjustColumn(column, amount)
513
+ }
514
+
515
+ async decrement(column: string, amount: number = 1): Promise<number> {
516
+ return this.adjustColumn(column, -amount)
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Terminal methods — iteration
521
+ // ---------------------------------------------------------------------------
522
+
523
+ async chunk(size: number, callback: (items: T[]) => Promise<void> | void): Promise<void> {
524
+ let page = 0
525
+ while (true) {
526
+ const savedLimit = this.limitValue
527
+ const savedOffset = this.offsetValue
528
+ this.limitValue = size
529
+ this.offsetValue = page * size
530
+ const items = await this.all()
531
+ this.limitValue = savedLimit
532
+ this.offsetValue = savedOffset
533
+
534
+ if (items.length === 0) break
535
+ await callback(items)
536
+ if (items.length < size) break
537
+ page++
538
+ }
539
+ }
540
+
541
+ /** Return the generated SQL and params without executing. */
542
+ toSQL(): { sql: string; params: unknown[] } {
543
+ return this.build('select')
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // SQL building — SELECT / COUNT
548
+ // ---------------------------------------------------------------------------
549
+
550
+ private build(mode: 'select' | 'count'): { sql: string; params: unknown[] } {
551
+ const parts: string[] = []
552
+ const params: unknown[] = []
553
+ const paramIdx = { value: 1 }
554
+
555
+ // SELECT
556
+ if (mode === 'count') {
557
+ parts.push('SELECT COUNT(*) AS "count"')
558
+ } else if (this.selectColumns.length > 0) {
559
+ const cols = this.selectColumns.map(c => this.resolveSelectColumn(c))
560
+ parts.push(`SELECT ${this.isDistinct ? 'DISTINCT ' : ''}${cols.join(', ')}`)
561
+ } else {
562
+ parts.push(`SELECT ${this.isDistinct ? 'DISTINCT ' : ''}"${this.primaryTable}".*`)
563
+ }
564
+
565
+ // FROM
566
+ parts.push(`FROM "${this.primaryTable}"`)
567
+
568
+ // JOINs
569
+ for (const join of this.joins) {
570
+ const left = this.resolveColumn(join.leftCol)
571
+ const right = this.resolveColumn(join.rightCol)
572
+ parts.push(`${join.type} "${join.table}" ON ${left} ${join.operator} ${right}`)
573
+ }
574
+
575
+ // WHERE
576
+ const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
577
+ this.wheres,
578
+ params,
579
+ paramIdx,
580
+ true
581
+ )
582
+ if (whereParts.length > 0) {
583
+ parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
584
+ }
585
+
586
+ // GROUP BY
587
+ if (this.groupBys.length > 0) {
588
+ const cols = this.groupBys.map(c => this.resolveColumn(c))
589
+ parts.push(`GROUP BY ${cols.join(', ')}`)
590
+ }
591
+
592
+ // HAVING
593
+ if (this.havings.length > 0) {
594
+ const { parts: havingParts, booleans: havingBooleans } = this.buildClauseParts(
595
+ this.havings,
596
+ params,
597
+ paramIdx,
598
+ false,
599
+ true
600
+ )
601
+ if (havingParts.length > 0) {
602
+ parts.push(`HAVING ${this.joinWithBooleans(havingParts, havingBooleans)}`)
603
+ }
604
+ }
605
+
606
+ // count mode skips ORDER BY, LIMIT, OFFSET
607
+ if (mode === 'select') {
608
+ // ORDER BY
609
+ if (this.orderBys.length > 0) {
610
+ const clauses = this.orderBys.map(o => `${this.resolveColumn(o.column)} ${o.direction}`)
611
+ parts.push(`ORDER BY ${clauses.join(', ')}`)
612
+ }
613
+
614
+ // LIMIT
615
+ if (this.limitValue !== null) {
616
+ parts.push(`LIMIT ${this.limitValue}`)
617
+ }
618
+
619
+ // OFFSET
620
+ if (this.offsetValue !== null) {
621
+ parts.push(`OFFSET ${this.offsetValue}`)
622
+ }
623
+ }
624
+
625
+ return { sql: parts.join(' '), params }
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // SQL building — UPDATE / DELETE / INCREMENT
630
+ // ---------------------------------------------------------------------------
631
+
632
+ private buildUpdate(data: Record<string, unknown>): { sql: string; params: unknown[] } {
633
+ const parts: string[] = []
634
+ const params: unknown[] = []
635
+ const paramIdx = { value: 1 }
636
+
637
+ parts.push(`UPDATE "${this.primaryTable}"`)
638
+
639
+ const setClauses: string[] = []
640
+ for (const [key, val] of Object.entries(data)) {
641
+ const col = toSnakeCase(key)
642
+ params.push(this.dehydrateValue(val, key))
643
+ setClauses.push(`"${col}" = $${paramIdx.value++}`)
644
+ }
645
+ parts.push(`SET ${setClauses.join(', ')}`)
646
+
647
+ const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
648
+ this.wheres,
649
+ params,
650
+ paramIdx,
651
+ true
652
+ )
653
+ if (whereParts.length > 0) {
654
+ parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
655
+ }
656
+
657
+ return { sql: parts.join(' '), params }
658
+ }
659
+
660
+ private buildDelete(): { sql: string; params: unknown[] } {
661
+ const parts: string[] = []
662
+ const params: unknown[] = []
663
+ const paramIdx = { value: 1 }
664
+
665
+ parts.push(`DELETE FROM "${this.primaryTable}"`)
666
+
667
+ const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
668
+ this.wheres,
669
+ params,
670
+ paramIdx,
671
+ true
672
+ )
673
+ if (whereParts.length > 0) {
674
+ parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
675
+ }
676
+
677
+ return { sql: parts.join(' '), params }
678
+ }
679
+
680
+ private async adjustColumn(column: string, amount: number): Promise<number> {
681
+ const col = toSnakeCase(column)
682
+ const parts: string[] = []
683
+ const params: unknown[] = []
684
+ const paramIdx = { value: 1 }
685
+
686
+ parts.push(`UPDATE "${this.primaryTable}"`)
687
+ params.push(Math.abs(amount))
688
+ const op = amount >= 0 ? '+' : '-'
689
+ parts.push(`SET "${col}" = "${col}" ${op} $${paramIdx.value++}`)
690
+
691
+ const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
692
+ this.wheres,
693
+ params,
694
+ paramIdx,
695
+ true
696
+ )
697
+ if (whereParts.length > 0) {
698
+ parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
699
+ }
700
+
701
+ const result = await this.connection.unsafe(parts.join(' '), params)
702
+ return result.count
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // Shared clause building
707
+ // ---------------------------------------------------------------------------
708
+
709
+ private buildClauseParts(
710
+ clauses: WhereClause[],
711
+ params: unknown[],
712
+ paramIdx: { value: number },
713
+ includeSoftDeletes: boolean,
714
+ rawColumns: boolean = false
715
+ ): { parts: string[]; booleans: WhereBoolean[] } {
716
+ const parts: string[] = []
717
+ const booleans: WhereBoolean[] = []
718
+
719
+ if (includeSoftDeletes && this.modelClass.softDeletes && !this.includeTrashed) {
720
+ if (this.isOnlyTrashed) {
721
+ parts.push(`"${this.primaryTable}"."deleted_at" IS NOT NULL`)
722
+ } else {
723
+ parts.push(`"${this.primaryTable}"."deleted_at" IS NULL`)
724
+ }
725
+ booleans.push('AND')
726
+ }
727
+
728
+ const resolve = rawColumns
729
+ ? (ref: string) => this.resolveSelectColumn(ref)
730
+ : (ref: string) => this.resolveColumn(ref)
731
+
732
+ for (const w of clauses) {
733
+ booleans.push(w.boolean)
734
+ switch (w.kind) {
735
+ case 'comparison': {
736
+ const col = resolve(w.column)
737
+ const prop = this.extractProperty(w.column)
738
+ params.push(this.dehydrateValue(w.value, prop))
739
+ parts.push(`${col} ${w.operator} $${paramIdx.value++}`)
740
+ break
741
+ }
742
+ case 'in': {
743
+ const col = resolve(w.column)
744
+ const prop = this.extractProperty(w.column)
745
+ const placeholders = w.values.map(v => {
746
+ params.push(this.dehydrateValue(v, prop))
747
+ return `$${paramIdx.value++}`
748
+ })
749
+ parts.push(`${col} IN (${placeholders.join(', ')})`)
750
+ break
751
+ }
752
+ case 'not_in': {
753
+ const col = resolve(w.column)
754
+ const prop = this.extractProperty(w.column)
755
+ const placeholders = w.values.map(v => {
756
+ params.push(this.dehydrateValue(v, prop))
757
+ return `$${paramIdx.value++}`
758
+ })
759
+ parts.push(`${col} NOT IN (${placeholders.join(', ')})`)
760
+ break
761
+ }
762
+ case 'null': {
763
+ const col = resolve(w.column)
764
+ parts.push(`${col} IS NULL`)
765
+ break
766
+ }
767
+ case 'not_null': {
768
+ const col = resolve(w.column)
769
+ parts.push(`${col} IS NOT NULL`)
770
+ break
771
+ }
772
+ case 'between': {
773
+ const col = resolve(w.column)
774
+ const prop = this.extractProperty(w.column)
775
+ params.push(this.dehydrateValue(w.low, prop))
776
+ params.push(this.dehydrateValue(w.high, prop))
777
+ parts.push(`${col} BETWEEN $${paramIdx.value++} AND $${paramIdx.value++}`)
778
+ break
779
+ }
780
+ case 'raw': {
781
+ let rawSql = w.sql
782
+ for (const p of w.params) {
783
+ rawSql = rawSql.replace(`$${w.params.indexOf(p) + 1}`, `$${paramIdx.value++}`)
784
+ params.push(this.dehydrateValue(p))
785
+ }
786
+ parts.push(rawSql)
787
+ break
788
+ }
789
+ case 'group': {
790
+ const { parts: groupParts, booleans: groupBooleans } = this.buildClauseParts(
791
+ w.clauses,
792
+ params,
793
+ paramIdx,
794
+ false,
795
+ rawColumns
796
+ )
797
+ if (groupParts.length > 0) {
798
+ parts.push(`(${this.joinWithBooleans(groupParts, groupBooleans)})`)
799
+ }
800
+ break
801
+ }
802
+ }
803
+ }
804
+
805
+ return { parts, booleans }
806
+ }
807
+
808
+ private joinWithBooleans(parts: string[], booleans: WhereBoolean[]): string {
809
+ if (parts.length === 0) return ''
810
+ let result = parts[0]!
811
+ for (let i = 1; i < parts.length; i++) {
812
+ result += ` ${booleans[i]} ${parts[i]}`
813
+ }
814
+ return result
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // Eager loading
819
+ // ---------------------------------------------------------------------------
820
+
821
+ private async eagerLoad(models: T[]): Promise<void> {
822
+ if (this.eagerLoads.length === 0 || models.length === 0) return
823
+
824
+ const ctor = this.modelClass as unknown as Function
825
+ const refMetas: ReferenceMetadata[] = getReferenceMeta(ctor)
826
+ const assocMetas: AssociateMetadata[] = getAssociates(ctor)
827
+
828
+ for (const relation of this.eagerLoads) {
829
+ const refMeta = refMetas.find(r => r.property === relation)
830
+ if (refMeta) {
831
+ await this.eagerLoadReference(models, refMeta)
832
+ continue
833
+ }
834
+
835
+ const assocMeta = assocMetas.find(a => a.property === relation)
836
+ if (assocMeta) {
837
+ await this.eagerLoadAssociation(models, assocMeta)
838
+ continue
839
+ }
840
+
841
+ throw new Error(`Unknown relation "${relation}" on ${this.modelClass.name}`)
842
+ }
843
+ }
844
+
845
+ private async eagerLoadReference(models: T[], meta: ReferenceMetadata): Promise<void> {
846
+ const fkValues = [
847
+ ...new Set(
848
+ models.map(m => (m as any)[meta.foreignKey]).filter(v => v !== null && v !== undefined)
849
+ ),
850
+ ]
851
+
852
+ if (fkValues.length === 0) {
853
+ for (const model of models) {
854
+ ;(model as any)[meta.property] = null
855
+ }
856
+ return
857
+ }
858
+
859
+ const targetTable = toSnakeCase(meta.model)
860
+ const targetPKCol = toSnakeCase(meta.targetPK)
861
+ const placeholders = fkValues.map((_, i) => `$${i + 1}`).join(', ')
862
+ const rows = await this.connection.unsafe(
863
+ `SELECT * FROM "${targetTable}" WHERE "${targetPKCol}" IN (${placeholders})`,
864
+ fkValues
865
+ )
866
+
867
+ const lookup = new Map<unknown, Record<string, unknown>>()
868
+ for (const row of rows) {
869
+ const r = row as Record<string, unknown>
870
+ lookup.set(r[targetPKCol], hydrateRow(r))
871
+ }
872
+
873
+ for (const model of models) {
874
+ const fkVal = (model as any)[meta.foreignKey]
875
+ ;(model as any)[meta.property] = fkVal != null ? (lookup.get(fkVal) ?? null) : null
876
+ }
877
+ }
878
+
879
+ private async eagerLoadAssociation(models: T[], meta: AssociateMetadata): Promise<void> {
880
+ const ctor = this.modelClass as typeof BaseModel
881
+ const pkProp = ctor.primaryKeyProperty
882
+
883
+ const pkValues = models.map(m => (m as any)[pkProp])
884
+ if (pkValues.length === 0) return
885
+
886
+ const targetTable = toSnakeCase(meta.model)
887
+ const targetPKCol = toSnakeCase(meta.targetPK)
888
+ const placeholders = pkValues.map((_, i) => `$${i + 1}`).join(', ')
889
+
890
+ const rows = await this.connection.unsafe(
891
+ `SELECT t.*, p."${meta.foreignKey}" AS "_pivot_fk" ` +
892
+ `FROM "${targetTable}" t ` +
893
+ `INNER JOIN "${meta.through}" p ON p."${meta.otherKey}" = t."${targetPKCol}" ` +
894
+ `WHERE p."${meta.foreignKey}" IN (${placeholders})`,
895
+ pkValues
896
+ )
897
+
898
+ const grouped = new Map<unknown, Record<string, unknown>[]>()
899
+ for (const row of rows) {
900
+ const r = row as Record<string, unknown>
901
+ const fk = r._pivot_fk
902
+ delete r._pivot_fk
903
+ if (!grouped.has(fk)) grouped.set(fk, [])
904
+ grouped.get(fk)!.push(hydrateRow(r))
905
+ }
906
+
907
+ for (const model of models) {
908
+ const pk = (model as any)[pkProp]
909
+ ;(model as any)[meta.property] = grouped.get(pk) ?? []
910
+ }
911
+ }
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // Column resolution
915
+ // ---------------------------------------------------------------------------
916
+
917
+ /**
918
+ * Resolve a user column reference to a fully qualified SQL identifier.
919
+ *
920
+ * - `'email'` → `"user"."email"`
921
+ * - `'User.email'` → `"user"."email"`
922
+ * - `'Project.userId'` → `"project"."user_id"`
923
+ */
924
+ private resolveColumn(ref: string): string {
925
+ const dot = ref.indexOf('.')
926
+ if (dot === -1) {
927
+ return `"${this.primaryTable}"."${toSnakeCase(ref)}"`
928
+ }
929
+
930
+ const modelName = ref.substring(0, dot)
931
+ const propName = ref.substring(dot + 1)
932
+ const tableName = this.resolveModelTable(modelName)
933
+ return `"${tableName}"."${toSnakeCase(propName)}"`
934
+ }
935
+
936
+ /**
937
+ * Resolve a select column. Passes through raw expressions containing
938
+ * special characters (parentheses, asterisk, AS keyword).
939
+ */
940
+ private resolveSelectColumn(col: string): string {
941
+ if (/[(*)]/.test(col) || /\bAS\b/i.test(col)) {
942
+ return col
943
+ }
944
+ return this.resolveColumn(col)
945
+ }
946
+
947
+ /** Resolve a PascalCase model name to its table name. */
948
+ private resolveModelTable(modelName: string): string {
949
+ const model = this.models.get(modelName)
950
+ if (model) return model.tableName
951
+ return toSnakeCase(modelName)
952
+ }
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // Value helpers
956
+ // ---------------------------------------------------------------------------
957
+
958
+ private dehydrateValue(value: unknown, property?: string): unknown {
959
+ if (value == null) return value
960
+
961
+ if (property) {
962
+ const castDef = this.castMap.get(property)
963
+ if (castDef) return castDef.set(value)
964
+ }
965
+
966
+ if (value instanceof DateTime) return value.toJSDate()
967
+ return value
968
+ }
969
+
970
+ private get castMap(): Map<string, CastDefinition> {
971
+ return getCasts(this.modelClass)
972
+ }
973
+
974
+ /** Extract the property name from a column reference (e.g. 'users.email' → 'email'). */
975
+ private extractProperty(column: string): string {
976
+ const dotIdx = column.lastIndexOf('.')
977
+ return dotIdx >= 0 ? column.slice(dotIdx + 1) : column
978
+ }
979
+ }
980
+
981
+ // ---------------------------------------------------------------------------
982
+ // Entry point
983
+ // ---------------------------------------------------------------------------
984
+
985
+ /**
986
+ * Create a new QueryBuilder for the given model class.
987
+ *
988
+ * @example
989
+ * const users = await query(User).where('email', 'test@example.com').all()
990
+ *
991
+ * // Inside a transaction:
992
+ * await transaction(async (trx) => {
993
+ * const user = await query(User, trx).where('id', 1).first()
994
+ * })
995
+ */
996
+ export function query<T extends BaseModel>(model: ModelStatic<T>, trx?: any): QueryBuilder<T> {
997
+ return new QueryBuilder<T>(model, trx)
998
+ }
999
+
1000
+ /**
1001
+ * Run a callback inside a database transaction.
1002
+ *
1003
+ * The transaction automatically commits on success and rolls back on error.
1004
+ * In multi-tenant mode, the transaction preserves the current tenant context.
1005
+ *
1006
+ * @example
1007
+ * const user = await transaction(async (trx) => {
1008
+ * const u = await User.create({ name: 'Alice' }, trx)
1009
+ * await Profile.create({ userId: u.id }, trx)
1010
+ * return u
1011
+ * })
1012
+ *
1013
+ * @example
1014
+ * // In multi-tenant context
1015
+ * await withTenant('tenant_123', async () => {
1016
+ * await transaction(async (trx) => {
1017
+ * // All queries use tenant_123 schema
1018
+ * await User.create({ name: 'Bob' }, trx)
1019
+ * })
1020
+ * })
1021
+ */
1022
+ export async function transaction<T>(fn: (trx: any) => Promise<T>): Promise<T> {
1023
+ const schema = hasSchemaContext() ? getCurrentSchema() : null
1024
+
1025
+ return Database.raw.begin(async (trx: any) => {
1026
+ // Set search_path for this transaction if in tenant context
1027
+ if (schema) {
1028
+ await trx.unsafe(`SET search_path TO "${schema}", public`)
1029
+ }
1030
+
1031
+ // Execute the user's transaction callback
1032
+ return fn(trx)
1033
+ })
1034
+ }