@mantiq/database 0.0.1

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 (42) hide show
  1. package/README.md +19 -0
  2. package/package.json +77 -0
  3. package/src/DatabaseManager.ts +115 -0
  4. package/src/DatabaseServiceProvider.ts +39 -0
  5. package/src/contracts/Connection.ts +13 -0
  6. package/src/contracts/Grammar.ts +16 -0
  7. package/src/contracts/MongoConnection.ts +122 -0
  8. package/src/contracts/Paginator.ts +10 -0
  9. package/src/drivers/BaseGrammar.ts +220 -0
  10. package/src/drivers/MSSQLConnection.ts +154 -0
  11. package/src/drivers/MSSQLGrammar.ts +106 -0
  12. package/src/drivers/MongoConnection.ts +298 -0
  13. package/src/drivers/MongoQueryBuilderImpl.ts +77 -0
  14. package/src/drivers/MySQLConnection.ts +120 -0
  15. package/src/drivers/MySQLGrammar.ts +19 -0
  16. package/src/drivers/PostgresConnection.ts +125 -0
  17. package/src/drivers/PostgresGrammar.ts +24 -0
  18. package/src/drivers/SQLiteConnection.ts +125 -0
  19. package/src/drivers/SQLiteGrammar.ts +19 -0
  20. package/src/errors/ConnectionError.ts +10 -0
  21. package/src/errors/ModelNotFoundError.ts +14 -0
  22. package/src/errors/QueryError.ts +11 -0
  23. package/src/events/DatabaseEvents.ts +101 -0
  24. package/src/factories/Factory.ts +170 -0
  25. package/src/factories/Faker.ts +382 -0
  26. package/src/helpers/db.ts +37 -0
  27. package/src/index.ts +100 -0
  28. package/src/migrations/Migration.ts +12 -0
  29. package/src/migrations/MigrationRepository.ts +50 -0
  30. package/src/migrations/Migrator.ts +201 -0
  31. package/src/orm/Collection.ts +236 -0
  32. package/src/orm/Document.ts +202 -0
  33. package/src/orm/Model.ts +775 -0
  34. package/src/orm/ModelQueryBuilder.ts +415 -0
  35. package/src/orm/Scope.ts +39 -0
  36. package/src/orm/eagerLoad.ts +300 -0
  37. package/src/query/Builder.ts +456 -0
  38. package/src/query/Expression.ts +18 -0
  39. package/src/schema/Blueprint.ts +196 -0
  40. package/src/schema/ColumnDefinition.ts +93 -0
  41. package/src/schema/SchemaBuilder.ts +376 -0
  42. package/src/seeders/Seeder.ts +28 -0
