@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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/auth/auth.ts +2 -1
  3. package/src/broadcast/broadcast_manager.ts +18 -5
  4. package/src/broadcast/client.ts +10 -4
  5. package/src/cache/cache_manager.ts +6 -2
  6. package/src/cache/http_cache.ts +1 -5
  7. package/src/core/container.ts +2 -6
  8. package/src/database/database.ts +11 -7
  9. package/src/database/migration/runner.ts +3 -1
  10. package/src/database/query_builder.ts +553 -60
  11. package/src/encryption/encryption_manager.ts +7 -1
  12. package/src/exceptions/errors.ts +1 -5
  13. package/src/exceptions/http_exception.ts +4 -1
  14. package/src/generators/api_generator.ts +8 -1
  15. package/src/generators/doc_generator.ts +33 -28
  16. package/src/generators/model_generator.ts +3 -1
  17. package/src/generators/test_generator.ts +81 -91
  18. package/src/i18n/helpers.ts +5 -1
  19. package/src/i18n/i18n_manager.ts +3 -1
  20. package/src/i18n/middleware.ts +2 -8
  21. package/src/mail/helpers.ts +1 -1
  22. package/src/mail/index.ts +4 -0
  23. package/src/mail/mail_manager.ts +20 -1
  24. package/src/mail/transports/alibaba_transport.ts +88 -0
  25. package/src/mail/transports/mailgun_transport.ts +74 -0
  26. package/src/mail/transports/resend_transport.ts +3 -4
  27. package/src/mail/transports/sendgrid_transport.ts +12 -9
  28. package/src/mail/transports/smtp_transport.ts +5 -5
  29. package/src/mail/types.ts +19 -1
  30. package/src/notification/channels/discord_channel.ts +6 -1
  31. package/src/notification/channels/webhook_channel.ts +8 -3
  32. package/src/notification/helpers.ts +7 -7
  33. package/src/notification/notification_manager.ts +7 -6
  34. package/src/orm/base_model.ts +4 -2
  35. package/src/queue/queue.ts +3 -1
  36. package/src/scheduler/cron.ts +12 -6
  37. package/src/scheduler/schedule.ts +17 -8
  38. package/src/session/session_manager.ts +3 -1
  39. package/src/storage/storage_manager.ts +3 -1
  40. package/src/view/compiler.ts +1 -3
  41. package/src/view/islands/island_builder.ts +4 -4
  42. package/src/view/islands/vue_plugin.ts +11 -15
  43. 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(column: string, operatorOrValue: unknown, value?: unknown): this {
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({ kind: 'comparison', column, operator: '=', value: operatorOrValue })
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({ kind: 'comparison', column, operator: operatorOrValue as string, value })
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
- // Terminal methods
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
- return rows.map((row: Record<string, unknown>) => Model.hydrate(row) as T)
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
- let paramIdx = 1
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: string[] = []
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
- // Soft delete handling
316
- if (this.modelClass.softDeletes && !this.includeTrashed) {
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
- whereParts.push(`"${this.primaryTable}"."deleted_at" IS NOT NULL`)
714
+ parts.push(`"${this.primaryTable}"."deleted_at" IS NOT NULL`)
319
715
  } else {
320
- whereParts.push(`"${this.primaryTable}"."deleted_at" IS NULL`)
716
+ parts.push(`"${this.primaryTable}"."deleted_at" IS NULL`)
321
717
  }
718
+ booleans.push('AND')
322
719
  }
323
720
 
324
- for (const w of this.wheres) {
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 = this.resolveColumn(w.column)
729
+ const col = resolve(w.column)
328
730
  params.push(this.dehydrateValue(w.value))
329
- whereParts.push(`${col} ${w.operator} $${paramIdx++}`)
731
+ parts.push(`${col} ${w.operator} $${paramIdx.value++}`)
330
732
  break
331
733
  }
332
734
  case 'in': {
333
- const col = this.resolveColumn(w.column)
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
- whereParts.push(`${col} IN (${placeholders.join(', ')})`)
740
+ parts.push(`${col} IN (${placeholders.join(', ')})`)
339
741
  break
340
742
  }
341
743
  case 'not_in': {
342
- const col = this.resolveColumn(w.column)
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
- whereParts.push(`${col} NOT IN (${placeholders.join(', ')})`)
749
+ parts.push(`${col} NOT IN (${placeholders.join(', ')})`)
348
750
  break
349
751
  }
350
752
  case 'null': {
351
- const col = this.resolveColumn(w.column)
352
- whereParts.push(`${col} IS NULL`)
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 = this.resolveColumn(w.column)
357
- whereParts.push(`${col} IS NOT NULL`)
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 = this.resolveColumn(w.column)
763
+ const col = resolve(w.column)
362
764
  params.push(this.dehydrateValue(w.low))
363
765
  params.push(this.dehydrateValue(w.high))
364
- whereParts.push(`${col} BETWEEN $${paramIdx++} AND $${paramIdx++}`)
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
- whereParts.push(rawSql)
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
- if (whereParts.length > 0) {
380
- parts.push(`WHERE ${whereParts.join(' AND ')}`)
381
- }
794
+ return { parts, booleans }
795
+ }
382
796
 
383
- // GROUP BY
384
- if (this.groupBys.length > 0) {
385
- const cols = this.groupBys.map(c => this.resolveColumn(c))
386
- parts.push(`GROUP BY ${cols.join(', ')}`)
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
- // count mode skips ORDER BY, LIMIT, OFFSET
390
- if (mode === 'select') {
391
- // ORDER BY
392
- if (this.orderBys.length > 0) {
393
- const clauses = this.orderBys.map(o => `${this.resolveColumn(o.column)} ${o.direction}`)
394
- parts.push(`ORDER BY ${clauses.join(', ')}`)
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
- // LIMIT
398
- if (this.limitValue !== null) {
399
- parts.push(`LIMIT ${this.limitValue}`)
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
- // OFFSET
403
- if (this.offsetValue !== null) {
404
- parts.push(`OFFSET ${this.offsetValue}`)
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
- return { sql: parts.join(' '), params }
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
  // ---------------------------------------------------------------------------