create-phoenixjs 0.1.4 → 0.1.6
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/template/config/database.ts +13 -1
- package/template/database/migrations/2024_01_01_000000_create_test_users_cli_table.ts +16 -0
- package/template/database/migrations/20260108165704_TestCliMigration.ts +16 -0
- package/template/database/migrations/2026_01_08_16_57_04_CreateTestMigrationsTable.ts +21 -0
- package/template/framework/cli/artisan.ts +12 -0
- package/template/framework/cli/commands/MakeModelCommand.ts +33 -3
- package/template/framework/database/DatabaseManager.ts +133 -0
- package/template/framework/database/connection/Connection.ts +71 -0
- package/template/framework/database/connection/ConnectionFactory.ts +30 -0
- package/template/framework/database/connection/PostgresConnection.ts +159 -0
- package/template/framework/database/console/MakeMigrationCommand.ts +58 -0
- package/template/framework/database/console/MigrateCommand.ts +32 -0
- package/template/framework/database/console/MigrateResetCommand.ts +31 -0
- package/template/framework/database/console/MigrateRollbackCommand.ts +31 -0
- package/template/framework/database/console/MigrateStatusCommand.ts +38 -0
- package/template/framework/database/migrations/DatabaseMigrationRepository.ts +122 -0
- package/template/framework/database/migrations/Migration.ts +5 -0
- package/template/framework/database/migrations/MigrationRepository.ts +46 -0
- package/template/framework/database/migrations/Migrator.ts +249 -0
- package/template/framework/database/migrations/index.ts +4 -0
- package/template/framework/database/orm/BelongsTo.ts +246 -0
- package/template/framework/database/orm/BelongsToMany.ts +570 -0
- package/template/framework/database/orm/Builder.ts +160 -0
- package/template/framework/database/orm/EagerLoadingBuilder.ts +324 -0
- package/template/framework/database/orm/HasMany.ts +303 -0
- package/template/framework/database/orm/HasManyThrough.ts +282 -0
- package/template/framework/database/orm/HasOne.ts +201 -0
- package/template/framework/database/orm/HasOneThrough.ts +281 -0
- package/template/framework/database/orm/Model.ts +1802 -0
- package/template/framework/database/orm/Relation.ts +342 -0
- package/template/framework/database/orm/Scope.ts +14 -0
- package/template/framework/database/orm/SoftDeletes.ts +160 -0
- package/template/framework/database/orm/index.ts +54 -0
- package/template/framework/database/orm/scopes/SoftDeletingScope.ts +58 -0
- package/template/framework/database/pagination/LengthAwarePaginator.ts +55 -0
- package/template/framework/database/pagination/Paginator.ts +110 -0
- package/template/framework/database/pagination/index.ts +2 -0
- package/template/framework/database/query/Builder.ts +918 -0
- package/template/framework/database/query/DB.ts +139 -0
- package/template/framework/database/query/grammars/Grammar.ts +430 -0
- package/template/framework/database/query/grammars/PostgresGrammar.ts +224 -0
- package/template/framework/database/query/grammars/index.ts +6 -0
- package/template/framework/database/query/index.ts +8 -0
- package/template/framework/database/query/types.ts +196 -0
- package/template/framework/database/schema/Blueprint.ts +478 -0
- package/template/framework/database/schema/Schema.ts +149 -0
- package/template/framework/database/schema/SchemaBuilder.ts +152 -0
- package/template/framework/database/schema/grammars/PostgresSchemaGrammar.ts +293 -0
- package/template/framework/database/schema/grammars/index.ts +5 -0
- package/template/framework/database/schema/index.ts +9 -0
- package/template/package.json +4 -1
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
import { DB } from '../query/DB';
|
|
2
|
+
import { Builder } from './Builder';
|
|
3
|
+
import type { Binding, InsertValues, UpdateValues } from '../query/types';
|
|
4
|
+
|
|
5
|
+
// Relationship imports
|
|
6
|
+
import { HasOne } from './HasOne';
|
|
7
|
+
import { BelongsTo } from './BelongsTo';
|
|
8
|
+
import { HasMany } from './HasMany';
|
|
9
|
+
import { BelongsToMany } from './BelongsToMany';
|
|
10
|
+
import { HasOneThrough } from './HasOneThrough';
|
|
11
|
+
import { HasManyThrough } from './HasManyThrough';
|
|
12
|
+
|
|
13
|
+
// Eager loading import
|
|
14
|
+
import { EagerLoadingBuilder } from './EagerLoadingBuilder';
|
|
15
|
+
|
|
16
|
+
// Pagination imports
|
|
17
|
+
import { Paginator } from '../pagination/Paginator';
|
|
18
|
+
import { LengthAwarePaginator } from '../pagination/LengthAwarePaginator';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ModelNotFoundException - Thrown when a model is not found.
|
|
23
|
+
*/
|
|
24
|
+
export class ModelNotFoundException extends Error {
|
|
25
|
+
constructor(model: string, id: string | number) {
|
|
26
|
+
super(`No query results for model [${model}] with ID [${id}]`);
|
|
27
|
+
this.name = 'ModelNotFoundException';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Model event types for lifecycle hooks.
|
|
33
|
+
*/
|
|
34
|
+
export type ModelEventType =
|
|
35
|
+
| 'saving'
|
|
36
|
+
| 'saved'
|
|
37
|
+
| 'creating'
|
|
38
|
+
| 'created'
|
|
39
|
+
| 'updating'
|
|
40
|
+
| 'updated'
|
|
41
|
+
| 'deleting'
|
|
42
|
+
| 'deleted';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Event callback function type.
|
|
46
|
+
*/
|
|
47
|
+
export type ModelEventCallback<T extends Model = Model> = (model: T) => void | boolean | Promise<void | boolean>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Base Model class for Active Record pattern.
|
|
51
|
+
* Provides CRUD operations and query building capabilities.
|
|
52
|
+
*/
|
|
53
|
+
export abstract class Model {
|
|
54
|
+
/**
|
|
55
|
+
* The table associated with the model.
|
|
56
|
+
*/
|
|
57
|
+
protected table?: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The primary key for the model.
|
|
61
|
+
*/
|
|
62
|
+
protected primaryKey: string = 'id';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The model's attributes.
|
|
66
|
+
*/
|
|
67
|
+
protected attributes: Record<string, unknown> = {};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The model's original attributes (for dirty checking).
|
|
71
|
+
*/
|
|
72
|
+
protected original: Record<string, unknown> = {};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The attributes that are mass assignable.
|
|
76
|
+
* Child classes should override this with their fillable fields.
|
|
77
|
+
*
|
|
78
|
+
* @var string[]
|
|
79
|
+
*/
|
|
80
|
+
protected fillable: string[] = [];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The attributes that aren't mass assignable.
|
|
84
|
+
* Default to guarding everything for security.
|
|
85
|
+
*
|
|
86
|
+
* @var string[]
|
|
87
|
+
*/
|
|
88
|
+
protected guarded: string[] = ['*'];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the fillable fields for this model.
|
|
92
|
+
* This method properly handles inheritance by checking instance and prototype.
|
|
93
|
+
* Due to JS class initialization order, we need to check the prototype chain
|
|
94
|
+
* when called from the parent constructor before child properties are initialized.
|
|
95
|
+
*/
|
|
96
|
+
public getFillableFields(): string[] {
|
|
97
|
+
// First check if 'this' has its own fillable property (already initialized)
|
|
98
|
+
if (Object.prototype.hasOwnProperty.call(this, 'fillable') &&
|
|
99
|
+
Array.isArray(this.fillable) && this.fillable.length > 0) {
|
|
100
|
+
return this.fillable;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Walk up the prototype chain looking for fillable definition
|
|
104
|
+
let proto = Object.getPrototypeOf(this);
|
|
105
|
+
while (proto && proto.constructor.name !== 'Model' && proto.constructor.name !== 'Object') {
|
|
106
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'fillable');
|
|
107
|
+
if (descriptor && descriptor.value && Array.isArray(descriptor.value)) {
|
|
108
|
+
return descriptor.value;
|
|
109
|
+
}
|
|
110
|
+
proto = Object.getPrototypeOf(proto);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Default to the instance property
|
|
114
|
+
return this.fillable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the guarded fields for this model.
|
|
119
|
+
* This method properly handles inheritance by checking instance and prototype.
|
|
120
|
+
* Due to JS class initialization order, we need to check the prototype chain
|
|
121
|
+
* when called from the parent constructor before child properties are initialized.
|
|
122
|
+
*/
|
|
123
|
+
public getGuardedFields(): string[] {
|
|
124
|
+
// First check if 'this' has its own guarded property (already initialized)
|
|
125
|
+
// We need to detect if the child class set a different value
|
|
126
|
+
if (Object.prototype.hasOwnProperty.call(this, 'guarded')) {
|
|
127
|
+
return this.guarded;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Walk up the prototype chain looking for guarded definition
|
|
131
|
+
let proto = Object.getPrototypeOf(this);
|
|
132
|
+
while (proto && proto.constructor.name !== 'Model' && proto.constructor.name !== 'Object') {
|
|
133
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'guarded');
|
|
134
|
+
if (descriptor && descriptor.value && Array.isArray(descriptor.value)) {
|
|
135
|
+
return descriptor.value;
|
|
136
|
+
}
|
|
137
|
+
proto = Object.getPrototypeOf(proto);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Default to the instance property (will be ['*'] from Model base)
|
|
141
|
+
return this.guarded;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Indicates if the model is totally guarded.
|
|
146
|
+
*
|
|
147
|
+
* @var boolean
|
|
148
|
+
*/
|
|
149
|
+
protected static totallyGuarded: boolean = false;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Indicates if the model exists in the database.
|
|
153
|
+
*/
|
|
154
|
+
public exists: boolean = false;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Indicates if the model should be timestamped.
|
|
158
|
+
*/
|
|
159
|
+
protected timestamps: boolean = true;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The name of the "created at" column.
|
|
163
|
+
*/
|
|
164
|
+
protected static CREATED_AT: string = 'created_at';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The name of the "updated at" column.
|
|
168
|
+
*/
|
|
169
|
+
protected static UPDATED_AT: string = 'updated_at';
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The event listeners for the model.
|
|
173
|
+
* Stored per model class to avoid cross-contamination.
|
|
174
|
+
*/
|
|
175
|
+
protected static eventListeners: Map<string, Map<ModelEventType, ModelEventCallback[]>> = new Map();
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The loaded relationships for this model.
|
|
179
|
+
* Used for eager loading to store pre-loaded relationships.
|
|
180
|
+
*/
|
|
181
|
+
protected relations: Map<string, Model | Model[] | null> = new Map();
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Global scopes for the model.
|
|
185
|
+
*/
|
|
186
|
+
protected static globalScopes: Map<string, any> = new Map();
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Booted models.
|
|
190
|
+
*/
|
|
191
|
+
protected static booted: Map<string, boolean> = new Map();
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The attributes that should be cast to native types.
|
|
195
|
+
*/
|
|
196
|
+
protected casts: Record<string, string> = {};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The attributes that should be hidden for arrays.
|
|
200
|
+
*/
|
|
201
|
+
protected hidden: string[] = [];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The attributes that should be visible in arrays.
|
|
205
|
+
*/
|
|
206
|
+
protected visible: string[] = [];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The set of traits callbacks.
|
|
210
|
+
*/
|
|
211
|
+
protected static traitInitializers: Map<string, any[]> = new Map();
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Boot the model.
|
|
215
|
+
*/
|
|
216
|
+
public static boot(): void {
|
|
217
|
+
if (this.booted.has(this.name)) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.booted.set(this.name, true);
|
|
222
|
+
|
|
223
|
+
// Boot traits
|
|
224
|
+
this.bootTraits();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Boot all of the bootable traits on the model.
|
|
229
|
+
*/
|
|
230
|
+
protected static bootTraits(): void {
|
|
231
|
+
const booted = new Set<string>();
|
|
232
|
+
let current = this;
|
|
233
|
+
|
|
234
|
+
while (current && current.name !== 'Model') {
|
|
235
|
+
const keys = Object.getOwnPropertyNames(current);
|
|
236
|
+
for (const key of keys) {
|
|
237
|
+
if (key.startsWith('boot') && key !== 'boot' && key !== 'bootTraits') {
|
|
238
|
+
if (!booted.has(key) && typeof (this as any)[key] === 'function') {
|
|
239
|
+
(this as any)[key]();
|
|
240
|
+
booted.add(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
current = Object.getPrototypeOf(current);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Register a new global scope on the model.
|
|
250
|
+
*/
|
|
251
|
+
public static addGlobalScope(scope: any, implementation?: any): void {
|
|
252
|
+
if (typeof scope === 'string' && implementation) {
|
|
253
|
+
this.globalScopes.set(scope, implementation);
|
|
254
|
+
} else if (scope.constructor) {
|
|
255
|
+
this.globalScopes.set(scope.constructor.name, scope);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Create a new model instance.
|
|
262
|
+
*
|
|
263
|
+
* Note: We use forceFill in the constructor because constructor arguments
|
|
264
|
+
* are trusted (not external user input). Mass assignment protection is
|
|
265
|
+
* meant for external input like HTTP request bodies, not constructor init.
|
|
266
|
+
*/
|
|
267
|
+
constructor(attributes: Record<string, unknown> = {}) {
|
|
268
|
+
this.forceFill(attributes);
|
|
269
|
+
|
|
270
|
+
// Return a proxy to handle attribute access directly on the model instance
|
|
271
|
+
return new Proxy(this, {
|
|
272
|
+
get: (target, prop: string | symbol, receiver) => {
|
|
273
|
+
// Return class properties/methods first
|
|
274
|
+
if (prop in target) {
|
|
275
|
+
return Reflect.get(target, prop, receiver);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Then try attributes
|
|
279
|
+
if (typeof prop === 'string') {
|
|
280
|
+
// console.log(`Proxy get attribute: ${prop}`);
|
|
281
|
+
return target.getAttribute(prop);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return undefined;
|
|
285
|
+
},
|
|
286
|
+
set: (target, prop: string | symbol, value, receiver) => {
|
|
287
|
+
// If property exists on class, set it
|
|
288
|
+
if (prop in target) {
|
|
289
|
+
return Reflect.set(target, prop, value, receiver);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Otherwise set attribute
|
|
293
|
+
if (typeof prop === 'string') {
|
|
294
|
+
if (prop === 'table' || prop === 'primaryKey' || prop === 'forceDeleting') {
|
|
295
|
+
Object.defineProperty(target, prop, { value, writable: true, configurable: true, enumerable: true });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
target.setAttribute(prop, value);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ==========================================
|
|
308
|
+
// Attribute Handling
|
|
309
|
+
// ==========================================
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Fill the model with an array of attributes.
|
|
313
|
+
*
|
|
314
|
+
* @throws Error if mass assignment protection is violated (optional, traditionally just ignored)
|
|
315
|
+
*/
|
|
316
|
+
public fill(attributes: Record<string, unknown>): this {
|
|
317
|
+
for (const key in attributes) {
|
|
318
|
+
if (this.isFillable(key)) {
|
|
319
|
+
this.setAttribute(key, attributes[key]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return this;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Force fill the model with an array of attributes.
|
|
327
|
+
* This bypasses mass assignment protection.
|
|
328
|
+
*/
|
|
329
|
+
public forceFill(attributes: Record<string, unknown>): this {
|
|
330
|
+
for (const key in attributes) {
|
|
331
|
+
this.setAttribute(key, attributes[key]);
|
|
332
|
+
}
|
|
333
|
+
return this;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Determine if the given attribute may be mass assigned.
|
|
338
|
+
*/
|
|
339
|
+
public isFillable(key: string): boolean {
|
|
340
|
+
const fillableFields = this.getFillableFields();
|
|
341
|
+
const guardedFields = this.getGuardedFields();
|
|
342
|
+
|
|
343
|
+
// If the key is in the fillable array, it can be filled
|
|
344
|
+
if (fillableFields.includes(key)) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// If the model is totally guarded (guarded = ['*']), return false
|
|
349
|
+
if (guardedFields.length === 1 && guardedFields[0] === '*') {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// If the key is not in the guarded array, it can be filled
|
|
354
|
+
if (!guardedFields.includes(key) && !guardedFields.includes('*')) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get an attribute from the model.
|
|
363
|
+
*/
|
|
364
|
+
public getAttribute(key: string): unknown {
|
|
365
|
+
if (!key) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// If the attribute has an accessor, we will call that to get the value
|
|
370
|
+
if (this.hasGetMutator(key)) {
|
|
371
|
+
return this.mutateAttribute(key, this.attributes[key]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// If the attribute exists in the attributes array or has a "get" mutator
|
|
375
|
+
if (key in this.attributes) {
|
|
376
|
+
return this.getAttributeValue(key);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// If the attribute is a relation
|
|
380
|
+
if (this.relations.has(key)) {
|
|
381
|
+
return this.relations.get(key);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check if it's a method on the model (e.g., relationship method)
|
|
385
|
+
if (key in this && typeof (this as any)[key] === 'function') {
|
|
386
|
+
return (this as any)[key];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get a plain attribute (not a relationship).
|
|
394
|
+
*/
|
|
395
|
+
public getAttributeValue(key: string): unknown {
|
|
396
|
+
const value = this.attributes[key];
|
|
397
|
+
|
|
398
|
+
if (this.hasCast(key)) {
|
|
399
|
+
return this.castAttribute(key, value);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Set a given attribute on the model.
|
|
407
|
+
*/
|
|
408
|
+
public setAttribute(key: string, value: unknown): this {
|
|
409
|
+
// First check for a mutator
|
|
410
|
+
if (this.hasSetMutator(key)) {
|
|
411
|
+
this.setMutatedAttributeValue(key, value);
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// If the attribute is JSON, we might want to cast it before setting,
|
|
416
|
+
// but usually we cast on read. However, if it's explicitly 'json',
|
|
417
|
+
// we might want to ensure it's stored correctly if the driver doesn't handle object->json automatically.
|
|
418
|
+
// For postgres.js, objects are usually handled, but let's be safe.
|
|
419
|
+
// Actually, standard Eloquent casts on read mostly, but for setting 'json', it ensures it's an object if passed as string?
|
|
420
|
+
// or vice versa?
|
|
421
|
+
// In strict casting, we might want to serialize objects to string if the DB column expects string.
|
|
422
|
+
// But postgres.js handles JSONB columns with objects directly.
|
|
423
|
+
// So we will stick to simple assignment unless specific logic is needed.
|
|
424
|
+
|
|
425
|
+
this.attributes[key] = value;
|
|
426
|
+
return this;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get all attributes.
|
|
431
|
+
*/
|
|
432
|
+
public getAttributes(): Record<string, unknown> {
|
|
433
|
+
return { ...this.attributes };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Get the original attributes.
|
|
438
|
+
*/
|
|
439
|
+
public getOriginal(): Record<string, unknown> {
|
|
440
|
+
return { ...this.original };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Sync the original attributes with the current attributes.
|
|
445
|
+
*/
|
|
446
|
+
public syncOriginal(): this {
|
|
447
|
+
this.original = { ...this.attributes };
|
|
448
|
+
return this;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Get the attributes that have been changed.
|
|
453
|
+
*/
|
|
454
|
+
public getDirty(): Record<string, unknown> {
|
|
455
|
+
const dirty: Record<string, unknown> = {};
|
|
456
|
+
for (const key in this.attributes) {
|
|
457
|
+
if (this.attributes[key] !== this.original[key]) {
|
|
458
|
+
dirty[key] = this.attributes[key];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return dirty;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Check if the model or a given attribute has been modified.
|
|
466
|
+
*/
|
|
467
|
+
public isDirty(attribute?: string): boolean {
|
|
468
|
+
if (attribute) {
|
|
469
|
+
return this.attributes[attribute] !== this.original[attribute];
|
|
470
|
+
}
|
|
471
|
+
return Object.keys(this.getDirty()).length > 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ==========================================
|
|
475
|
+
// Eager Loading / Relation Storage
|
|
476
|
+
// ==========================================
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Set a loaded relationship on the model.
|
|
480
|
+
* Used internally by eager loading to store pre-loaded relationships.
|
|
481
|
+
*
|
|
482
|
+
* @param relation - The name of the relationship
|
|
483
|
+
* @param value - The loaded model(s) or null
|
|
484
|
+
*/
|
|
485
|
+
public setRelation(relation: string, value: Model | Model[] | null): this {
|
|
486
|
+
this.relations.set(relation, value);
|
|
487
|
+
return this;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get a loaded relationship from the model.
|
|
492
|
+
* Returns undefined if the relationship hasn't been loaded.
|
|
493
|
+
*
|
|
494
|
+
* @param relation - The name of the relationship
|
|
495
|
+
* @returns The loaded relationship value or undefined
|
|
496
|
+
*/
|
|
497
|
+
public getRelation(relation: string): Model | Model[] | null | undefined {
|
|
498
|
+
return this.relations.get(relation);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Check if a relationship has been loaded.
|
|
503
|
+
*
|
|
504
|
+
* @param relation - The name of the relationship
|
|
505
|
+
* @returns True if the relationship has been loaded
|
|
506
|
+
*/
|
|
507
|
+
public relationLoaded(relation: string): boolean {
|
|
508
|
+
return this.relations.has(relation);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Get all loaded relationships.
|
|
513
|
+
*
|
|
514
|
+
* @returns Map of all loaded relationships
|
|
515
|
+
*/
|
|
516
|
+
public getRelations(): Map<string, Model | Model[] | null> {
|
|
517
|
+
return new Map(this.relations);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Set multiple relationships at once.
|
|
522
|
+
*
|
|
523
|
+
* @param relations - Map of relationship names to values
|
|
524
|
+
*/
|
|
525
|
+
public setRelations(relations: Map<string, Model | Model[] | null>): this {
|
|
526
|
+
for (const [name, value] of relations) {
|
|
527
|
+
this.relations.set(name, value);
|
|
528
|
+
}
|
|
529
|
+
return this;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ==========================================
|
|
533
|
+
// Table & Key Information
|
|
534
|
+
// ==========================================
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Get the table associated with the model.
|
|
539
|
+
*/
|
|
540
|
+
public getTable(): string {
|
|
541
|
+
if (!this.table) {
|
|
542
|
+
this.table = this.inferTableName();
|
|
543
|
+
}
|
|
544
|
+
return this.table;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Get the primary key for the model.
|
|
549
|
+
*/
|
|
550
|
+
public getKeyName(): string {
|
|
551
|
+
return this.primaryKey;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Get the value of the model's primary key.
|
|
556
|
+
*/
|
|
557
|
+
public getKey(): unknown {
|
|
558
|
+
return this.getAttribute(this.getKeyName());
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Get the default foreign key name for the model.
|
|
563
|
+
*/
|
|
564
|
+
public getForeignKey(): string {
|
|
565
|
+
return this.constructor.name.toLowerCase() + '_id';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Convert the model instance to JSON.
|
|
570
|
+
*/
|
|
571
|
+
public toJSON(): Record<string, unknown> {
|
|
572
|
+
return this.toArray();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Convert the model instance to an array.
|
|
577
|
+
*/
|
|
578
|
+
public toArray(): Record<string, unknown> {
|
|
579
|
+
return {
|
|
580
|
+
...this.attributesToArray(),
|
|
581
|
+
...this.relationsToArray(),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Convert the model's attributes to an array.
|
|
587
|
+
*/
|
|
588
|
+
public attributesToArray(): Record<string, unknown> {
|
|
589
|
+
const attributes: Record<string, unknown> = {};
|
|
590
|
+
|
|
591
|
+
// Get all attributes, processing accessors
|
|
592
|
+
const keys = Object.keys(this.attributes);
|
|
593
|
+
|
|
594
|
+
// Also look for accessors that don't have matching attributes
|
|
595
|
+
const prototype = Object.getPrototypeOf(this);
|
|
596
|
+
const methodNames = Object.getOwnPropertyNames(prototype);
|
|
597
|
+
for (const name of methodNames) {
|
|
598
|
+
if (name.startsWith('get') && name.endsWith('Attribute') && name !== 'getAttribute') {
|
|
599
|
+
const key = this.snakeCase(name.slice(3, -9));
|
|
600
|
+
if (!keys.includes(key)) {
|
|
601
|
+
keys.push(key);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const key of keys) {
|
|
607
|
+
if (!this.isVisible(key)) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
attributes[key] = this.getAttribute(key);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return attributes;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Get the model's relationships in array form.
|
|
618
|
+
*/
|
|
619
|
+
public relationsToArray(): Record<string, unknown> {
|
|
620
|
+
const attributes: Record<string, unknown> = {};
|
|
621
|
+
|
|
622
|
+
for (const [key, value] of this.relations) {
|
|
623
|
+
if (!this.isVisible(key)) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (value instanceof Model) {
|
|
628
|
+
attributes[key] = value.toArray();
|
|
629
|
+
} else if (Array.isArray(value)) {
|
|
630
|
+
attributes[key] = value.map((model) => model instanceof Model ? model.toArray() : model);
|
|
631
|
+
} else {
|
|
632
|
+
attributes[key] = value;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return attributes;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Determine if an attribute should be visible.
|
|
641
|
+
*/
|
|
642
|
+
protected isVisible(key: string): boolean {
|
|
643
|
+
if (this.visible.length > 0) {
|
|
644
|
+
return this.visible.includes(key);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (this.hidden.length > 0) {
|
|
648
|
+
return !this.hidden.includes(key);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Infer the table name from the class name.
|
|
656
|
+
*/
|
|
657
|
+
protected inferTableName(): string {
|
|
658
|
+
return this.snakeCase(this.constructor.name) + 's';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Convert a string to snake case.
|
|
663
|
+
*/
|
|
664
|
+
protected snakeCase(str: string): string {
|
|
665
|
+
return str.replace(/[A-Z]/g, (letter, index) => {
|
|
666
|
+
return index === 0 ? letter.toLowerCase() : '_' + letter.toLowerCase();
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ==========================================
|
|
671
|
+
// Casting & Mutators
|
|
672
|
+
// ==========================================
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Determine if an attribute has a cast.
|
|
676
|
+
*/
|
|
677
|
+
public hasCast(key: string): boolean {
|
|
678
|
+
return key in this.casts;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Cast an attribute to a native PHP type.
|
|
683
|
+
*/
|
|
684
|
+
protected castAttribute(key: string, value: unknown): unknown {
|
|
685
|
+
if (value === null || value === undefined) {
|
|
686
|
+
return value;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const type = this.casts[key].trim().toLowerCase();
|
|
690
|
+
|
|
691
|
+
switch (type) {
|
|
692
|
+
case 'int':
|
|
693
|
+
case 'integer':
|
|
694
|
+
return typeof value === 'string' ? parseInt(value, 10) : Math.floor(value as number);
|
|
695
|
+
case 'real':
|
|
696
|
+
case 'float':
|
|
697
|
+
case 'double':
|
|
698
|
+
return typeof value === 'string' ? parseFloat(value) : value;
|
|
699
|
+
case 'string':
|
|
700
|
+
return String(value);
|
|
701
|
+
case 'bool':
|
|
702
|
+
case 'boolean':
|
|
703
|
+
return Boolean(value);
|
|
704
|
+
case 'object':
|
|
705
|
+
case 'json':
|
|
706
|
+
if (typeof value === 'string') {
|
|
707
|
+
try {
|
|
708
|
+
return JSON.parse(value);
|
|
709
|
+
} catch (e) {
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return value;
|
|
714
|
+
case 'date':
|
|
715
|
+
case 'datetime':
|
|
716
|
+
if (value instanceof Date) {
|
|
717
|
+
return value;
|
|
718
|
+
}
|
|
719
|
+
return new Date(value as string | number);
|
|
720
|
+
default:
|
|
721
|
+
return value;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Determine if a get mutator exists for an attribute.
|
|
727
|
+
*/
|
|
728
|
+
public hasGetMutator(key: string): boolean {
|
|
729
|
+
return typeof (this as any)[this.getMutatorMethodName(key)] === 'function';
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Determine if a set mutator exists for an attribute.
|
|
734
|
+
*/
|
|
735
|
+
public hasSetMutator(key: string): boolean {
|
|
736
|
+
return typeof (this as any)[this.setMutatorMethodName(key)] === 'function';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Get the value of an attribute using its mutator.
|
|
741
|
+
*/
|
|
742
|
+
protected mutateAttribute(key: string, value: unknown): unknown {
|
|
743
|
+
return (this as any)[this.getMutatorMethodName(key)](value);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Set the value of an attribute using its mutator.
|
|
748
|
+
*/
|
|
749
|
+
protected setMutatedAttributeValue(key: string, value: unknown): void {
|
|
750
|
+
(this as any)[this.setMutatorMethodName(key)](value);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Get the mutator method name for a "get" mutator.
|
|
755
|
+
*/
|
|
756
|
+
protected getMutatorMethodName(key: string): string {
|
|
757
|
+
const pascalKey = key
|
|
758
|
+
.split('_')
|
|
759
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
760
|
+
.join('');
|
|
761
|
+
return `get${pascalKey}Attribute`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Get the mutator method name for a "set" mutator.
|
|
766
|
+
*/
|
|
767
|
+
protected setMutatorMethodName(key: string): string {
|
|
768
|
+
const pascalKey = key
|
|
769
|
+
.split('_')
|
|
770
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
771
|
+
.join('');
|
|
772
|
+
return `set${pascalKey}Attribute`;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ==========================================
|
|
776
|
+
// Timestamps
|
|
777
|
+
// ==========================================
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Get a fresh timestamp for the model.
|
|
781
|
+
*/
|
|
782
|
+
public freshTimestamp(): Date {
|
|
783
|
+
return new Date();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Get a fresh timestamp string for the model.
|
|
788
|
+
*/
|
|
789
|
+
public freshTimestampString(): string {
|
|
790
|
+
return this.freshTimestamp().toISOString();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Get the name of the "created at" column.
|
|
795
|
+
*/
|
|
796
|
+
public getCreatedAtColumn(): string {
|
|
797
|
+
return (this.constructor as typeof Model).CREATED_AT;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Get the name of the "updated at" column.
|
|
802
|
+
*/
|
|
803
|
+
public getUpdatedAtColumn(): string {
|
|
804
|
+
return (this.constructor as typeof Model).UPDATED_AT;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Determine if the model uses timestamps.
|
|
809
|
+
*/
|
|
810
|
+
public usesTimestamps(): boolean {
|
|
811
|
+
return this.timestamps;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Set the timestamps for a create operation.
|
|
816
|
+
*/
|
|
817
|
+
protected setCreatedAt(): this {
|
|
818
|
+
if (!this.usesTimestamps()) {
|
|
819
|
+
return this;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const timestamp = this.freshTimestamp();
|
|
823
|
+
this.setAttribute(this.getCreatedAtColumn(), timestamp);
|
|
824
|
+
return this;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Set the timestamps for an update operation.
|
|
829
|
+
*/
|
|
830
|
+
protected setUpdatedAt(): this {
|
|
831
|
+
if (!this.usesTimestamps()) {
|
|
832
|
+
return this;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const timestamp = this.freshTimestamp();
|
|
836
|
+
this.setAttribute(this.getUpdatedAtColumn(), timestamp);
|
|
837
|
+
return this;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ==========================================
|
|
841
|
+
// Model Events
|
|
842
|
+
// ==========================================
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Get the event listeners for the model class.
|
|
846
|
+
*/
|
|
847
|
+
protected static getModelListeners(modelName: string): Map<ModelEventType, ModelEventCallback[]> {
|
|
848
|
+
if (!this.eventListeners.has(modelName)) {
|
|
849
|
+
this.eventListeners.set(modelName, new Map());
|
|
850
|
+
}
|
|
851
|
+
return this.eventListeners.get(modelName)!;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Register a model event callback.
|
|
856
|
+
*/
|
|
857
|
+
protected static registerModelEvent(event: ModelEventType, callback: ModelEventCallback): void {
|
|
858
|
+
const listeners = this.getModelListeners(this.name);
|
|
859
|
+
if (!listeners.has(event)) {
|
|
860
|
+
listeners.set(event, []);
|
|
861
|
+
}
|
|
862
|
+
listeners.get(event)!.push(callback);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Fire the given model event.
|
|
867
|
+
* Returns false if any listener returned false to halt the action.
|
|
868
|
+
*/
|
|
869
|
+
protected async fireModelEvent(event: ModelEventType): Promise<boolean> {
|
|
870
|
+
const modelName = this.constructor.name;
|
|
871
|
+
const listeners = (this.constructor as typeof Model).getModelListeners(modelName);
|
|
872
|
+
const eventListeners = listeners.get(event) || [];
|
|
873
|
+
|
|
874
|
+
for (const callback of eventListeners) {
|
|
875
|
+
const result = await callback(this);
|
|
876
|
+
// If callback returns false, halt the action
|
|
877
|
+
if (result === false) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Register a saving event listener.
|
|
887
|
+
*/
|
|
888
|
+
static saving<T extends Model>(
|
|
889
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
890
|
+
callback: ModelEventCallback<T>
|
|
891
|
+
): void {
|
|
892
|
+
(this as unknown as typeof Model).registerModelEvent('saving', callback as ModelEventCallback);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Register a saved event listener.
|
|
897
|
+
*/
|
|
898
|
+
static saved<T extends Model>(
|
|
899
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
900
|
+
callback: ModelEventCallback<T>
|
|
901
|
+
): void {
|
|
902
|
+
(this as unknown as typeof Model).registerModelEvent('saved', callback as ModelEventCallback);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Register a creating event listener.
|
|
907
|
+
*/
|
|
908
|
+
static creating<T extends Model>(
|
|
909
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
910
|
+
callback: ModelEventCallback<T>
|
|
911
|
+
): void {
|
|
912
|
+
(this as unknown as typeof Model).registerModelEvent('creating', callback as ModelEventCallback);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Register a created event listener.
|
|
917
|
+
*/
|
|
918
|
+
static created<T extends Model>(
|
|
919
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
920
|
+
callback: ModelEventCallback<T>
|
|
921
|
+
): void {
|
|
922
|
+
(this as unknown as typeof Model).registerModelEvent('created', callback as ModelEventCallback);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Register an updating event listener.
|
|
927
|
+
*/
|
|
928
|
+
static updating<T extends Model>(
|
|
929
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
930
|
+
callback: ModelEventCallback<T>
|
|
931
|
+
): void {
|
|
932
|
+
(this as unknown as typeof Model).registerModelEvent('updating', callback as ModelEventCallback);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Register an updated event listener.
|
|
937
|
+
*/
|
|
938
|
+
static updated<T extends Model>(
|
|
939
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
940
|
+
callback: ModelEventCallback<T>
|
|
941
|
+
): void {
|
|
942
|
+
(this as unknown as typeof Model).registerModelEvent('updated', callback as ModelEventCallback);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Register a deleting event listener.
|
|
947
|
+
*/
|
|
948
|
+
static deleting<T extends Model>(
|
|
949
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
950
|
+
callback: ModelEventCallback<T>
|
|
951
|
+
): void {
|
|
952
|
+
(this as unknown as typeof Model).registerModelEvent('deleting', callback as ModelEventCallback);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Register a deleted event listener.
|
|
957
|
+
*/
|
|
958
|
+
static deleted<T extends Model>(
|
|
959
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
960
|
+
callback: ModelEventCallback<T>
|
|
961
|
+
): void {
|
|
962
|
+
(this as unknown as typeof Model).registerModelEvent('deleted', callback as ModelEventCallback);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Clear all event listeners for this model class.
|
|
967
|
+
*/
|
|
968
|
+
static clearEventListeners<T extends Model>(
|
|
969
|
+
this: new (attrs?: Record<string, unknown>) => T
|
|
970
|
+
): void {
|
|
971
|
+
const modelClass = this as unknown as typeof Model;
|
|
972
|
+
modelClass.eventListeners.delete(modelClass.name);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ==========================================
|
|
976
|
+
// CRUD Operations (Instance Methods)
|
|
977
|
+
// ==========================================
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Save the model to the database.
|
|
981
|
+
* If the model exists, it updates; otherwise, it inserts.
|
|
982
|
+
*/
|
|
983
|
+
public async save(): Promise<boolean> {
|
|
984
|
+
// Fire 'saving' event
|
|
985
|
+
if (!(await this.fireModelEvent('saving'))) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let result: boolean;
|
|
990
|
+
|
|
991
|
+
if (this.exists) {
|
|
992
|
+
result = await this.performUpdate();
|
|
993
|
+
} else {
|
|
994
|
+
result = await this.performInsert();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (result) {
|
|
998
|
+
// Fire 'saved' event
|
|
999
|
+
await this.fireModelEvent('saved');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return result;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Perform an INSERT operation for a new model.
|
|
1007
|
+
*/
|
|
1008
|
+
protected async performInsert(): Promise<boolean> {
|
|
1009
|
+
// Fire 'creating' event
|
|
1010
|
+
if (!(await this.fireModelEvent('creating'))) {
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Set timestamps
|
|
1015
|
+
this.setCreatedAt();
|
|
1016
|
+
this.setUpdatedAt();
|
|
1017
|
+
|
|
1018
|
+
const query = this.newQuery();
|
|
1019
|
+
|
|
1020
|
+
// Insert and get the ID back
|
|
1021
|
+
const result = await query.insertReturning<Record<string, unknown>>(
|
|
1022
|
+
this.attributes as InsertValues,
|
|
1023
|
+
['*'] // Return all columns
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
if (result) {
|
|
1027
|
+
// Update model with returned values (including generated ID)
|
|
1028
|
+
this.fill(result);
|
|
1029
|
+
this.exists = true;
|
|
1030
|
+
this.syncOriginal();
|
|
1031
|
+
|
|
1032
|
+
// Fire 'created' event
|
|
1033
|
+
await this.fireModelEvent('created');
|
|
1034
|
+
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Perform an UPDATE operation for an existing model.
|
|
1043
|
+
*/
|
|
1044
|
+
protected async performUpdate(): Promise<boolean> {
|
|
1045
|
+
// Fire 'updating' event
|
|
1046
|
+
if (!(await this.fireModelEvent('updating'))) {
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Set updated_at timestamp
|
|
1051
|
+
this.setUpdatedAt();
|
|
1052
|
+
|
|
1053
|
+
const dirty = this.getDirty();
|
|
1054
|
+
|
|
1055
|
+
// Nothing to update (even after setting updated_at)
|
|
1056
|
+
if (Object.keys(dirty).length === 0) {
|
|
1057
|
+
return true;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const query = this.newQuery();
|
|
1061
|
+
const affected = await query
|
|
1062
|
+
.where(this.getKeyName(), '=', this.getKey() as Binding)
|
|
1063
|
+
.update(dirty as UpdateValues);
|
|
1064
|
+
|
|
1065
|
+
if (affected > 0) {
|
|
1066
|
+
this.syncOriginal();
|
|
1067
|
+
|
|
1068
|
+
// Fire 'updated' event
|
|
1069
|
+
await this.fireModelEvent('updated');
|
|
1070
|
+
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Update the model in the database.
|
|
1079
|
+
*/
|
|
1080
|
+
public async update(attributes: Record<string, unknown>): Promise<boolean> {
|
|
1081
|
+
if (!this.exists) {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
this.fill(attributes);
|
|
1086
|
+
return this.save();
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Delete the model from the database.
|
|
1091
|
+
*/
|
|
1092
|
+
public async delete(): Promise<boolean> {
|
|
1093
|
+
if (!this.exists) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Fire 'deleting' event
|
|
1098
|
+
if (!(await this.fireModelEvent('deleting'))) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const query = this.newQuery();
|
|
1103
|
+
const affected = await query
|
|
1104
|
+
.where(this.getKeyName(), '=', this.getKey() as Binding)
|
|
1105
|
+
.delete();
|
|
1106
|
+
|
|
1107
|
+
if (affected > 0) {
|
|
1108
|
+
this.exists = false;
|
|
1109
|
+
|
|
1110
|
+
// Fire 'deleted' event
|
|
1111
|
+
await this.fireModelEvent('deleted');
|
|
1112
|
+
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Refresh the model from the database.
|
|
1121
|
+
*/
|
|
1122
|
+
public async refresh(): Promise<this | null> {
|
|
1123
|
+
if (!this.exists) {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const fresh = await this.newQuery()
|
|
1128
|
+
.where(this.getKeyName(), '=', this.getKey() as Binding)
|
|
1129
|
+
.first();
|
|
1130
|
+
|
|
1131
|
+
if (fresh) {
|
|
1132
|
+
this.fill(fresh.getAttributes());
|
|
1133
|
+
this.syncOriginal();
|
|
1134
|
+
return this;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ==========================================
|
|
1141
|
+
// Query Builder Integration
|
|
1142
|
+
// ==========================================
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get a new query builder for the model's table.
|
|
1148
|
+
*/
|
|
1149
|
+
public newQuery(): Builder<this> {
|
|
1150
|
+
const query = DB.table(this.getTable());
|
|
1151
|
+
const builder = new Builder<this>(query);
|
|
1152
|
+
|
|
1153
|
+
builder.setModel(this);
|
|
1154
|
+
|
|
1155
|
+
// Apply global scopes
|
|
1156
|
+
const scopes = (this.constructor as typeof Model).globalScopes;
|
|
1157
|
+
if (scopes.size > 0) {
|
|
1158
|
+
for (const [name, scope] of scopes.entries()) {
|
|
1159
|
+
builder.withGlobalScope(name, scope);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return builder;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Create a new instance of the model with the given attributes.
|
|
1168
|
+
*/
|
|
1169
|
+
public newInstance(
|
|
1170
|
+
attributes: Record<string, unknown> = {},
|
|
1171
|
+
exists: boolean = false
|
|
1172
|
+
): this {
|
|
1173
|
+
const ModelClass = this.constructor as new (attrs?: Record<string, unknown>) => this;
|
|
1174
|
+
|
|
1175
|
+
// If hydrating (exists=true), bypass constructor assignment to avoid mass assignment protection
|
|
1176
|
+
// and use forceFill() instead.
|
|
1177
|
+
const instance = new ModelClass(exists ? {} : attributes);
|
|
1178
|
+
|
|
1179
|
+
if (exists) {
|
|
1180
|
+
instance.forceFill(attributes);
|
|
1181
|
+
instance.exists = true;
|
|
1182
|
+
instance.syncOriginal();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return instance;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Convert a database row to a model instance.
|
|
1190
|
+
*/
|
|
1191
|
+
protected hydrateModel(row: Record<string, unknown>): this {
|
|
1192
|
+
return this.newInstance(row, true);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Convert multiple database rows to model instances.
|
|
1197
|
+
*/
|
|
1198
|
+
protected hydrateModels(rows: Record<string, unknown>[]): this[] {
|
|
1199
|
+
return rows.map((row) => this.hydrateModel(row));
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ==========================================
|
|
1203
|
+
// Static CRUD Methods
|
|
1204
|
+
// ==========================================
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Create a new model and persist it to the database.
|
|
1208
|
+
*/
|
|
1209
|
+
static async create<T extends Model>(
|
|
1210
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1211
|
+
attributes: Record<string, unknown>
|
|
1212
|
+
): Promise<T> {
|
|
1213
|
+
const instance = new this(attributes);
|
|
1214
|
+
// Ensure boot is called
|
|
1215
|
+
(this as unknown as typeof Model).boot();
|
|
1216
|
+
|
|
1217
|
+
await instance.save();
|
|
1218
|
+
return instance;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Insert multiple records into the database.
|
|
1223
|
+
*/
|
|
1224
|
+
static async insert<T extends Model>(
|
|
1225
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1226
|
+
records: InsertValues[]
|
|
1227
|
+
): Promise<boolean> {
|
|
1228
|
+
const instance = new this();
|
|
1229
|
+
return instance.newQuery().insert(records);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Find a model by its primary key.
|
|
1234
|
+
*/
|
|
1235
|
+
static async find<T extends Model>(
|
|
1236
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1237
|
+
id: number | string
|
|
1238
|
+
): Promise<T | null> {
|
|
1239
|
+
const instance = new this();
|
|
1240
|
+
return instance.newQuery()
|
|
1241
|
+
.where(instance.getKeyName(), '=', id)
|
|
1242
|
+
.first();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Find a model by its primary key or throw an exception.
|
|
1247
|
+
*/
|
|
1248
|
+
static async findOrFail<T extends Model>(
|
|
1249
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1250
|
+
id: number | string
|
|
1251
|
+
): Promise<T> {
|
|
1252
|
+
const result = await (this as unknown as typeof Model).find.call(this, id);
|
|
1253
|
+
|
|
1254
|
+
if (!result) {
|
|
1255
|
+
throw new ModelNotFoundException(this.name, id);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return result as T;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Get all models from the database.
|
|
1263
|
+
*/
|
|
1264
|
+
static async all<T extends Model>(
|
|
1265
|
+
this: new (attrs?: Record<string, unknown>) => T
|
|
1266
|
+
): Promise<T[]> {
|
|
1267
|
+
const instance = new this();
|
|
1268
|
+
return instance.newQuery().get();
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Get the first model from the database.
|
|
1273
|
+
*/
|
|
1274
|
+
static async first<T extends Model>(
|
|
1275
|
+
this: new (attrs?: Record<string, unknown>) => T
|
|
1276
|
+
): Promise<T | null> {
|
|
1277
|
+
const instance = new this();
|
|
1278
|
+
return instance.newQuery().first();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Destroy model(s) by primary key.
|
|
1283
|
+
*/
|
|
1284
|
+
static async destroy<T extends Model>(
|
|
1285
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1286
|
+
ids: number | string | (number | string)[]
|
|
1287
|
+
): Promise<number> {
|
|
1288
|
+
const instance = new this();
|
|
1289
|
+
const idArray = Array.isArray(ids) ? ids : [ids];
|
|
1290
|
+
|
|
1291
|
+
if (idArray.length === 0) {
|
|
1292
|
+
return 0;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return instance.newQuery()
|
|
1296
|
+
.whereIn(instance.getKeyName(), idArray)
|
|
1297
|
+
.delete();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Get a fresh query builder for the model.
|
|
1302
|
+
*/
|
|
1303
|
+
static query<T extends Model>(
|
|
1304
|
+
this: new (attrs?: Record<string, unknown>) => T
|
|
1305
|
+
): Builder {
|
|
1306
|
+
const instance = new this();
|
|
1307
|
+
return instance.newQuery();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Add a WHERE clause and return the query builder.
|
|
1312
|
+
*/
|
|
1313
|
+
static where<T extends Model>(
|
|
1314
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1315
|
+
column: string,
|
|
1316
|
+
operatorOrValue?: string | number | boolean | null,
|
|
1317
|
+
value?: string | number | boolean | null
|
|
1318
|
+
): Builder {
|
|
1319
|
+
const instance = new this();
|
|
1320
|
+
return instance.newQuery().where(column, operatorOrValue, value);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Add a WHERE IN clause and return the query builder.
|
|
1325
|
+
*/
|
|
1326
|
+
static whereIn<T extends Model>(
|
|
1327
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1328
|
+
column: string,
|
|
1329
|
+
values: (string | number | boolean | null)[]
|
|
1330
|
+
): Builder {
|
|
1331
|
+
const instance = new this();
|
|
1332
|
+
return instance.newQuery().whereIn(column, values);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Add a WHERE NULL clause and return the query builder.
|
|
1337
|
+
*/
|
|
1338
|
+
static whereNull<T extends Model>(
|
|
1339
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1340
|
+
column: string
|
|
1341
|
+
): Builder {
|
|
1342
|
+
const instance = new this();
|
|
1343
|
+
return instance.newQuery().whereNull(column);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Add a WHERE NOT NULL clause and return the query builder.
|
|
1348
|
+
*/
|
|
1349
|
+
static whereNotNull<T extends Model>(
|
|
1350
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1351
|
+
column: string
|
|
1352
|
+
): Builder {
|
|
1353
|
+
const instance = new this();
|
|
1354
|
+
return instance.newQuery().whereNotNull(column);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Add an ORDER BY clause and return the query builder.
|
|
1359
|
+
*/
|
|
1360
|
+
static orderBy<T extends Model>(
|
|
1361
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1362
|
+
column: string,
|
|
1363
|
+
direction: 'asc' | 'desc' = 'asc'
|
|
1364
|
+
): Builder {
|
|
1365
|
+
const instance = new this();
|
|
1366
|
+
return instance.newQuery().orderBy(column, direction);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Add a LIMIT clause and return the query builder.
|
|
1371
|
+
*/
|
|
1372
|
+
static limit<T extends Model>(
|
|
1373
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1374
|
+
value: number
|
|
1375
|
+
): Builder {
|
|
1376
|
+
const instance = new this();
|
|
1377
|
+
return instance.newQuery().limit(value);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ==========================================
|
|
1381
|
+
// Eager Loading
|
|
1382
|
+
// ==========================================
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Begin a query with eager loading for the specified relationships.
|
|
1386
|
+
*
|
|
1387
|
+
* This solves the N+1 query problem by loading all related models in bulk
|
|
1388
|
+
* rather than executing a separate query for each parent model.
|
|
1389
|
+
*
|
|
1390
|
+
* Usage:
|
|
1391
|
+
* ```typescript
|
|
1392
|
+
* // Load users with their posts
|
|
1393
|
+
* const users = await User.with('posts').get();
|
|
1394
|
+
*
|
|
1395
|
+
* // Load users with multiple relationships
|
|
1396
|
+
* const users = await User.with('posts', 'profile').get();
|
|
1397
|
+
*
|
|
1398
|
+
* // Load users with nested relationships (posts and their comments)
|
|
1399
|
+
* const users = await User.with('posts.comments').get();
|
|
1400
|
+
*
|
|
1401
|
+
* // Chain with query constraints
|
|
1402
|
+
* const users = await User.with('posts')
|
|
1403
|
+
* .where('active', true)
|
|
1404
|
+
* .orderBy('created_at', 'desc')
|
|
1405
|
+
* .limit(10)
|
|
1406
|
+
* .get();
|
|
1407
|
+
*
|
|
1408
|
+
* // Access eagerly loaded relations
|
|
1409
|
+
* for (const user of users) {
|
|
1410
|
+
* const posts = user.getRelation('posts'); // Already loaded, no query
|
|
1411
|
+
* console.log(posts);
|
|
1412
|
+
* }
|
|
1413
|
+
* ```
|
|
1414
|
+
*
|
|
1415
|
+
* @param relations - The relationship names to eager load (can be nested with dots)
|
|
1416
|
+
* @returns An EagerLoadingBuilder for chaining
|
|
1417
|
+
*/
|
|
1418
|
+
static with<T extends Model>(
|
|
1419
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1420
|
+
...relations: string[]
|
|
1421
|
+
): EagerLoadingBuilder<T> {
|
|
1422
|
+
const instance = new this();
|
|
1423
|
+
const builder = instance.newQuery();
|
|
1424
|
+
return new EagerLoadingBuilder<T>(this, builder, relations);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ==========================================
|
|
1428
|
+
// Relationship Methods
|
|
1429
|
+
// ==========================================
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Define a one-to-one relationship.
|
|
1434
|
+
*
|
|
1435
|
+
* The foreign key is on the RELATED model's table, referencing this model.
|
|
1436
|
+
*
|
|
1437
|
+
* Example: User has one Profile (profiles.user_id references users.id)
|
|
1438
|
+
*
|
|
1439
|
+
* Usage:
|
|
1440
|
+
* ```typescript
|
|
1441
|
+
* class User extends Model {
|
|
1442
|
+
* profile() {
|
|
1443
|
+
* return this.hasOne(Profile, 'user_id', 'id');
|
|
1444
|
+
* }
|
|
1445
|
+
* }
|
|
1446
|
+
*
|
|
1447
|
+
* const user = await User.find(1);
|
|
1448
|
+
* const profile = await user.profile().get();
|
|
1449
|
+
* ```
|
|
1450
|
+
*
|
|
1451
|
+
* @param RelatedClass - The related model class constructor
|
|
1452
|
+
* @param foreignKey - The foreign key column on the related model (defaults to {thisModel}_id)
|
|
1453
|
+
* @param localKey - The local key column on this model (defaults to 'id')
|
|
1454
|
+
* @returns A HasOne relationship instance
|
|
1455
|
+
*/
|
|
1456
|
+
protected hasOne<TRelated extends Model>(
|
|
1457
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1458
|
+
foreignKey?: string,
|
|
1459
|
+
localKey?: string
|
|
1460
|
+
): HasOne<TRelated> {
|
|
1461
|
+
const relatedInstance = new RelatedClass();
|
|
1462
|
+
|
|
1463
|
+
// Default foreign key: user_id (for User model)
|
|
1464
|
+
const fk = foreignKey || this.getForeignKey();
|
|
1465
|
+
|
|
1466
|
+
// Default local key: id
|
|
1467
|
+
const lk = localKey || this.getKeyName();
|
|
1468
|
+
|
|
1469
|
+
return new HasOne<TRelated>(this, relatedInstance, fk, lk);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Define a belongs-to relationship (inverse of hasOne or hasMany).
|
|
1474
|
+
*
|
|
1475
|
+
* The foreign key is on THIS model's table, referencing the parent.
|
|
1476
|
+
*
|
|
1477
|
+
* Example: Profile belongs to User (profiles.user_id references users.id)
|
|
1478
|
+
*
|
|
1479
|
+
* Usage:
|
|
1480
|
+
* ```typescript
|
|
1481
|
+
* class Profile extends Model {
|
|
1482
|
+
* user() {
|
|
1483
|
+
* return this.belongsTo(User, 'user_id', 'id');
|
|
1484
|
+
* }
|
|
1485
|
+
* }
|
|
1486
|
+
*
|
|
1487
|
+
* const profile = await Profile.find(1);
|
|
1488
|
+
* const user = await profile.user().get();
|
|
1489
|
+
* ```
|
|
1490
|
+
*
|
|
1491
|
+
* @param RelatedClass - The parent model class constructor
|
|
1492
|
+
* @param foreignKey - The foreign key column on this model (defaults to {parent}_id)
|
|
1493
|
+
* @param ownerKey - The owner key column on the parent model (defaults to 'id')
|
|
1494
|
+
* @returns A BelongsTo relationship instance
|
|
1495
|
+
*/
|
|
1496
|
+
protected belongsTo<TRelated extends Model>(
|
|
1497
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1498
|
+
foreignKey?: string,
|
|
1499
|
+
ownerKey?: string
|
|
1500
|
+
): BelongsTo<TRelated> {
|
|
1501
|
+
const relatedInstance = new RelatedClass();
|
|
1502
|
+
|
|
1503
|
+
// Default foreign key: user_id (for User parent model)
|
|
1504
|
+
const fk = foreignKey || relatedInstance.getForeignKey();
|
|
1505
|
+
|
|
1506
|
+
// Default owner key: id
|
|
1507
|
+
const ok = ownerKey || relatedInstance.getKeyName();
|
|
1508
|
+
|
|
1509
|
+
return new BelongsTo<TRelated>(this, relatedInstance, fk, ok);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Define a one-to-many relationship.
|
|
1514
|
+
*
|
|
1515
|
+
* The foreign key is on the RELATED model's table, referencing this model.
|
|
1516
|
+
*
|
|
1517
|
+
* Example: User has many Posts (posts.user_id references users.id)
|
|
1518
|
+
*
|
|
1519
|
+
* Usage:
|
|
1520
|
+
* ```typescript
|
|
1521
|
+
* class User extends Model {
|
|
1522
|
+
* posts() {
|
|
1523
|
+
* return this.hasMany(Post, 'user_id', 'id');
|
|
1524
|
+
* }
|
|
1525
|
+
* }
|
|
1526
|
+
*
|
|
1527
|
+
* const user = await User.find(1);
|
|
1528
|
+
* const posts = await user.posts().get(); // Get all posts
|
|
1529
|
+
* const recentPosts = await user.posts()
|
|
1530
|
+
* .where('published', true)
|
|
1531
|
+
* .orderBy('created_at', 'desc')
|
|
1532
|
+
* .limit(5)
|
|
1533
|
+
* .get();
|
|
1534
|
+
* ```
|
|
1535
|
+
*
|
|
1536
|
+
* @param RelatedClass - The related model class constructor
|
|
1537
|
+
* @param foreignKey - The foreign key column on the related model (defaults to {thisModel}_id)
|
|
1538
|
+
* @param localKey - The local key column on this model (defaults to 'id')
|
|
1539
|
+
* @returns A HasMany relationship instance
|
|
1540
|
+
*/
|
|
1541
|
+
protected hasMany<TRelated extends Model>(
|
|
1542
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1543
|
+
foreignKey?: string,
|
|
1544
|
+
localKey?: string
|
|
1545
|
+
): HasMany<TRelated> {
|
|
1546
|
+
const relatedInstance = new RelatedClass();
|
|
1547
|
+
|
|
1548
|
+
// Default foreign key: user_id (for User model)
|
|
1549
|
+
const fk = foreignKey || this.getForeignKey();
|
|
1550
|
+
|
|
1551
|
+
// Default local key: id
|
|
1552
|
+
const lk = localKey || this.getKeyName();
|
|
1553
|
+
|
|
1554
|
+
return new HasMany<TRelated>(this, relatedInstance, fk, lk);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Define a many-to-many relationship with a pivot table.
|
|
1559
|
+
*
|
|
1560
|
+
* Uses an intermediate (pivot) table to connect two models.
|
|
1561
|
+
*
|
|
1562
|
+
* Example: Users belong to many Roles through role_user pivot table
|
|
1563
|
+
*
|
|
1564
|
+
* Usage:
|
|
1565
|
+
* ```typescript
|
|
1566
|
+
* class User extends Model {
|
|
1567
|
+
* roles() {
|
|
1568
|
+
* return this.belongsToMany(Role, 'role_user', 'user_id', 'role_id');
|
|
1569
|
+
* }
|
|
1570
|
+
* }
|
|
1571
|
+
*
|
|
1572
|
+
* const user = await User.find(1);
|
|
1573
|
+
* const roles = await user.roles().get(); // Get all roles
|
|
1574
|
+
* await user.roles().attach(1); // Attach a role
|
|
1575
|
+
* await user.roles().detach([1, 2]); // Detach roles
|
|
1576
|
+
* await user.roles().sync([1, 2, 3]); // Sync roles
|
|
1577
|
+
* ```
|
|
1578
|
+
*
|
|
1579
|
+
* @param RelatedClass - The related model class constructor
|
|
1580
|
+
* @param table - The pivot table name (defaults to alphabetically sorted model names: role_user)
|
|
1581
|
+
* @param foreignPivotKey - The foreign key for this model on the pivot table (defaults to {thisModel}_id)
|
|
1582
|
+
* @param relatedPivotKey - The foreign key for the related model on the pivot table (defaults to {related}_id)
|
|
1583
|
+
* @param parentKey - The primary key on this model (defaults to 'id')
|
|
1584
|
+
* @param relatedKey - The primary key on the related model (defaults to 'id')
|
|
1585
|
+
* @returns A BelongsToMany relationship instance
|
|
1586
|
+
*/
|
|
1587
|
+
protected belongsToMany<TRelated extends Model>(
|
|
1588
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1589
|
+
table?: string,
|
|
1590
|
+
foreignPivotKey?: string,
|
|
1591
|
+
relatedPivotKey?: string,
|
|
1592
|
+
parentKey?: string,
|
|
1593
|
+
relatedKey?: string
|
|
1594
|
+
): BelongsToMany<TRelated> {
|
|
1595
|
+
const relatedInstance = new RelatedClass();
|
|
1596
|
+
|
|
1597
|
+
// Generate default pivot table name (alphabetically sorted, e.g., role_user)
|
|
1598
|
+
const thisTable = this.inferTableName().replace(/s$/, ''); // Remove trailing 's'
|
|
1599
|
+
const relatedTable = relatedInstance.getTable().replace(/s$/, '');
|
|
1600
|
+
const defaultTable = [thisTable, relatedTable].sort().join('_');
|
|
1601
|
+
|
|
1602
|
+
const pivotTable = table || defaultTable;
|
|
1603
|
+
const fpk = foreignPivotKey || this.getForeignKey();
|
|
1604
|
+
const rpk = relatedPivotKey || relatedInstance.getForeignKey();
|
|
1605
|
+
const pk = parentKey || this.getKeyName();
|
|
1606
|
+
const rk = relatedKey || relatedInstance.getKeyName();
|
|
1607
|
+
|
|
1608
|
+
return new BelongsToMany<TRelated>(
|
|
1609
|
+
this,
|
|
1610
|
+
relatedInstance,
|
|
1611
|
+
pivotTable,
|
|
1612
|
+
fpk,
|
|
1613
|
+
rpk,
|
|
1614
|
+
pk,
|
|
1615
|
+
rk
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Define a has-one-through relationship.
|
|
1621
|
+
*
|
|
1622
|
+
* Retrieves a single related model through an intermediate model.
|
|
1623
|
+
*
|
|
1624
|
+
* Example: Country has one Profile through User
|
|
1625
|
+
*
|
|
1626
|
+
* Usage:
|
|
1627
|
+
* ```typescript
|
|
1628
|
+
* class Country extends Model {
|
|
1629
|
+
* userProfile() {
|
|
1630
|
+
* return this.hasOneThrough(
|
|
1631
|
+
* Profile, // Final model
|
|
1632
|
+
* User, // Intermediate model
|
|
1633
|
+
* 'country_id', // Foreign key on users (links to countries)
|
|
1634
|
+
* 'user_id', // Foreign key on profiles (links to users)
|
|
1635
|
+
* 'id', // Local key on countries
|
|
1636
|
+
* 'id' // Local key on users
|
|
1637
|
+
* );
|
|
1638
|
+
* }
|
|
1639
|
+
* }
|
|
1640
|
+
*
|
|
1641
|
+
* const country = await Country.find(1);
|
|
1642
|
+
* const profile = await country.userProfile().get();
|
|
1643
|
+
* ```
|
|
1644
|
+
*
|
|
1645
|
+
* @param RelatedClass - The final related model class constructor
|
|
1646
|
+
* @param ThroughClass - The intermediate model class constructor
|
|
1647
|
+
* @param firstKey - The foreign key on the intermediate table referencing this model
|
|
1648
|
+
* @param secondKey - The foreign key on the final table referencing the intermediate model
|
|
1649
|
+
* @param localKey - The primary key on this model (defaults to 'id')
|
|
1650
|
+
* @param secondLocalKey - The primary key on the intermediate model (defaults to 'id')
|
|
1651
|
+
* @returns A HasOneThrough relationship instance
|
|
1652
|
+
*/
|
|
1653
|
+
protected hasOneThrough<TRelated extends Model, TThrough extends Model>(
|
|
1654
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1655
|
+
ThroughClass: new (attrs?: Record<string, unknown>) => TThrough,
|
|
1656
|
+
firstKey?: string,
|
|
1657
|
+
secondKey?: string,
|
|
1658
|
+
localKey?: string,
|
|
1659
|
+
secondLocalKey?: string
|
|
1660
|
+
): HasOneThrough<TRelated> {
|
|
1661
|
+
const relatedInstance = new RelatedClass();
|
|
1662
|
+
const throughInstance = new ThroughClass();
|
|
1663
|
+
|
|
1664
|
+
// Default keys
|
|
1665
|
+
const fk = firstKey || this.getForeignKey();
|
|
1666
|
+
const sk = secondKey || throughInstance.getForeignKey();
|
|
1667
|
+
const lk = localKey || this.getKeyName();
|
|
1668
|
+
const slk = secondLocalKey || throughInstance.getKeyName();
|
|
1669
|
+
|
|
1670
|
+
return new HasOneThrough<TRelated>(
|
|
1671
|
+
this,
|
|
1672
|
+
relatedInstance,
|
|
1673
|
+
throughInstance,
|
|
1674
|
+
fk,
|
|
1675
|
+
sk,
|
|
1676
|
+
lk,
|
|
1677
|
+
slk
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Define a has-many-through relationship.
|
|
1683
|
+
*
|
|
1684
|
+
* Retrieves multiple related models through an intermediate model.
|
|
1685
|
+
*
|
|
1686
|
+
* Example: Country has many Posts through Users
|
|
1687
|
+
*
|
|
1688
|
+
* Usage:
|
|
1689
|
+
* ```typescript
|
|
1690
|
+
* class Country extends Model {
|
|
1691
|
+
* posts() {
|
|
1692
|
+
* return this.hasManyThrough(
|
|
1693
|
+
* Post, // Final model
|
|
1694
|
+
* User, // Intermediate model
|
|
1695
|
+
* 'country_id', // Foreign key on users (links to countries)
|
|
1696
|
+
* 'user_id', // Foreign key on posts (links to users)
|
|
1697
|
+
* 'id', // Local key on countries
|
|
1698
|
+
* 'id' // Local key on users
|
|
1699
|
+
* );
|
|
1700
|
+
* }
|
|
1701
|
+
* }
|
|
1702
|
+
*
|
|
1703
|
+
* const country = await Country.find(1);
|
|
1704
|
+
* const posts = await country.posts().get(); // All posts from users in this country
|
|
1705
|
+
* ```
|
|
1706
|
+
*
|
|
1707
|
+
* @param RelatedClass - The final related model class constructor
|
|
1708
|
+
* @param ThroughClass - The intermediate model class constructor
|
|
1709
|
+
* @param firstKey - The foreign key on the intermediate table referencing this model
|
|
1710
|
+
* @param secondKey - The foreign key on the final table referencing the intermediate model
|
|
1711
|
+
* @param localKey - The primary key on this model (defaults to 'id')
|
|
1712
|
+
* @param secondLocalKey - The primary key on the intermediate model (defaults to 'id')
|
|
1713
|
+
* @returns A HasManyThrough relationship instance
|
|
1714
|
+
*/
|
|
1715
|
+
protected hasManyThrough<TRelated extends Model, TThrough extends Model>(
|
|
1716
|
+
RelatedClass: new (attrs?: Record<string, unknown>) => TRelated,
|
|
1717
|
+
ThroughClass: new (attrs?: Record<string, unknown>) => TThrough,
|
|
1718
|
+
firstKey?: string,
|
|
1719
|
+
secondKey?: string,
|
|
1720
|
+
localKey?: string,
|
|
1721
|
+
secondLocalKey?: string
|
|
1722
|
+
): HasManyThrough<TRelated> {
|
|
1723
|
+
const relatedInstance = new RelatedClass();
|
|
1724
|
+
const throughInstance = new ThroughClass();
|
|
1725
|
+
|
|
1726
|
+
// Default keys
|
|
1727
|
+
const fk = firstKey || this.getForeignKey();
|
|
1728
|
+
const sk = secondKey || throughInstance.getForeignKey();
|
|
1729
|
+
const lk = localKey || this.getKeyName();
|
|
1730
|
+
const slk = secondLocalKey || throughInstance.getKeyName();
|
|
1731
|
+
|
|
1732
|
+
return new HasManyThrough<TRelated>(
|
|
1733
|
+
this,
|
|
1734
|
+
relatedInstance,
|
|
1735
|
+
throughInstance,
|
|
1736
|
+
fk,
|
|
1737
|
+
sk,
|
|
1738
|
+
lk,
|
|
1739
|
+
slk
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Paginate the model query.
|
|
1745
|
+
*/
|
|
1746
|
+
static async paginate<T extends Model>(
|
|
1747
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1748
|
+
perPage: number = 15,
|
|
1749
|
+
columns: string[] = ['*'],
|
|
1750
|
+
pageName: string = 'page',
|
|
1751
|
+
page?: number
|
|
1752
|
+
): Promise<LengthAwarePaginator<T>> {
|
|
1753
|
+
const instance = new this();
|
|
1754
|
+
const paginator = await instance.newQuery().paginate<Record<string, unknown>>(
|
|
1755
|
+
perPage,
|
|
1756
|
+
columns,
|
|
1757
|
+
pageName,
|
|
1758
|
+
page
|
|
1759
|
+
);
|
|
1760
|
+
|
|
1761
|
+
return new LengthAwarePaginator<T>(
|
|
1762
|
+
paginator.items as unknown as T[],
|
|
1763
|
+
paginator.total,
|
|
1764
|
+
paginator.perPage,
|
|
1765
|
+
paginator.currentPage,
|
|
1766
|
+
{
|
|
1767
|
+
path: paginator.path,
|
|
1768
|
+
query: paginator.query,
|
|
1769
|
+
}
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Simple pagination for the model query.
|
|
1775
|
+
*/
|
|
1776
|
+
static async simplePaginate<T extends Model>(
|
|
1777
|
+
this: new (attrs?: Record<string, unknown>) => T,
|
|
1778
|
+
perPage: number = 15,
|
|
1779
|
+
columns: string[] = ['*'],
|
|
1780
|
+
pageName: string = 'page',
|
|
1781
|
+
page?: number
|
|
1782
|
+
): Promise<Paginator<T>> {
|
|
1783
|
+
const instance = new this();
|
|
1784
|
+
const paginator = await instance.newQuery().simplePaginate<Record<string, unknown>>(
|
|
1785
|
+
perPage,
|
|
1786
|
+
columns,
|
|
1787
|
+
pageName,
|
|
1788
|
+
page
|
|
1789
|
+
);
|
|
1790
|
+
|
|
1791
|
+
return new Paginator<T>(
|
|
1792
|
+
paginator.items as unknown as T[],
|
|
1793
|
+
paginator.perPage,
|
|
1794
|
+
paginator.currentPage,
|
|
1795
|
+
{
|
|
1796
|
+
path: paginator.path,
|
|
1797
|
+
query: paginator.query,
|
|
1798
|
+
hasMore: paginator.hasMore,
|
|
1799
|
+
}
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
}
|