@stravigor/core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth/auth.ts +2 -1
- package/src/broadcast/broadcast_manager.ts +18 -5
- package/src/broadcast/client.ts +10 -4
- package/src/cache/cache_manager.ts +6 -2
- package/src/cache/http_cache.ts +1 -5
- package/src/core/container.ts +2 -6
- package/src/database/database.ts +11 -7
- package/src/database/migration/runner.ts +3 -1
- package/src/database/query_builder.ts +553 -60
- package/src/encryption/encryption_manager.ts +7 -1
- package/src/exceptions/errors.ts +1 -5
- package/src/exceptions/http_exception.ts +4 -1
- package/src/generators/api_generator.ts +8 -1
- package/src/generators/doc_generator.ts +33 -28
- package/src/generators/model_generator.ts +3 -1
- package/src/generators/test_generator.ts +81 -91
- package/src/i18n/helpers.ts +5 -1
- package/src/i18n/i18n_manager.ts +3 -1
- package/src/i18n/middleware.ts +2 -8
- package/src/mail/helpers.ts +1 -1
- package/src/mail/index.ts +4 -0
- package/src/mail/mail_manager.ts +20 -1
- package/src/mail/transports/alibaba_transport.ts +88 -0
- package/src/mail/transports/mailgun_transport.ts +74 -0
- package/src/mail/transports/resend_transport.ts +3 -4
- package/src/mail/transports/sendgrid_transport.ts +12 -9
- package/src/mail/transports/smtp_transport.ts +5 -5
- package/src/mail/types.ts +19 -1
- package/src/notification/channels/discord_channel.ts +6 -1
- package/src/notification/channels/webhook_channel.ts +8 -3
- package/src/notification/helpers.ts +7 -7
- package/src/notification/notification_manager.ts +7 -6
- package/src/orm/base_model.ts +4 -2
- package/src/queue/queue.ts +3 -1
- package/src/scheduler/cron.ts +12 -6
- package/src/scheduler/schedule.ts +17 -8
- package/src/session/session_manager.ts +3 -1
- package/src/storage/storage_manager.ts +3 -1
- package/src/view/compiler.ts +1 -3
- package/src/view/islands/island_builder.ts +4 -4
- package/src/view/islands/vue_plugin.ts +11 -15
- package/src/view/tokenizer.ts +11 -1
|
@@ -2,6 +2,9 @@ import { DateTime } from 'luxon'
|
|
|
2
2
|
import { toSnakeCase } from '../helpers/strings.ts'
|
|
3
3
|
import type BaseModel from '../orm/base_model.ts'
|
|
4
4
|
import { ModelNotFoundError } from '../exceptions/errors.ts'
|
|
5
|
+
import { getReferenceMeta, getAssociates } from '../orm/decorators.ts'
|
|
6
|
+
import type { ReferenceMetadata, AssociateMetadata } from '../orm/decorators.ts'
|
|
7
|
+
import { hydrateRow } from '../orm/base_model.ts'
|
|
5
8
|
|
|
6
9
|
type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
|
|
7
10
|
|
|
@@ -27,12 +30,15 @@ export interface PaginationResult<T> {
|
|
|
27
30
|
// Internal types
|
|
28
31
|
// ---------------------------------------------------------------------------
|
|
29
32
|
|
|
33
|
+
type WhereBoolean = 'AND' | 'OR'
|
|
34
|
+
|
|
30
35
|
type WhereClause =
|
|
31
|
-
| { kind: 'comparison'; column: string; operator: string; value: unknown }
|
|
32
|
-
| { kind: 'in' | 'not_in'; column: string; values: unknown[] }
|
|
33
|
-
| { kind: 'null' | 'not_null'; column: string }
|
|
34
|
-
| { kind: 'between'; column: string; low: unknown; high: unknown }
|
|
35
|
-
| { kind: 'raw'; sql: string; params: unknown[] }
|
|
36
|
+
| { kind: 'comparison'; column: string; operator: string; value: unknown; boolean: WhereBoolean }
|
|
37
|
+
| { kind: 'in' | 'not_in'; column: string; values: unknown[]; boolean: WhereBoolean }
|
|
38
|
+
| { kind: 'null' | 'not_null'; column: string; boolean: WhereBoolean }
|
|
39
|
+
| { kind: 'between'; column: string; low: unknown; high: unknown; boolean: WhereBoolean }
|
|
40
|
+
| { kind: 'raw'; sql: string; params: unknown[]; boolean: WhereBoolean }
|
|
41
|
+
| { kind: 'group'; clauses: WhereClause[]; boolean: WhereBoolean }
|
|
36
42
|
|
|
37
43
|
interface JoinClause {
|
|
38
44
|
type: 'LEFT JOIN' | 'INNER JOIN' | 'RIGHT JOIN'
|
|
@@ -81,6 +87,7 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
81
87
|
private models: Map<string, typeof BaseModel> = new Map()
|
|
82
88
|
|
|
83
89
|
private wheres: WhereClause[] = []
|
|
90
|
+
private havings: WhereClause[] = []
|
|
84
91
|
private joins: JoinClause[] = []
|
|
85
92
|
private orderBys: OrderByClause[] = []
|
|
86
93
|
private groupBys: string[] = []
|
|
@@ -90,6 +97,7 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
90
97
|
private isDistinct: boolean = false
|
|
91
98
|
private includeTrashed: boolean = false
|
|
92
99
|
private isOnlyTrashed: boolean = false
|
|
100
|
+
private eagerLoads: string[] = []
|
|
93
101
|
|
|
94
102
|
constructor(modelClass: ModelStatic<T>) {
|
|
95
103
|
this.modelClass = modelClass
|
|
@@ -101,42 +109,159 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
101
109
|
// WHERE
|
|
102
110
|
// ---------------------------------------------------------------------------
|
|
103
111
|
|
|
104
|
-
where(
|
|
112
|
+
where(
|
|
113
|
+
column: string | ((q: QueryBuilder<T>) => void),
|
|
114
|
+
operatorOrValue?: unknown,
|
|
115
|
+
value?: unknown
|
|
116
|
+
): this {
|
|
117
|
+
if (typeof column === 'function') {
|
|
118
|
+
const sub = new QueryBuilder<T>(this.modelClass)
|
|
119
|
+
column(sub)
|
|
120
|
+
this.wheres.push({ kind: 'group', clauses: sub.wheres, boolean: 'AND' })
|
|
121
|
+
return this
|
|
122
|
+
}
|
|
105
123
|
if (value === undefined) {
|
|
106
|
-
this.wheres.push({
|
|
124
|
+
this.wheres.push({
|
|
125
|
+
kind: 'comparison',
|
|
126
|
+
column,
|
|
127
|
+
operator: '=',
|
|
128
|
+
value: operatorOrValue,
|
|
129
|
+
boolean: 'AND',
|
|
130
|
+
})
|
|
107
131
|
} else {
|
|
108
|
-
this.wheres.push({
|
|
132
|
+
this.wheres.push({
|
|
133
|
+
kind: 'comparison',
|
|
134
|
+
column,
|
|
135
|
+
operator: operatorOrValue as string,
|
|
136
|
+
value,
|
|
137
|
+
boolean: 'AND',
|
|
138
|
+
})
|
|
109
139
|
}
|
|
110
140
|
return this
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
whereIn(column: string, values: unknown[]): this {
|
|
114
|
-
this.wheres.push({ kind: 'in', column, values })
|
|
144
|
+
this.wheres.push({ kind: 'in', column, values, boolean: 'AND' })
|
|
115
145
|
return this
|
|
116
146
|
}
|
|
117
147
|
|
|
118
148
|
whereNotIn(column: string, values: unknown[]): this {
|
|
119
|
-
this.wheres.push({ kind: 'not_in', column, values })
|
|
149
|
+
this.wheres.push({ kind: 'not_in', column, values, boolean: 'AND' })
|
|
120
150
|
return this
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
whereNull(column: string): this {
|
|
124
|
-
this.wheres.push({ kind: 'null', column })
|
|
154
|
+
this.wheres.push({ kind: 'null', column, boolean: 'AND' })
|
|
125
155
|
return this
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
whereNotNull(column: string): this {
|
|
129
|
-
this.wheres.push({ kind: 'not_null', column })
|
|
159
|
+
this.wheres.push({ kind: 'not_null', column, boolean: 'AND' })
|
|
130
160
|
return this
|
|
131
161
|
}
|
|
132
162
|
|
|
133
163
|
whereBetween(column: string, low: unknown, high: unknown): this {
|
|
134
|
-
this.wheres.push({ kind: 'between', column, low, high })
|
|
164
|
+
this.wheres.push({ kind: 'between', column, low, high, boolean: 'AND' })
|
|
135
165
|
return this
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
whereRaw(sql: string, params: unknown[] = []): this {
|
|
139
|
-
this.wheres.push({ kind: 'raw', sql, params })
|
|
169
|
+
this.wheres.push({ kind: 'raw', sql, params, boolean: 'AND' })
|
|
170
|
+
return this
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// OR WHERE
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
orWhere(
|
|
178
|
+
column: string | ((q: QueryBuilder<T>) => void),
|
|
179
|
+
operatorOrValue?: unknown,
|
|
180
|
+
value?: unknown
|
|
181
|
+
): this {
|
|
182
|
+
if (typeof column === 'function') {
|
|
183
|
+
const sub = new QueryBuilder<T>(this.modelClass)
|
|
184
|
+
column(sub)
|
|
185
|
+
this.wheres.push({ kind: 'group', clauses: sub.wheres, boolean: 'OR' })
|
|
186
|
+
return this
|
|
187
|
+
}
|
|
188
|
+
if (value === undefined) {
|
|
189
|
+
this.wheres.push({
|
|
190
|
+
kind: 'comparison',
|
|
191
|
+
column,
|
|
192
|
+
operator: '=',
|
|
193
|
+
value: operatorOrValue,
|
|
194
|
+
boolean: 'OR',
|
|
195
|
+
})
|
|
196
|
+
} else {
|
|
197
|
+
this.wheres.push({
|
|
198
|
+
kind: 'comparison',
|
|
199
|
+
column,
|
|
200
|
+
operator: operatorOrValue as string,
|
|
201
|
+
value,
|
|
202
|
+
boolean: 'OR',
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return this
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
orWhereIn(column: string, values: unknown[]): this {
|
|
209
|
+
this.wheres.push({ kind: 'in', column, values, boolean: 'OR' })
|
|
210
|
+
return this
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
orWhereNotIn(column: string, values: unknown[]): this {
|
|
214
|
+
this.wheres.push({ kind: 'not_in', column, values, boolean: 'OR' })
|
|
215
|
+
return this
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
orWhereNull(column: string): this {
|
|
219
|
+
this.wheres.push({ kind: 'null', column, boolean: 'OR' })
|
|
220
|
+
return this
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
orWhereNotNull(column: string): this {
|
|
224
|
+
this.wheres.push({ kind: 'not_null', column, boolean: 'OR' })
|
|
225
|
+
return this
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
orWhereBetween(column: string, low: unknown, high: unknown): this {
|
|
229
|
+
this.wheres.push({ kind: 'between', column, low, high, boolean: 'OR' })
|
|
230
|
+
return this
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
orWhereRaw(sql: string, params: unknown[] = []): this {
|
|
234
|
+
this.wheres.push({ kind: 'raw', sql, params, boolean: 'OR' })
|
|
235
|
+
return this
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// HAVING
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
having(column: string, operatorOrValue: unknown, value?: unknown): this {
|
|
243
|
+
if (value === undefined) {
|
|
244
|
+
this.havings.push({
|
|
245
|
+
kind: 'comparison',
|
|
246
|
+
column,
|
|
247
|
+
operator: '=',
|
|
248
|
+
value: operatorOrValue,
|
|
249
|
+
boolean: 'AND',
|
|
250
|
+
})
|
|
251
|
+
} else {
|
|
252
|
+
this.havings.push({
|
|
253
|
+
kind: 'comparison',
|
|
254
|
+
column,
|
|
255
|
+
operator: operatorOrValue as string,
|
|
256
|
+
value,
|
|
257
|
+
boolean: 'AND',
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
return this
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
havingRaw(sql: string, params: unknown[] = []): this {
|
|
264
|
+
this.havings.push({ kind: 'raw', sql, params, boolean: 'AND' })
|
|
140
265
|
return this
|
|
141
266
|
}
|
|
142
267
|
|
|
@@ -214,7 +339,29 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
214
339
|
}
|
|
215
340
|
|
|
216
341
|
// ---------------------------------------------------------------------------
|
|
217
|
-
//
|
|
342
|
+
// Eager loading
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
with(...relations: string[]): this {
|
|
346
|
+
this.eagerLoads.push(...relations)
|
|
347
|
+
return this
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Scopes
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
scope(name: string): this {
|
|
355
|
+
const scopes = (this.modelClass as any).scopes
|
|
356
|
+
if (!scopes || typeof scopes[name] !== 'function') {
|
|
357
|
+
throw new Error(`Unknown scope "${name}" on ${this.modelClass.name}`)
|
|
358
|
+
}
|
|
359
|
+
scopes[name](this)
|
|
360
|
+
return this
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Terminal methods — read
|
|
218
365
|
// ---------------------------------------------------------------------------
|
|
219
366
|
|
|
220
367
|
async all(): Promise<T[]> {
|
|
@@ -222,7 +369,9 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
222
369
|
const db = this.modelClass.db
|
|
223
370
|
const rows = await db.sql.unsafe(sql, params)
|
|
224
371
|
const Model = this.modelClass as any
|
|
225
|
-
|
|
372
|
+
const results = rows.map((row: Record<string, unknown>) => Model.hydrate(row) as T)
|
|
373
|
+
await this.eagerLoad(results)
|
|
374
|
+
return results
|
|
226
375
|
}
|
|
227
376
|
|
|
228
377
|
async first(): Promise<T | null> {
|
|
@@ -275,19 +424,125 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
275
424
|
}
|
|
276
425
|
}
|
|
277
426
|
|
|
427
|
+
async pluck<V = unknown>(column: string): Promise<V[]> {
|
|
428
|
+
const savedColumns = [...this.selectColumns]
|
|
429
|
+
this.selectColumns = [column]
|
|
430
|
+
const { sql, params } = this.build('select')
|
|
431
|
+
this.selectColumns = savedColumns
|
|
432
|
+
|
|
433
|
+
const db = this.modelClass.db
|
|
434
|
+
const rows = await db.sql.unsafe(sql, params)
|
|
435
|
+
const snakeCol = toSnakeCase(column.includes('.') ? column.split('.')[1]! : column)
|
|
436
|
+
return rows.map((row: Record<string, unknown>) => row[snakeCol] as V)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Terminal methods — aggregates
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
async sum(column: string): Promise<number> {
|
|
444
|
+
return this.aggregate('SUM', column)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async avg(column: string): Promise<number> {
|
|
448
|
+
return this.aggregate('AVG', column)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async min(column: string): Promise<number> {
|
|
452
|
+
return this.aggregate('MIN', column)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async max(column: string): Promise<number> {
|
|
456
|
+
return this.aggregate('MAX', column)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async aggregate(fn: string, column: string): Promise<number> {
|
|
460
|
+
const col = this.resolveColumn(column)
|
|
461
|
+
const savedColumns = [...this.selectColumns]
|
|
462
|
+
const savedDistinct = this.isDistinct
|
|
463
|
+
this.selectColumns = [`${fn}(${col}) AS "result"`]
|
|
464
|
+
this.isDistinct = false
|
|
465
|
+
|
|
466
|
+
const { sql, params } = this.build('select')
|
|
467
|
+
this.selectColumns = savedColumns
|
|
468
|
+
this.isDistinct = savedDistinct
|
|
469
|
+
|
|
470
|
+
const db = this.modelClass.db
|
|
471
|
+
const rows = await db.sql.unsafe(sql, params)
|
|
472
|
+
return Number(rows[0]?.result ?? 0)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Terminal methods — mutations
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
async update(data: Record<string, unknown>): Promise<number> {
|
|
480
|
+
const { sql, params } = this.buildUpdate(data)
|
|
481
|
+
const db = this.modelClass.db
|
|
482
|
+
const result = await db.sql.unsafe(sql, params)
|
|
483
|
+
return result.count
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async delete(): Promise<number> {
|
|
487
|
+
if (this.modelClass.softDeletes && !this.includeTrashed) {
|
|
488
|
+
return this.update({ deletedAt: DateTime.now() })
|
|
489
|
+
}
|
|
490
|
+
const { sql, params } = this.buildDelete()
|
|
491
|
+
const db = this.modelClass.db
|
|
492
|
+
const result = await db.sql.unsafe(sql, params)
|
|
493
|
+
return result.count
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async forceDelete(): Promise<number> {
|
|
497
|
+
const { sql, params } = this.buildDelete()
|
|
498
|
+
const db = this.modelClass.db
|
|
499
|
+
const result = await db.sql.unsafe(sql, params)
|
|
500
|
+
return result.count
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async increment(column: string, amount: number = 1): Promise<number> {
|
|
504
|
+
return this.adjustColumn(column, amount)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async decrement(column: string, amount: number = 1): Promise<number> {
|
|
508
|
+
return this.adjustColumn(column, -amount)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// Terminal methods — iteration
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
async chunk(size: number, callback: (items: T[]) => Promise<void> | void): Promise<void> {
|
|
516
|
+
let page = 0
|
|
517
|
+
while (true) {
|
|
518
|
+
const savedLimit = this.limitValue
|
|
519
|
+
const savedOffset = this.offsetValue
|
|
520
|
+
this.limitValue = size
|
|
521
|
+
this.offsetValue = page * size
|
|
522
|
+
const items = await this.all()
|
|
523
|
+
this.limitValue = savedLimit
|
|
524
|
+
this.offsetValue = savedOffset
|
|
525
|
+
|
|
526
|
+
if (items.length === 0) break
|
|
527
|
+
await callback(items)
|
|
528
|
+
if (items.length < size) break
|
|
529
|
+
page++
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
278
533
|
/** Return the generated SQL and params without executing. */
|
|
279
534
|
toSQL(): { sql: string; params: unknown[] } {
|
|
280
535
|
return this.build('select')
|
|
281
536
|
}
|
|
282
537
|
|
|
283
538
|
// ---------------------------------------------------------------------------
|
|
284
|
-
// SQL building
|
|
539
|
+
// SQL building — SELECT / COUNT
|
|
285
540
|
// ---------------------------------------------------------------------------
|
|
286
541
|
|
|
287
542
|
private build(mode: 'select' | 'count'): { sql: string; params: unknown[] } {
|
|
288
543
|
const parts: string[] = []
|
|
289
544
|
const params: unknown[] = []
|
|
290
|
-
|
|
545
|
+
const paramIdx = { value: 1 }
|
|
291
546
|
|
|
292
547
|
// SELECT
|
|
293
548
|
if (mode === 'count') {
|
|
@@ -310,102 +565,340 @@ export default class QueryBuilder<T extends BaseModel> {
|
|
|
310
565
|
}
|
|
311
566
|
|
|
312
567
|
// WHERE
|
|
313
|
-
const whereParts:
|
|
568
|
+
const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
|
|
569
|
+
this.wheres,
|
|
570
|
+
params,
|
|
571
|
+
paramIdx,
|
|
572
|
+
true
|
|
573
|
+
)
|
|
574
|
+
if (whereParts.length > 0) {
|
|
575
|
+
parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
|
|
576
|
+
}
|
|
314
577
|
|
|
315
|
-
//
|
|
316
|
-
if (this.
|
|
578
|
+
// GROUP BY
|
|
579
|
+
if (this.groupBys.length > 0) {
|
|
580
|
+
const cols = this.groupBys.map(c => this.resolveColumn(c))
|
|
581
|
+
parts.push(`GROUP BY ${cols.join(', ')}`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// HAVING
|
|
585
|
+
if (this.havings.length > 0) {
|
|
586
|
+
const { parts: havingParts, booleans: havingBooleans } = this.buildClauseParts(
|
|
587
|
+
this.havings,
|
|
588
|
+
params,
|
|
589
|
+
paramIdx,
|
|
590
|
+
false,
|
|
591
|
+
true
|
|
592
|
+
)
|
|
593
|
+
if (havingParts.length > 0) {
|
|
594
|
+
parts.push(`HAVING ${this.joinWithBooleans(havingParts, havingBooleans)}`)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// count mode skips ORDER BY, LIMIT, OFFSET
|
|
599
|
+
if (mode === 'select') {
|
|
600
|
+
// ORDER BY
|
|
601
|
+
if (this.orderBys.length > 0) {
|
|
602
|
+
const clauses = this.orderBys.map(o => `${this.resolveColumn(o.column)} ${o.direction}`)
|
|
603
|
+
parts.push(`ORDER BY ${clauses.join(', ')}`)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// LIMIT
|
|
607
|
+
if (this.limitValue !== null) {
|
|
608
|
+
parts.push(`LIMIT ${this.limitValue}`)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// OFFSET
|
|
612
|
+
if (this.offsetValue !== null) {
|
|
613
|
+
parts.push(`OFFSET ${this.offsetValue}`)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { sql: parts.join(' '), params }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// SQL building — UPDATE / DELETE / INCREMENT
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
private buildUpdate(data: Record<string, unknown>): { sql: string; params: unknown[] } {
|
|
625
|
+
const parts: string[] = []
|
|
626
|
+
const params: unknown[] = []
|
|
627
|
+
const paramIdx = { value: 1 }
|
|
628
|
+
|
|
629
|
+
parts.push(`UPDATE "${this.primaryTable}"`)
|
|
630
|
+
|
|
631
|
+
const setClauses: string[] = []
|
|
632
|
+
for (const [key, val] of Object.entries(data)) {
|
|
633
|
+
const col = toSnakeCase(key)
|
|
634
|
+
params.push(this.dehydrateValue(val))
|
|
635
|
+
setClauses.push(`"${col}" = $${paramIdx.value++}`)
|
|
636
|
+
}
|
|
637
|
+
parts.push(`SET ${setClauses.join(', ')}`)
|
|
638
|
+
|
|
639
|
+
const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
|
|
640
|
+
this.wheres,
|
|
641
|
+
params,
|
|
642
|
+
paramIdx,
|
|
643
|
+
true
|
|
644
|
+
)
|
|
645
|
+
if (whereParts.length > 0) {
|
|
646
|
+
parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return { sql: parts.join(' '), params }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private buildDelete(): { sql: string; params: unknown[] } {
|
|
653
|
+
const parts: string[] = []
|
|
654
|
+
const params: unknown[] = []
|
|
655
|
+
const paramIdx = { value: 1 }
|
|
656
|
+
|
|
657
|
+
parts.push(`DELETE FROM "${this.primaryTable}"`)
|
|
658
|
+
|
|
659
|
+
const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
|
|
660
|
+
this.wheres,
|
|
661
|
+
params,
|
|
662
|
+
paramIdx,
|
|
663
|
+
true
|
|
664
|
+
)
|
|
665
|
+
if (whereParts.length > 0) {
|
|
666
|
+
parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return { sql: parts.join(' '), params }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private async adjustColumn(column: string, amount: number): Promise<number> {
|
|
673
|
+
const col = toSnakeCase(column)
|
|
674
|
+
const parts: string[] = []
|
|
675
|
+
const params: unknown[] = []
|
|
676
|
+
const paramIdx = { value: 1 }
|
|
677
|
+
|
|
678
|
+
parts.push(`UPDATE "${this.primaryTable}"`)
|
|
679
|
+
params.push(Math.abs(amount))
|
|
680
|
+
const op = amount >= 0 ? '+' : '-'
|
|
681
|
+
parts.push(`SET "${col}" = "${col}" ${op} $${paramIdx.value++}`)
|
|
682
|
+
|
|
683
|
+
const { parts: whereParts, booleans: whereBooleans } = this.buildClauseParts(
|
|
684
|
+
this.wheres,
|
|
685
|
+
params,
|
|
686
|
+
paramIdx,
|
|
687
|
+
true
|
|
688
|
+
)
|
|
689
|
+
if (whereParts.length > 0) {
|
|
690
|
+
parts.push(`WHERE ${this.joinWithBooleans(whereParts, whereBooleans)}`)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const db = this.modelClass.db
|
|
694
|
+
const result = await db.sql.unsafe(parts.join(' '), params)
|
|
695
|
+
return result.count
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Shared clause building
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
private buildClauseParts(
|
|
703
|
+
clauses: WhereClause[],
|
|
704
|
+
params: unknown[],
|
|
705
|
+
paramIdx: { value: number },
|
|
706
|
+
includeSoftDeletes: boolean,
|
|
707
|
+
rawColumns: boolean = false
|
|
708
|
+
): { parts: string[]; booleans: WhereBoolean[] } {
|
|
709
|
+
const parts: string[] = []
|
|
710
|
+
const booleans: WhereBoolean[] = []
|
|
711
|
+
|
|
712
|
+
if (includeSoftDeletes && this.modelClass.softDeletes && !this.includeTrashed) {
|
|
317
713
|
if (this.isOnlyTrashed) {
|
|
318
|
-
|
|
714
|
+
parts.push(`"${this.primaryTable}"."deleted_at" IS NOT NULL`)
|
|
319
715
|
} else {
|
|
320
|
-
|
|
716
|
+
parts.push(`"${this.primaryTable}"."deleted_at" IS NULL`)
|
|
321
717
|
}
|
|
718
|
+
booleans.push('AND')
|
|
322
719
|
}
|
|
323
720
|
|
|
324
|
-
|
|
721
|
+
const resolve = rawColumns
|
|
722
|
+
? (ref: string) => this.resolveSelectColumn(ref)
|
|
723
|
+
: (ref: string) => this.resolveColumn(ref)
|
|
724
|
+
|
|
725
|
+
for (const w of clauses) {
|
|
726
|
+
booleans.push(w.boolean)
|
|
325
727
|
switch (w.kind) {
|
|
326
728
|
case 'comparison': {
|
|
327
|
-
const col =
|
|
729
|
+
const col = resolve(w.column)
|
|
328
730
|
params.push(this.dehydrateValue(w.value))
|
|
329
|
-
|
|
731
|
+
parts.push(`${col} ${w.operator} $${paramIdx.value++}`)
|
|
330
732
|
break
|
|
331
733
|
}
|
|
332
734
|
case 'in': {
|
|
333
|
-
const col =
|
|
735
|
+
const col = resolve(w.column)
|
|
334
736
|
const placeholders = w.values.map(v => {
|
|
335
737
|
params.push(this.dehydrateValue(v))
|
|
336
|
-
return `$${paramIdx++}`
|
|
738
|
+
return `$${paramIdx.value++}`
|
|
337
739
|
})
|
|
338
|
-
|
|
740
|
+
parts.push(`${col} IN (${placeholders.join(', ')})`)
|
|
339
741
|
break
|
|
340
742
|
}
|
|
341
743
|
case 'not_in': {
|
|
342
|
-
const col =
|
|
744
|
+
const col = resolve(w.column)
|
|
343
745
|
const placeholders = w.values.map(v => {
|
|
344
746
|
params.push(this.dehydrateValue(v))
|
|
345
|
-
return `$${paramIdx++}`
|
|
747
|
+
return `$${paramIdx.value++}`
|
|
346
748
|
})
|
|
347
|
-
|
|
749
|
+
parts.push(`${col} NOT IN (${placeholders.join(', ')})`)
|
|
348
750
|
break
|
|
349
751
|
}
|
|
350
752
|
case 'null': {
|
|
351
|
-
const col =
|
|
352
|
-
|
|
753
|
+
const col = resolve(w.column)
|
|
754
|
+
parts.push(`${col} IS NULL`)
|
|
353
755
|
break
|
|
354
756
|
}
|
|
355
757
|
case 'not_null': {
|
|
356
|
-
const col =
|
|
357
|
-
|
|
758
|
+
const col = resolve(w.column)
|
|
759
|
+
parts.push(`${col} IS NOT NULL`)
|
|
358
760
|
break
|
|
359
761
|
}
|
|
360
762
|
case 'between': {
|
|
361
|
-
const col =
|
|
763
|
+
const col = resolve(w.column)
|
|
362
764
|
params.push(this.dehydrateValue(w.low))
|
|
363
765
|
params.push(this.dehydrateValue(w.high))
|
|
364
|
-
|
|
766
|
+
parts.push(`${col} BETWEEN $${paramIdx.value++} AND $${paramIdx.value++}`)
|
|
365
767
|
break
|
|
366
768
|
}
|
|
367
769
|
case 'raw': {
|
|
368
770
|
let rawSql = w.sql
|
|
369
771
|
for (const p of w.params) {
|
|
370
|
-
rawSql = rawSql.replace(`$${w.params.indexOf(p) + 1}`, `$${paramIdx++}`)
|
|
772
|
+
rawSql = rawSql.replace(`$${w.params.indexOf(p) + 1}`, `$${paramIdx.value++}`)
|
|
371
773
|
params.push(this.dehydrateValue(p))
|
|
372
774
|
}
|
|
373
|
-
|
|
775
|
+
parts.push(rawSql)
|
|
776
|
+
break
|
|
777
|
+
}
|
|
778
|
+
case 'group': {
|
|
779
|
+
const { parts: groupParts, booleans: groupBooleans } = this.buildClauseParts(
|
|
780
|
+
w.clauses,
|
|
781
|
+
params,
|
|
782
|
+
paramIdx,
|
|
783
|
+
false,
|
|
784
|
+
rawColumns
|
|
785
|
+
)
|
|
786
|
+
if (groupParts.length > 0) {
|
|
787
|
+
parts.push(`(${this.joinWithBooleans(groupParts, groupBooleans)})`)
|
|
788
|
+
}
|
|
374
789
|
break
|
|
375
790
|
}
|
|
376
791
|
}
|
|
377
792
|
}
|
|
378
793
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
794
|
+
return { parts, booleans }
|
|
795
|
+
}
|
|
382
796
|
|
|
383
|
-
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
797
|
+
private joinWithBooleans(parts: string[], booleans: WhereBoolean[]): string {
|
|
798
|
+
if (parts.length === 0) return ''
|
|
799
|
+
let result = parts[0]!
|
|
800
|
+
for (let i = 1; i < parts.length; i++) {
|
|
801
|
+
result += ` ${booleans[i]} ${parts[i]}`
|
|
387
802
|
}
|
|
803
|
+
return result
|
|
804
|
+
}
|
|
388
805
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// Eager loading
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
private async eagerLoad(models: T[]): Promise<void> {
|
|
811
|
+
if (this.eagerLoads.length === 0 || models.length === 0) return
|
|
812
|
+
|
|
813
|
+
const ctor = this.modelClass as unknown as Function
|
|
814
|
+
const refMetas: ReferenceMetadata[] = getReferenceMeta(ctor)
|
|
815
|
+
const assocMetas: AssociateMetadata[] = getAssociates(ctor)
|
|
816
|
+
|
|
817
|
+
for (const relation of this.eagerLoads) {
|
|
818
|
+
const refMeta = refMetas.find(r => r.property === relation)
|
|
819
|
+
if (refMeta) {
|
|
820
|
+
await this.eagerLoadReference(models, refMeta)
|
|
821
|
+
continue
|
|
395
822
|
}
|
|
396
823
|
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
|
|
824
|
+
const assocMeta = assocMetas.find(a => a.property === relation)
|
|
825
|
+
if (assocMeta) {
|
|
826
|
+
await this.eagerLoadAssociation(models, assocMeta)
|
|
827
|
+
continue
|
|
400
828
|
}
|
|
401
829
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
830
|
+
throw new Error(`Unknown relation "${relation}" on ${this.modelClass.name}`)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private async eagerLoadReference(models: T[], meta: ReferenceMetadata): Promise<void> {
|
|
835
|
+
const db = this.modelClass.db
|
|
836
|
+
const fkValues = [
|
|
837
|
+
...new Set(
|
|
838
|
+
models.map(m => (m as any)[meta.foreignKey]).filter(v => v !== null && v !== undefined)
|
|
839
|
+
),
|
|
840
|
+
]
|
|
841
|
+
|
|
842
|
+
if (fkValues.length === 0) {
|
|
843
|
+
for (const model of models) {
|
|
844
|
+
;(model as any)[meta.property] = null
|
|
405
845
|
}
|
|
846
|
+
return
|
|
406
847
|
}
|
|
407
848
|
|
|
408
|
-
|
|
849
|
+
const targetTable = toSnakeCase(meta.model)
|
|
850
|
+
const targetPKCol = toSnakeCase(meta.targetPK)
|
|
851
|
+
const placeholders = fkValues.map((_, i) => `$${i + 1}`).join(', ')
|
|
852
|
+
const rows = await db.sql.unsafe(
|
|
853
|
+
`SELECT * FROM "${targetTable}" WHERE "${targetPKCol}" IN (${placeholders})`,
|
|
854
|
+
fkValues
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
const lookup = new Map<unknown, Record<string, unknown>>()
|
|
858
|
+
for (const row of rows) {
|
|
859
|
+
const r = row as Record<string, unknown>
|
|
860
|
+
lookup.set(r[targetPKCol], hydrateRow(r))
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
for (const model of models) {
|
|
864
|
+
const fkVal = (model as any)[meta.foreignKey]
|
|
865
|
+
;(model as any)[meta.property] = fkVal != null ? (lookup.get(fkVal) ?? null) : null
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private async eagerLoadAssociation(models: T[], meta: AssociateMetadata): Promise<void> {
|
|
870
|
+
const db = this.modelClass.db
|
|
871
|
+
const ctor = this.modelClass as typeof BaseModel
|
|
872
|
+
const pkProp = ctor.primaryKeyProperty
|
|
873
|
+
|
|
874
|
+
const pkValues = models.map(m => (m as any)[pkProp])
|
|
875
|
+
if (pkValues.length === 0) return
|
|
876
|
+
|
|
877
|
+
const targetTable = toSnakeCase(meta.model)
|
|
878
|
+
const targetPKCol = toSnakeCase(meta.targetPK)
|
|
879
|
+
const placeholders = pkValues.map((_, i) => `$${i + 1}`).join(', ')
|
|
880
|
+
|
|
881
|
+
const rows = await db.sql.unsafe(
|
|
882
|
+
`SELECT t.*, p."${meta.foreignKey}" AS "_pivot_fk" ` +
|
|
883
|
+
`FROM "${targetTable}" t ` +
|
|
884
|
+
`INNER JOIN "${meta.through}" p ON p."${meta.otherKey}" = t."${targetPKCol}" ` +
|
|
885
|
+
`WHERE p."${meta.foreignKey}" IN (${placeholders})`,
|
|
886
|
+
pkValues
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
const grouped = new Map<unknown, Record<string, unknown>[]>()
|
|
890
|
+
for (const row of rows) {
|
|
891
|
+
const r = row as Record<string, unknown>
|
|
892
|
+
const fk = r._pivot_fk
|
|
893
|
+
delete r._pivot_fk
|
|
894
|
+
if (!grouped.has(fk)) grouped.set(fk, [])
|
|
895
|
+
grouped.get(fk)!.push(hydrateRow(r))
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
for (const model of models) {
|
|
899
|
+
const pk = (model as any)[pkProp]
|
|
900
|
+
;(model as any)[meta.property] = grouped.get(pk) ?? []
|
|
901
|
+
}
|
|
409
902
|
}
|
|
410
903
|
|
|
411
904
|
// ---------------------------------------------------------------------------
|