@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.
- package/README.md +19 -0
- package/package.json +77 -0
- package/src/DatabaseManager.ts +115 -0
- package/src/DatabaseServiceProvider.ts +39 -0
- package/src/contracts/Connection.ts +13 -0
- package/src/contracts/Grammar.ts +16 -0
- package/src/contracts/MongoConnection.ts +122 -0
- package/src/contracts/Paginator.ts +10 -0
- package/src/drivers/BaseGrammar.ts +220 -0
- package/src/drivers/MSSQLConnection.ts +154 -0
- package/src/drivers/MSSQLGrammar.ts +106 -0
- package/src/drivers/MongoConnection.ts +298 -0
- package/src/drivers/MongoQueryBuilderImpl.ts +77 -0
- package/src/drivers/MySQLConnection.ts +120 -0
- package/src/drivers/MySQLGrammar.ts +19 -0
- package/src/drivers/PostgresConnection.ts +125 -0
- package/src/drivers/PostgresGrammar.ts +24 -0
- package/src/drivers/SQLiteConnection.ts +125 -0
- package/src/drivers/SQLiteGrammar.ts +19 -0
- package/src/errors/ConnectionError.ts +10 -0
- package/src/errors/ModelNotFoundError.ts +14 -0
- package/src/errors/QueryError.ts +11 -0
- package/src/events/DatabaseEvents.ts +101 -0
- package/src/factories/Factory.ts +170 -0
- package/src/factories/Faker.ts +382 -0
- package/src/helpers/db.ts +37 -0
- package/src/index.ts +100 -0
- package/src/migrations/Migration.ts +12 -0
- package/src/migrations/MigrationRepository.ts +50 -0
- package/src/migrations/Migrator.ts +201 -0
- package/src/orm/Collection.ts +236 -0
- package/src/orm/Document.ts +202 -0
- package/src/orm/Model.ts +775 -0
- package/src/orm/ModelQueryBuilder.ts +415 -0
- package/src/orm/Scope.ts +39 -0
- package/src/orm/eagerLoad.ts +300 -0
- package/src/query/Builder.ts +456 -0
- package/src/query/Expression.ts +18 -0
- package/src/schema/Blueprint.ts +196 -0
- package/src/schema/ColumnDefinition.ts +93 -0
- package/src/schema/SchemaBuilder.ts +376 -0
- package/src/seeders/Seeder.ts +28 -0
package/src/orm/Model.ts
ADDED
|
@@ -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
|
+
}
|