@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.
- package/package.json +46 -0
- package/src/database/database.ts +181 -0
- package/src/database/domain/context.ts +84 -0
- package/src/database/domain/index.ts +17 -0
- package/src/database/domain/manager.ts +274 -0
- package/src/database/domain/wrapper.ts +105 -0
- package/src/database/index.ts +32 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +145 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +86 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +1034 -0
- package/src/database/seeder.ts +31 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/orm/base_model.ts +427 -0
- package/src/orm/decorators.ts +290 -0
- package/src/orm/index.ts +3 -0
- package/src/providers/database_provider.ts +25 -0
- package/src/providers/index.ts +1 -0
- package/src/schema/database_representation.ts +124 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/domain_discovery.ts +83 -0
- package/src/schema/field_builder.ts +160 -0
- package/src/schema/field_definition.ts +69 -0
- package/src/schema/index.ts +22 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +187 -0
- package/src/schema/representation_builder.ts +482 -0
- package/src/schema/type_builder.ts +115 -0
- package/src/schema/types.ts +35 -0
- 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
|
+
}
|