@@ -0,0 +1,775 @@
1
+ import { ModelQueryBuilder } from './ModelQueryBuilder.ts'
2
+ import { ModelNotFoundError } from '../errors/ModelNotFoundError.ts'
3
+ import { ClosureScope, type Scope } from './Scope.ts'
4
+ import type { EagerLoadSpec } from './eagerLoad.ts'
5
+ import type { DatabaseConnection } from '../contracts/Connection.ts'
6
+ import type { PaginationResult } from '../contracts/Paginator.ts'
7
+
8
+ type CastType = 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'
9
+
10
+ export interface ModelStatic<T extends Model> {
11
+ new (): T
12
+ connection: DatabaseConnection | null
13
+ table: string
14
+ primaryKey: string
15
+ fillable: string[]
16
+ guarded: string[]
17
+ hidden: string[]
18
+ casts: Record<string, CastType>
19
+ timestamps: boolean
20
+ softDelete: boolean
21
+ softDeleteColumn: string
22
+ _fireEvent: ((model: Model, event: string) => Promise<boolean>) | null
23
+ _globalScopes: Map<string, Scope>
24
+ _booted: Set<typeof Model>
25
+ addGlobalScope(name: string, scope: Scope | ((builder: ModelQueryBuilder<T>, model: typeof Model) => void)): void
26
+ hasGlobalScope(name: string): boolean
27
+ removeGlobalScope(name: string): void
28
+ getGlobalScopes(): Map<string, Scope>
29
+ booted(): void
30
+ bootIfNotBooted(): void
31
+ ensureOwnGlobalScopes(): void
32
+ withoutEvents<R>(callback: () => Promise<R> | R): Promise<R>
33
+ // Methods
34
+ query(): ModelQueryBuilder<T>
35
+ all(): Promise<T[]>
36
+ find(id: number | string): Promise<T | null>
37
+ findOrFail(id: number | string): Promise<T>
38
+ where(column: string, operatorOrValue?: any, value?: any): ModelQueryBuilder<T>
39
+ whereIn(column: string, values: any[]): ModelQueryBuilder<T>
40
+ with(...relations: EagerLoadSpec[]): ModelQueryBuilder<T>
41
+ first(): Promise<T | null>
42
+ firstOrFail(): Promise<T>
43
+ create(data: Record<string, any>): Promise<T>
44
+ updateOrCreate(conditions: Record<string, any>, data: Record<string, any>): Promise<T>
45
+ paginate(page?: number, perPage?: number): Promise<PaginationResult<T>>
46
+ count(): Promise<number>
47
+ setConnection(connection: DatabaseConnection): void
48
+ __callStatic(method: string, ...args: any[]): ModelQueryBuilder<T> | undefined
49
+ // Allow scope methods to exist
50
+ [key: string]: any
51
+ }
52
+
53
+ export abstract class Model {
54
+ // ── Static configuration (override in subclasses) ─────────────────────────
55
+
56
+ static connection: DatabaseConnection | null = null
57
+ static table: string = ''
58
+ static primaryKey: string = 'id'
59
+ static fillable: string[] = []
60
+ static guarded: string[] = ['id']
61
+ static hidden: string[] = []
62
+ static casts: Record<string, CastType> = {}
63
+ static timestamps = true
64
+ static softDelete = false
65
+ static softDeleteColumn = 'deleted_at'
66
+
67
+ /**
68
+ * Model event hook. Set by @mantiq/events when the events package is registered.
69
+ * Returns false if a cancellable event was cancelled.
70
+ */
71
+ static _fireEvent: ((model: Model, event: string) => Promise<boolean>) | null = null
72
+
73
+ /** Per-class global scope registry. */
74
+ static _globalScopes = new Map<string, Scope>()
75
+
76
+ /** Whether `booted()` has been called for this class. */
77
+ static _booted = new Set<typeof Model>()
78
+
79
+ // ── Instance state ────────────────────────────────────────────────────────
80
+
81
+ protected _attributes: Record<string, any> = {}
82
+ protected _original: Record<string, any> = {}
83
+ protected _exists = false
84
+ protected _relations: Record<string, any> = {}
85
+
86
+ // ── Query API (static methods) ────────────────────────────────────────────
87
+
88
+ static query<T extends Model>(this: ModelStatic<T>): ModelQueryBuilder<T> {
89
+ // Ensure booted() is called once per class
90
+ ;(this as any).bootIfNotBooted()
91
+
92
+ const conn = this.connection
93
+ if (!conn) throw new Error(`No connection set on model ${this.table}. Call Model.setConnection() first.`)
94
+
95
+ const tableName = this.table || snakeCase(this.name)
96
+ const builder = new ModelQueryBuilder<T>(
97
+ conn,
98
+ tableName,
99
+ (row) => {
100
+ const instance = new this()
101
+ instance.setRawAttributes(row)
102
+ instance._exists = true
103
+ return instance
104
+ },
105
+ this.softDelete ? this.softDeleteColumn : null,
106
+ )
107
+
108
+ // Register global scopes (applied lazily before execution)
109
+ builder.setModel(this)
110
+ const scopes = this.getGlobalScopes()
111
+ for (const [name, scope] of scopes) {
112
+ builder.registerGlobalScope(name, scope)
113
+ }
114
+
115
+ return builder
116
+ }
117
+
118
+ static async all<T extends Model>(this: ModelStatic<T>): Promise<T[]> {
119
+ return this.query().get()
120
+ }
121
+
122
+ static async find<T extends Model>(this: ModelStatic<T>, id: number | string): Promise<T | null> {
123
+ return this.query().find(id)
124
+ }
125
+
126
+ static async findOrFail<T extends Model>(this: ModelStatic<T>, id: number | string): Promise<T> {
127
+ const model = await this.find(id)
128
+ if (!model) throw new ModelNotFoundError(this.table)
129
+ return model
130
+ }
131
+
132
+ static where<T extends Model>(
133
+ this: ModelStatic<T>,
134
+ column: string,
135
+ operatorOrValue?: any,
136
+ value?: any,
137
+ ): ModelQueryBuilder<T> {
138
+ return this.query().where(column, operatorOrValue, value) as ModelQueryBuilder<T>
139
+ }
140
+
141
+ static whereIn<T extends Model>(
142
+ this: ModelStatic<T>,
143
+ column: string,
144
+ values: any[],
145
+ ): ModelQueryBuilder<T> {
146
+ return this.query().whereIn(column, values) as ModelQueryBuilder<T>
147
+ }
148
+
149
+ static async first<T extends Model>(this: ModelStatic<T>): Promise<T | null> {
150
+ return this.query().first()
151
+ }
152
+
153
+ static async firstOrFail<T extends Model>(this: ModelStatic<T>): Promise<T> {
154
+ return this.query().firstOrFail()
155
+ }
156
+
157
+ static async create<T extends Model>(this: ModelStatic<T>, data: Record<string, any>): Promise<T> {
158
+ const instance = new this() as T
159
+ instance.fill(data)
160
+ await instance.save()
161
+ return instance
162
+ }
163
+
164
+ static async updateOrCreate<T extends Model>(
165
+ this: ModelStatic<T>,
166
+ conditions: Record<string, any>,
167
+ data: Record<string, any>,
168
+ ): Promise<T> {
169
+ let q = this.query()
170
+ for (const [k, v] of Object.entries(conditions)) q = q.where(k, v) as ModelQueryBuilder<T>
171
+ const existing = await q.first()
172
+ if (existing) {
173
+ existing.fill(data)
174
+ await existing.save()
175
+ return existing
176
+ }
177
+ return this.create({ ...conditions, ...data })
178
+ }
179
+
180
+ static async paginate<T extends Model>(
181
+ this: ModelStatic<T>,
182
+ page = 1,
183
+ perPage = 15,
184
+ ): Promise<PaginationResult<T>> {
185
+ const result = await this.query().paginate(page, perPage)
186
+ return result as unknown as PaginationResult<T>
187
+ }
188
+
189
+ static async count<T extends Model>(this: ModelStatic<T>): Promise<number> {
190
+ return this.query().count()
191
+ }
192
+
193
+ static with<T extends Model>(
194
+ this: ModelStatic<T>,
195
+ ...relations: EagerLoadSpec[]
196
+ ): ModelQueryBuilder<T> {
197
+ return this.query().with(...relations)
198
+ }
199
+
200
+ /** Set the database connection for this model class */
201
+ static setConnection(connection: DatabaseConnection): void {
202
+ this.connection = connection
203
+ }
204
+
205
+ // ── Global Scopes ──────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Register a global scope on this model.
209
+ * Accepts a Scope instance or a closure.
210
+ *
211
+ * @example
212
+ * // Scope class
213
+ * User.addGlobalScope('active', new ActiveScope())
214
+ *
215
+ * // Closure
216
+ * User.addGlobalScope('active', (builder) => builder.where('is_active', true))
217
+ */
218
+ static addGlobalScope(
219
+ name: string,
220
+ scope: Scope | ((builder: ModelQueryBuilder<any>, model: typeof Model) => void),
221
+ ): void {
222
+ this.ensureOwnGlobalScopes()
223
+ if (typeof scope === 'function') {
224
+ this._globalScopes.set(name, new ClosureScope(scope as (builder: ModelQueryBuilder<any>, model: typeof Model) => void))
225
+ } else {
226
+ this._globalScopes.set(name, scope)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Check if a global scope is registered.
232
+ */
233
+ static hasGlobalScope(name: string): boolean {
234
+ return this._globalScopes.has(name)
235
+ }
236
+
237
+ /**
238
+ * Remove a global scope from this model class.
239
+ */
240
+ static removeGlobalScope(name: string): void {
241
+ this.ensureOwnGlobalScopes()
242
+ this._globalScopes.delete(name)
243
+ }
244
+
245
+ /**
246
+ * Get all registered global scopes.
247
+ */
248
+ static getGlobalScopes(): Map<string, Scope> {
249
+ return this._globalScopes
250
+ }
251
+
252
+ /**
253
+ * Override in subclasses to register global scopes and other boot-time config.
254
+ * Called once per class, automatically before the first query.
255
+ */
256
+ static booted(): void {
257
+ // Override in subclasses
258
+ }
259
+
260
+ /**
261
+ * Ensure booted() has been called for this specific class.
262
+ */
263
+ static bootIfNotBooted(): void {
264
+ if (!Model._booted.has(this)) {
265
+ Model._booted.add(this)
266
+ this.booted()
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Ensure this class has its own scope map (copy-on-write from parent).
272
+ */
273
+ static ensureOwnGlobalScopes(): void {
274
+ if (!Object.prototype.hasOwnProperty.call(this, '_globalScopes')) {
275
+ this._globalScopes = new Map(this._globalScopes)
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Scope support: calling a static method named `scope<Name>` on the class
281
+ * allows `Model.<name>()` as a shorthand.
282
+ * e.g., `static scopeActive(query) { ... }` → `User.active()`
283
+ *
284
+ * This is implemented via a static method that proxies unknown calls.
285
+ */
286
+ static __callStatic<T extends Model>(
287
+ this: ModelStatic<T>,
288
+ method: string,
289
+ ...args: any[]
290
+ ): ModelQueryBuilder<T> | undefined {
291
+ const scopeName = `scope${method.charAt(0).toUpperCase() + method.slice(1)}`
292
+ const scopeFn = (this as any)[scopeName]
293
+ if (typeof scopeFn === 'function') {
294
+ const query = this.query()
295
+ scopeFn.call(this, query, ...args)
296
+ return query
297
+ }
298
+ return undefined
299
+ }
300
+
301
+ // ── Instance methods ──────────────────────────────────────────────────────
302
+
303
+ fill(data: Record<string, any>): this {
304
+ const ctor = this.constructor as typeof Model
305
+ const fillable = ctor.fillable
306
+ const guarded = ctor.guarded
307
+
308
+ for (const [key, value] of Object.entries(data)) {
309
+ if (guarded.includes(key)) continue
310
+ if (fillable.length > 0 && !fillable.includes(key)) continue
311
+ this._attributes[key] = value
312
+ }
313
+
314
+ return this
315
+ }
316
+
317
+ forceFill(data: Record<string, any>): this {
318
+ for (const [key, value] of Object.entries(data)) {
319
+ this._attributes[key] = value
320
+ }
321
+ return this
322
+ }
323
+
324
+ setRawAttributes(attributes: Record<string, any>): this {
325
+ this._attributes = { ...attributes }
326
+ this._original = { ...attributes }
327
+ return this
328
+ }
329
+
330
+ getAttribute(key: string): any {
331
+ const ctor = this.constructor as typeof Model
332
+ const rawValue = this._attributes[key]
333
+
334
+ // Check for relation
335
+ if (key in this._relations) return this._relations[key]
336
+
337
+ // Check for custom getter (get<Key>Attribute pattern)
338
+ const getterName = `get${key.charAt(0).toUpperCase() + key.slice(1)}Attribute` as keyof this
339
+ if (typeof this[getterName] === 'function') {
340
+ return (this[getterName] as any)(rawValue)
341
+ }
342
+
343
+ // Apply cast
344
+ const castType = ctor.casts[key]
345
+ if (castType) return this.castAttribute(rawValue, castType)
346
+
347
+ return rawValue
348
+ }
349
+
350
+ setAttribute(key: string, value: any): this {
351
+ const ctor = this.constructor as typeof Model
352
+
353
+ // Check for custom setter
354
+ const setterName = `set${key.charAt(0).toUpperCase() + key.slice(1)}Attribute` as keyof this
355
+ if (typeof this[setterName] === 'function') {
356
+ ;(this[setterName] as any)(value)
357
+ return this
358
+ }
359
+
360
+ this._attributes[key] = value
361
+ return this
362
+ }
363
+
364
+ get(key: string): any {
365
+ return this.getAttribute(key)
366
+ }
367
+
368
+ set(key: string, value: any): this {
369
+ return this.setAttribute(key, value)
370
+ }
371
+
372
+ getKey(): any {
373
+ const ctor = this.constructor as typeof Model
374
+ return this._attributes[ctor.primaryKey]
375
+ }
376
+
377
+ isDirty(key?: string): boolean {
378
+ if (key) return this._attributes[key] !== this._original[key]
379
+ return Object.keys(this._attributes).some((k) => this._attributes[k] !== this._original[k])
380
+ }
381
+
382
+ getDirty(): Record<string, any> {
383
+ const dirty: Record<string, any> = {}
384
+ for (const [k, v] of Object.entries(this._attributes)) {
385
+ if (v !== this._original[k]) dirty[k] = v
386
+ }
387
+ return dirty
388
+ }
389
+
390
+ isClean(key?: string): boolean {
391
+ return !this.isDirty(key)
392
+ }
393
+
394
+ wasChanged(key?: string): boolean {
395
+ return this.isDirty(key)
396
+ }
397
+
398
+ toObject(): Record<string, any> {
399
+ const ctor = this.constructor as typeof Model
400
+ const obj: Record<string, any> = {}
401
+
402
+ for (const key of Object.keys(this._attributes)) {
403
+ if (ctor.hidden.includes(key)) continue
404
+ obj[key] = this.getAttribute(key)
405
+ }
406
+
407
+ // Include loaded relations
408
+ for (const [k, v] of Object.entries(this._relations)) {
409
+ obj[k] = v
410
+ }
411
+
412
+ return obj
413
+ }
414
+
415
+ toJSON(): Record<string, any> {
416
+ return this.toObject()
417
+ }
418
+
419
+ // ── Persistence ────────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Fire a model event if the events system is installed.
423
+ * Returns false if a cancellable event was cancelled.
424
+ */
425
+ protected async fireModelEvent(event: string): Promise<boolean> {
426
+ const fire = (this.constructor as typeof Model)._fireEvent
427
+ if (!fire) return true
428
+ return fire(this, event)
429
+ }
430
+
431
+ async save(): Promise<this> {
432
+ const ctor = this.constructor as typeof Model
433
+ if (!ctor.connection) throw new Error(`No connection set on model ${ctor.table}`)
434
+
435
+ // saving (cancellable)
436
+ if (await this.fireModelEvent('saving') === false) return this
437
+
438
+ const table = ctor.table || snakeCase(ctor.name)
439
+ const now = new Date()
440
+
441
+ if (this._exists) {
442
+ const dirty = this.getDirty()
443
+ if (Object.keys(dirty).length === 0) return this
444
+
445
+ // updating (cancellable)
446
+ if (await this.fireModelEvent('updating') === false) return this
447
+
448
+ if (ctor.timestamps && 'updated_at' in this._attributes) {
449
+ dirty['updated_at'] = now
450
+ }
451
+
452
+ await ctor.connection.table(table)
453
+ .where(ctor.primaryKey, this.getKey())
454
+ .update(dirty)
455
+
456
+ this._original = { ...this._attributes }
457
+
458
+ await this.fireModelEvent('updated')
459
+ } else {
460
+ // creating (cancellable)
461
+ if (await this.fireModelEvent('creating') === false) return this
462
+
463
+ if (ctor.timestamps) {
464
+ if (!this._attributes['created_at']) this._attributes['created_at'] = now
465
+ if (!this._attributes['updated_at']) this._attributes['updated_at'] = now
466
+ }
467
+
468
+ const id = await ctor.connection.table(table).insertGetId(this._attributes)
469
+ this._attributes[ctor.primaryKey] = Number(id)
470
+ this._original = { ...this._attributes }
471
+ this._exists = true
472
+
473
+ await this.fireModelEvent('created')
474
+ }
475
+
476
+ await this.fireModelEvent('saved')
477
+ return this
478
+ }
479
+
480
+ async delete(): Promise<boolean> {
481
+ const ctor = this.constructor as typeof Model
482
+ if (!ctor.connection || !this._exists) return false
483
+
484
+ // deleting (cancellable)
485
+ if (await this.fireModelEvent('deleting') === false) return false
486
+
487
+ const table = ctor.table || snakeCase(ctor.name)
488
+
489
+ if (ctor.softDelete) {
490
+ await ctor.connection.table(table)
491
+ .where(ctor.primaryKey, this.getKey())
492
+ .update({ [ctor.softDeleteColumn]: new Date() })
493
+ this._attributes[ctor.softDeleteColumn] = new Date()
494
+
495
+ await this.fireModelEvent('trashed')
496
+ } else {
497
+ await ctor.connection.table(table).where(ctor.primaryKey, this.getKey()).delete()
498
+ this._exists = false
499
+ }
500
+
501
+ await this.fireModelEvent('deleted')
502
+ return true
503
+ }
504
+
505
+ async forceDelete(): Promise<boolean> {
506
+ const ctor = this.constructor as typeof Model
507
+ if (!ctor.connection || !this._exists) return false
508
+
509
+ // forceDeleting (cancellable)
510
+ if (await this.fireModelEvent('forceDeleting') === false) return false
511
+
512
+ const table = ctor.table || snakeCase(ctor.name)
513
+ await ctor.connection.table(table).where(ctor.primaryKey, this.getKey()).delete()
514
+ this._exists = false
515
+
516
+ await this.fireModelEvent('forceDeleted')
517
+ return true
518
+ }
519
+
520
+ async restore(): Promise<boolean> {
521
+ const ctor = this.constructor as typeof Model
522
+ if (!ctor.softDelete || !ctor.connection) return false
523
+
524
+ // restoring (cancellable)
525
+ if (await this.fireModelEvent('restoring') === false) return false
526
+
527
+ const table = ctor.table || snakeCase(ctor.name)
528
+ await ctor.connection.table(table)
529
+ .where(ctor.primaryKey, this.getKey())
530
+ .update({ [ctor.softDeleteColumn]: null })
531
+
532
+ this._attributes[ctor.softDeleteColumn] = null
533
+
534
+ await this.fireModelEvent('restored')
535
+ return true
536
+ }
537
+
538
+ isTrashed(): boolean {
539
+ const ctor = this.constructor as typeof Model
540
+ return ctor.softDelete && this._attributes[ctor.softDeleteColumn] != null
541
+ }
542
+
543
+ // ── Replication ──────────────────────────────────────────────────────────
544
+
545
+ /**
546
+ * Create an unsaved copy of this model, optionally excluding certain attributes.
547
+ * The clone has no primary key and _exists = false, so save() will INSERT.
548
+ *
549
+ * @example
550
+ * const copy = user.replicate() // all fillable attributes
551
+ * const copy = user.replicate(['email']) // exclude email
552
+ */
553
+ replicate(except?: string[]): this {
554
+ const ctor = this.constructor as typeof Model
555
+ const clone = new (this.constructor as any)() as this
556
+ const excludeKeys = new Set([
557
+ ctor.primaryKey,
558
+ ...(ctor.timestamps ? ['created_at', 'updated_at'] : []),
559
+ ...(ctor.softDelete ? [ctor.softDeleteColumn] : []),
560
+ ...(except ?? []),
561
+ ])
562
+
563
+ for (const [key, value] of Object.entries(this._attributes)) {
564
+ if (!excludeKeys.has(key)) {
565
+ clone._attributes[key] = value
566
+ }
567
+ }
568
+
569
+ return clone
570
+ }
571
+
572
+ // ── Event control ────────────────────────────────────────────────────────
573
+
574
+ /**
575
+ * Execute a callback with model events disabled for this model class.
576
+ *
577
+ * @example
578
+ * await User.withoutEvents(async () => {
579
+ * await User.create({ name: 'Seed User' })
580
+ * })
581
+ */
582
+ static async withoutEvents<R>(callback: () => Promise<R> | R): Promise<R> {
583
+ const previous = this._fireEvent
584
+ this._fireEvent = null
585
+ try {
586
+ return await callback()
587
+ } finally {
588
+ this._fireEvent = previous
589
+ }
590
+ }
591
+
592
+ // ── Relations ─────────────────────────────────────────────────────────────
593
+
594
+ protected hasOne<R extends Model>(
595
+ related: ModelStatic<R>,
596
+ foreignKey?: string,
597
+ localKey?: string,
598
+ ) {
599
+ const ctor = this.constructor as typeof Model
600
+ const fk = foreignKey ?? `${snakeCase(ctor.name)}_id`
601
+ const lk = localKey ?? ctor.primaryKey
602
+ return new HasOneRelation<R>(related, this._attributes[lk], fk)
603
+ }
604
+
605
+ protected hasMany<R extends Model>(
606
+ related: ModelStatic<R>,
607
+ foreignKey?: string,
608
+ localKey?: string,
609
+ ) {
610
+ const ctor = this.constructor as typeof Model
611
+ const fk = foreignKey ?? `${snakeCase(ctor.name)}_id`
612
+ const lk = localKey ?? ctor.primaryKey
613
+ return new HasManyRelation<R>(related, this._attributes[lk], fk)
614
+ }
615
+
616
+ protected belongsTo<R extends Model>(
617
+ related: ModelStatic<R>,
618
+ foreignKey?: string,
619
+ ownerKey?: string,
620
+ ) {
621
+ const relatedCtor = related as typeof Model
622
+ const fk = foreignKey ?? `${snakeCase(relatedCtor.name)}_id`
623
+ const ok = ownerKey ?? relatedCtor.primaryKey
624
+ return new BelongsToRelation<R>(related, this._attributes[fk], ok)
625
+ }
626
+
627
+ protected belongsToMany<R extends Model>(
628
+ related: ModelStatic<R>,
629
+ pivotTable?: string,
630
+ foreignKey?: string,
631
+ relatedKey?: string,
632
+ ) {
633
+ const ctor = this.constructor as typeof Model
634
+ const relatedCtor = related as typeof Model
635
+ const pivot = pivotTable ?? [snakeCase(ctor.name), snakeCase(relatedCtor.name)].sort().join('_')
636
+ const fk = foreignKey ?? `${snakeCase(ctor.name)}_id`
637
+ const rk = relatedKey ?? `${snakeCase(relatedCtor.name)}_id`
638
+ return new BelongsToManyRelation<R>(related, this._attributes[ctor.primaryKey], pivot, fk, rk)
639
+ }
640
+
641
+ // ── Private helpers ────────────────────────────────────────────────────────
642
+
643
+ private castAttribute(value: any, type: CastType): any {
644
+ if (value === null || value === undefined) return value
645
+ switch (type) {
646
+ case 'int': return parseInt(value, 10)
647
+ case 'float': return parseFloat(value)
648
+ case 'boolean': return Boolean(value) && value !== '0' && value !== 0
649
+ case 'string': return String(value)
650
+ case 'json':
651
+ case 'array':
652
+ if (typeof value === 'string') {
653
+ try { return JSON.parse(value) } catch { return value }
654
+ }
655
+ return value
656
+ case 'date': return new Date(value)
657
+ case 'datetime': return new Date(value)
658
+ default: return value
659
+ }
660
+ }
661
+ }
662
+
663
+ // ── Relation classes ──────────────────────────────────────────────────────────
664
+
665
+ export class HasOneRelation<T extends Model> {
666
+ constructor(
667
+ private readonly related: ModelStatic<T>,
668
+ private readonly parentId: any,
669
+ private readonly foreignKey: string,
670
+ ) {}
671
+
672
+ async get(): Promise<T | null> {
673
+ return this.related.where(this.foreignKey, this.parentId).first()
674
+ }
675
+
676
+ async getOrFail(): Promise<T> {
677
+ const result = await this.get()
678
+ if (!result) throw new ModelNotFoundError((this.related as typeof Model).table)
679
+ return result
680
+ }
681
+ }
682
+
683
+ export class HasManyRelation<T extends Model> {
684
+ constructor(
685
+ private readonly related: ModelStatic<T>,
686
+ private readonly parentId: any,
687
+ private readonly foreignKey: string,
688
+ ) {}
689
+
690
+ query(): ModelQueryBuilder<T> {
691
+ return this.related.where(this.foreignKey, this.parentId)
692
+ }
693
+
694
+ async get(): Promise<T[]> {
695
+ return this.query().get()
696
+ }
697
+
698
+ async create(data: Record<string, any>): Promise<T> {
699
+ return this.related.create({ ...data, [this.foreignKey]: this.parentId })
700
+ }
701
+ }
702
+
703
+ export class BelongsToRelation<T extends Model> {
704
+ constructor(
705
+ private readonly related: ModelStatic<T>,
706
+ private readonly foreignId: any,
707
+ private readonly ownerKey: string,
708
+ ) {}
709
+
710
+ async get(): Promise<T | null> {
711
+ if (this.foreignId == null) return null
712
+ return this.related.where(this.ownerKey, this.foreignId).first()
713
+ }
714
+
715
+ async getOrFail(): Promise<T> {
716
+ const result = await this.get()
717
+ if (!result) throw new ModelNotFoundError((this.related as typeof Model).table)
718
+ return result
719
+ }
720
+ }
721
+
722
+ export class BelongsToManyRelation<T extends Model> {
723
+ constructor(
724
+ private readonly related: ModelStatic<T>,
725
+ private readonly parentId: any,
726
+ private readonly pivotTable: string,
727
+ private readonly foreignKey: string,
728
+ private readonly relatedKey: string,
729
+ ) {}
730
+
731
+ async get(): Promise<T[]> {
732
+ const ctor = this.related as typeof Model
733
+ if (!ctor.connection) return []
734
+
735
+ const pivotRows = await ctor.connection.table(this.pivotTable)
736
+ .where(this.foreignKey, this.parentId)
737
+ .pluck(this.relatedKey)
738
+
739
+ if (!pivotRows.length) return []
740
+ return this.related.whereIn(ctor.primaryKey, pivotRows).get()
741
+ }
742
+
743
+ async attach(ids: any[]): Promise<void> {
744
+ const ctor = this.related as typeof Model
745
+ if (!ctor.connection) return
746
+ for (const id of ids) {
747
+ await ctor.connection.table(this.pivotTable).insert({
748
+ [this.foreignKey]: this.parentId,
749
+ [this.relatedKey]: id,
750
+ })
751
+ }
752
+ }
753
+
754
+ async detach(ids?: any[]): Promise<void> {
755
+ const ctor = this.related as typeof Model
756
+ if (!ctor.connection) return
757
+ let q = ctor.connection.table(this.pivotTable).where(this.foreignKey, this.parentId)
758
+ if (ids) q = q.whereIn(this.relatedKey, ids)
759
+ await q.delete()
760
+ }
761
+
762
+ async sync(ids: any[]): Promise<void> {
763
+ await this.detach()
764
+ await this.attach(ids)
765
+ }
766
+ }
767
+
768
+ // ── Utility ────────────────────────────────────────────────────────────────────
769
+
770
+ function snakeCase(name: string): string {
771
+ return name
772
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
773
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
774
+ .toLowerCase()
775
+ }