@veap/database 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +177 -2
- package/dist/casts.d.ts +9 -0
- package/dist/casts.js +78 -0
- package/dist/composition.d.ts +15 -0
- package/dist/composition.js +26 -0
- package/dist/connection.d.ts +49 -0
- package/dist/connection.js +193 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +17 -0
- package/dist/events.d.ts +5 -0
- package/dist/events.js +7 -0
- package/dist/factory.d.ts +68 -0
- package/dist/factory.js +161 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +21 -0
- package/dist/migration-provider.d.ts +7 -0
- package/dist/migration-provider.js +36 -0
- package/dist/migrations/generator.d.ts +4 -0
- package/dist/migrations/generator.js +58 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +3 -0
- package/dist/migrations/runner.d.ts +12 -0
- package/dist/migrations/runner.js +179 -0
- package/dist/migrations/schema-builder.d.ts +84 -0
- package/dist/migrations/schema-builder.js +409 -0
- package/dist/model.d.ts +379 -0
- package/dist/model.js +913 -0
- package/dist/provider.d.ts +13 -0
- package/dist/provider.js +112 -0
- package/dist/query-builder.d.ts +197 -0
- package/dist/query-builder.js +749 -0
- package/dist/relations/belongs-to-many.d.ts +15 -0
- package/dist/relations/belongs-to-many.js +80 -0
- package/dist/relations/belongs-to.d.ts +8 -0
- package/dist/relations/belongs-to.js +37 -0
- package/dist/relations/eager-loader.d.ts +5 -0
- package/dist/relations/eager-loader.js +68 -0
- package/dist/relations/has-many.d.ts +9 -0
- package/dist/relations/has-many.js +48 -0
- package/dist/relations/has-one.d.ts +8 -0
- package/dist/relations/has-one.js +37 -0
- package/dist/relations/index.d.ts +11 -0
- package/dist/relations/index.js +11 -0
- package/dist/relations/morph-many.d.ts +14 -0
- package/dist/relations/morph-many.js +65 -0
- package/dist/relations/morph-map.d.ts +32 -0
- package/dist/relations/morph-map.js +73 -0
- package/dist/relations/morph-one.d.ts +14 -0
- package/dist/relations/morph-one.js +65 -0
- package/dist/relations/morph-to-many.d.ts +44 -0
- package/dist/relations/morph-to-many.js +215 -0
- package/dist/relations/morph-to.d.ts +19 -0
- package/dist/relations/morph-to.js +90 -0
- package/dist/relations/relation.d.ts +22 -0
- package/dist/relations/relation.js +13 -0
- package/dist/scopes.d.ts +16 -0
- package/dist/scopes.js +1 -0
- package/dist/seeder.d.ts +25 -0
- package/dist/seeder.js +49 -0
- package/dist/tokens.d.ts +10 -0
- package/dist/tokens.js +8 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +9 -0
- package/dist/utils.js +20 -0
- package/package.json +58 -4
package/dist/model.js
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
/** biome-ignore-all lint/correctness/noConstructorReturn: <ignore> */
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { castAttribute, serializeForStorage } from "./casts.js";
|
|
4
|
+
import { getKnex } from "./connection.js";
|
|
5
|
+
import { DatabaseError } from "./errors.js";
|
|
6
|
+
import { getDatabaseEventDispatcher } from "./events.js";
|
|
7
|
+
import { Factory } from "./factory.js";
|
|
8
|
+
import { ModelQueryBuilder } from "./query-builder.js";
|
|
9
|
+
import { BelongsTo, BelongsToMany, HasMany, HasOne, MorphedByMany, MorphMany, MorphMap, MorphOne, MorphTo, MorphToMany, } from "./relations/index.js";
|
|
10
|
+
import { toCamelCase, toSnakeCase } from "./utils.js";
|
|
11
|
+
export class Model {
|
|
12
|
+
/**
|
|
13
|
+
* Database table name.
|
|
14
|
+
*/
|
|
15
|
+
static table;
|
|
16
|
+
/**
|
|
17
|
+
* Optional polymorphic morph alias (e.g. 'post', 'category', 'user').
|
|
18
|
+
*/
|
|
19
|
+
static morphAlias;
|
|
20
|
+
/**
|
|
21
|
+
* Primary key column name.
|
|
22
|
+
*/
|
|
23
|
+
static primaryKey = "id";
|
|
24
|
+
/**
|
|
25
|
+
* Whether to automatically generate UUID v4 for the primary key on creation.
|
|
26
|
+
*/
|
|
27
|
+
static autoUuid = true;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the table has timestamp columns.
|
|
30
|
+
* Can be true (both created_at and updated_at), false (none),
|
|
31
|
+
* or an array of specific timestamp columns like ["created_at"].
|
|
32
|
+
*/
|
|
33
|
+
static timestamps = true;
|
|
34
|
+
/**
|
|
35
|
+
* Attribute type casts dictionary.
|
|
36
|
+
*/
|
|
37
|
+
static casts = {};
|
|
38
|
+
/**
|
|
39
|
+
* Whether this model uses soft deletes.
|
|
40
|
+
*/
|
|
41
|
+
static softDeletes = false;
|
|
42
|
+
/**
|
|
43
|
+
* Column name used for soft deletes.
|
|
44
|
+
*/
|
|
45
|
+
static deletedAtColumn = "deleted_at";
|
|
46
|
+
/**
|
|
47
|
+
* Hidden attributes that should not be included in toJSON serialization.
|
|
48
|
+
*/
|
|
49
|
+
static hidden = [];
|
|
50
|
+
/**
|
|
51
|
+
* Attributes that are mass assignable.
|
|
52
|
+
* If not empty, only attributes in this array can be mass-assigned.
|
|
53
|
+
*/
|
|
54
|
+
static fillable = [];
|
|
55
|
+
/**
|
|
56
|
+
* Attributes that are guarded from mass assignment.
|
|
57
|
+
* If '*' or non-empty, attributes in this array cannot be mass-assigned.
|
|
58
|
+
*/
|
|
59
|
+
static guarded = [];
|
|
60
|
+
/**
|
|
61
|
+
* Registered global scopes dictionary.
|
|
62
|
+
*/
|
|
63
|
+
static globalScopes = {};
|
|
64
|
+
/**
|
|
65
|
+
* Registers a new global scope on this model.
|
|
66
|
+
*/
|
|
67
|
+
static addGlobalScope(name, scope) {
|
|
68
|
+
if (!this.globalScopes) {
|
|
69
|
+
this.globalScopes = {};
|
|
70
|
+
}
|
|
71
|
+
// Ensure subclass has its own dictionary (not mutating parent Model.globalScopes)
|
|
72
|
+
if (!Object.prototype.hasOwnProperty.call(this, "globalScopes")) {
|
|
73
|
+
this.globalScopes = { ...this.globalScopes };
|
|
74
|
+
}
|
|
75
|
+
this.globalScopes[name] = scope;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Checks if a global scope is registered on this model.
|
|
79
|
+
*/
|
|
80
|
+
static hasGlobalScope(name) {
|
|
81
|
+
return !!this.globalScopes?.[name];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Model attributes stored in state.
|
|
85
|
+
*/
|
|
86
|
+
_attributes = {};
|
|
87
|
+
/**
|
|
88
|
+
* Original attributes as loaded from database (for dirty tracking).
|
|
89
|
+
*/
|
|
90
|
+
_original = {};
|
|
91
|
+
/**
|
|
92
|
+
* Eager-loaded relations.
|
|
93
|
+
*/
|
|
94
|
+
_relations = {};
|
|
95
|
+
/**
|
|
96
|
+
* Indicates if the model exists in the database.
|
|
97
|
+
*/
|
|
98
|
+
isNew = true;
|
|
99
|
+
constructor(attributes = {}, isNew = true) {
|
|
100
|
+
this.isNew = isNew;
|
|
101
|
+
if (attributes) {
|
|
102
|
+
if (isNew) {
|
|
103
|
+
this.fill(attributes);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
this.hydrate(attributes);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Return a Proxy to allow direct property access (model.email, model.title)
|
|
110
|
+
return new Proxy(this, {
|
|
111
|
+
get(target, prop, receiver) {
|
|
112
|
+
if (typeof prop === "string") {
|
|
113
|
+
// 1. Internal/private properties or fundamental Object/Model methods
|
|
114
|
+
if (prop.startsWith("_") ||
|
|
115
|
+
prop === "isNew" ||
|
|
116
|
+
prop === "constructor" ||
|
|
117
|
+
prop === "toJSON" ||
|
|
118
|
+
prop === "toString" ||
|
|
119
|
+
prop === "valueOf") {
|
|
120
|
+
return Reflect.get(target, prop, receiver);
|
|
121
|
+
}
|
|
122
|
+
// 2. Base Model instance methods (save, delete, update, getAttribute, etc.)
|
|
123
|
+
const baseDesc = Object.getOwnPropertyDescriptor(Model.prototype, prop);
|
|
124
|
+
if (baseDesc && typeof baseDesc.value === "function") {
|
|
125
|
+
return Reflect.get(target, prop, receiver);
|
|
126
|
+
}
|
|
127
|
+
// 3. Loaded relations take precedence over relation-defining methods!
|
|
128
|
+
if (prop in target._relations) {
|
|
129
|
+
return target._relations[prop];
|
|
130
|
+
}
|
|
131
|
+
// 4. Attribute value from model attributes
|
|
132
|
+
const attr = target.getAttribute(prop);
|
|
133
|
+
if (attr !== undefined) {
|
|
134
|
+
return attr;
|
|
135
|
+
}
|
|
136
|
+
// 5. Prototype method/getter on model class or base classes
|
|
137
|
+
let currProto = Object.getPrototypeOf(target);
|
|
138
|
+
while (currProto && currProto !== Object.prototype) {
|
|
139
|
+
const protoDesc = Object.getOwnPropertyDescriptor(currProto, prop);
|
|
140
|
+
if (protoDesc) {
|
|
141
|
+
if (typeof protoDesc.value === "function" || protoDesc.get) {
|
|
142
|
+
return Reflect.get(target, prop, receiver);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
currProto = Object.getPrototypeOf(currProto);
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return Reflect.get(target, prop, receiver);
|
|
150
|
+
},
|
|
151
|
+
set(target, prop, value, receiver) {
|
|
152
|
+
if (typeof prop === "string") {
|
|
153
|
+
if (prop.startsWith("_") || prop === "isNew") {
|
|
154
|
+
return Reflect.set(target, prop, value, receiver);
|
|
155
|
+
}
|
|
156
|
+
let currProto = Object.getPrototypeOf(target);
|
|
157
|
+
while (currProto && currProto !== Object.prototype) {
|
|
158
|
+
const protoDesc = Object.getOwnPropertyDescriptor(currProto, prop);
|
|
159
|
+
if (protoDesc?.set) {
|
|
160
|
+
return Reflect.set(target, prop, value, receiver);
|
|
161
|
+
}
|
|
162
|
+
currProto = Object.getPrototypeOf(currProto);
|
|
163
|
+
}
|
|
164
|
+
target.setAttribute(prop, value);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return Reflect.set(target, prop, value, receiver);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
get id() {
|
|
172
|
+
return this.getAttribute(this.constructor.primaryKey);
|
|
173
|
+
}
|
|
174
|
+
set id(value) {
|
|
175
|
+
this.setAttribute(this.constructor.primaryKey, value);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Static Query Builder entrypoint.
|
|
179
|
+
*/
|
|
180
|
+
static query() {
|
|
181
|
+
return new ModelQueryBuilder(this);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Starts a query builder including soft-deleted models.
|
|
185
|
+
*/
|
|
186
|
+
static withTrashed() {
|
|
187
|
+
return this.query().withTrashed();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Starts a query builder restricting to only soft-deleted models.
|
|
191
|
+
*/
|
|
192
|
+
static onlyTrashed() {
|
|
193
|
+
return this.query().onlyTrashed();
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Finds a model by primary key.
|
|
197
|
+
*/
|
|
198
|
+
static async find(id) {
|
|
199
|
+
return await this.query().find(id);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Finds a model by primary key or throws.
|
|
203
|
+
*/
|
|
204
|
+
static async findOrFail(id) {
|
|
205
|
+
return await this.query().findOrFail(id);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Gets all records from table.
|
|
209
|
+
*/
|
|
210
|
+
static async all() {
|
|
211
|
+
return await this.query().get();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Counts rows in table.
|
|
215
|
+
*/
|
|
216
|
+
static async count(column = "*") {
|
|
217
|
+
return await this.query().count(column);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Starts a WHERE query.
|
|
221
|
+
*/
|
|
222
|
+
static where(column, operator, value) {
|
|
223
|
+
return this.query().where(column, operator, value);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Creates and saves a new model instance.
|
|
227
|
+
*/
|
|
228
|
+
static async create(attributes) {
|
|
229
|
+
const instance = new this(attributes, true);
|
|
230
|
+
await instance.save();
|
|
231
|
+
return instance;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Creates and saves a new model instance without mass assignment restrictions.
|
|
235
|
+
*/
|
|
236
|
+
static async forceCreate(attributes) {
|
|
237
|
+
const instance = new this({}, true);
|
|
238
|
+
instance.forceFill(attributes);
|
|
239
|
+
await instance.save();
|
|
240
|
+
return instance;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Finds the first record matching attributes or creates and saves a new instance.
|
|
244
|
+
*/
|
|
245
|
+
static async firstOrCreate(attributes, values = {}) {
|
|
246
|
+
return await this.query().firstOrCreate(attributes, values);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Finds the first record matching attributes or instantiates a new instance (unsaved).
|
|
250
|
+
*/
|
|
251
|
+
static async firstOrNew(attributes, values = {}) {
|
|
252
|
+
return await this.query().firstOrNew(attributes, values);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Creates or updates a record matching attributes, filling it with values.
|
|
256
|
+
*/
|
|
257
|
+
static async updateOrCreate(attributes, values = {}) {
|
|
258
|
+
return await this.query().updateOrCreate(attributes, values);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Begins querying a relation existence on the model.
|
|
262
|
+
*/
|
|
263
|
+
static has(relation, operator = ">=", count = 1) {
|
|
264
|
+
return this.query().has(relation, operator, count);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Begins querying a relation with a callback on the model.
|
|
268
|
+
*/
|
|
269
|
+
static whereHas(relation, callback) {
|
|
270
|
+
return this.query().whereHas(relation, callback);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Begins querying a relation absence on the model.
|
|
274
|
+
*/
|
|
275
|
+
static whereDoesntHave(relation, callback) {
|
|
276
|
+
return this.query().whereDoesntHave(relation, callback);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Begins querying with related models count.
|
|
280
|
+
*/
|
|
281
|
+
static withCount(...relations) {
|
|
282
|
+
return this.query().withCount(...relations);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Executes a database transaction.
|
|
286
|
+
*/
|
|
287
|
+
static async transaction(callback) {
|
|
288
|
+
const { transaction: runTrx } = await import("./connection.js");
|
|
289
|
+
return await runTrx(callback);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Applies a local scope by name on a new query builder.
|
|
293
|
+
*/
|
|
294
|
+
static scope(name, ...args) {
|
|
295
|
+
return this.query().scope(name, ...args);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Excludes a specific global scope on a new query builder.
|
|
299
|
+
*/
|
|
300
|
+
static withoutGlobalScope(scope) {
|
|
301
|
+
return this.query().withoutGlobalScope(scope);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Excludes multiple or all global scopes on a new query builder.
|
|
305
|
+
*/
|
|
306
|
+
static withoutGlobalScopes(scopes) {
|
|
307
|
+
return this.query().withoutGlobalScopes(scopes);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Starts a new Factory for this model.
|
|
311
|
+
* Uses static newFactory() method on the model class.
|
|
312
|
+
*/
|
|
313
|
+
static factory(count) {
|
|
314
|
+
const registered = Factory.getForModel(this);
|
|
315
|
+
if (registered) {
|
|
316
|
+
const f = typeof registered === "function" &&
|
|
317
|
+
!(registered.prototype instanceof Factory)
|
|
318
|
+
? registered(count)
|
|
319
|
+
: new registered();
|
|
320
|
+
if (count !== undefined && typeof f.count === "function") {
|
|
321
|
+
f.count(count);
|
|
322
|
+
}
|
|
323
|
+
return f;
|
|
324
|
+
}
|
|
325
|
+
if (typeof this.newFactory === "function") {
|
|
326
|
+
const f = this.newFactory();
|
|
327
|
+
if (count !== undefined && typeof f.count === "function") {
|
|
328
|
+
f.count(count);
|
|
329
|
+
}
|
|
330
|
+
return f;
|
|
331
|
+
}
|
|
332
|
+
throw DatabaseError.Internal(`[veap:ORM] Model ${this.name || this.table} does not have a factory defined. Implement static newFactory() or use defineFactory().`);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Deletes a model by primary key (soft-deletes if softDeletes is enabled).
|
|
336
|
+
*/
|
|
337
|
+
static async destroy(id) {
|
|
338
|
+
const knex = getKnex();
|
|
339
|
+
const clientType = knex.client?.config?.client;
|
|
340
|
+
if (this.softDeletes) {
|
|
341
|
+
const col = toSnakeCase(this.deletedAtColumn || "deleted_at");
|
|
342
|
+
const count = await knex(this.table)
|
|
343
|
+
.where(this.primaryKey, id)
|
|
344
|
+
.whereNull(col)
|
|
345
|
+
.update({
|
|
346
|
+
[col]: serializeForStorage(new Date(), "datetime", clientType),
|
|
347
|
+
});
|
|
348
|
+
return count > 0;
|
|
349
|
+
}
|
|
350
|
+
const count = await knex(this.table).where(this.primaryKey, id).delete();
|
|
351
|
+
return count > 0;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Permanently deletes a model by primary key regardless of softDeletes setting.
|
|
355
|
+
*/
|
|
356
|
+
static async forceDestroy(id) {
|
|
357
|
+
const knex = getKnex();
|
|
358
|
+
const count = await knex(this.table).where(this.primaryKey, id).delete();
|
|
359
|
+
return count > 0;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Restores a soft-deleted model by primary key.
|
|
363
|
+
*/
|
|
364
|
+
static async restore(id) {
|
|
365
|
+
const knex = getKnex();
|
|
366
|
+
const col = toSnakeCase(this.deletedAtColumn || "deleted_at");
|
|
367
|
+
const count = await knex(this.table)
|
|
368
|
+
.where(this.primaryKey, id)
|
|
369
|
+
.update({ [col]: null });
|
|
370
|
+
return count > 0;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Determines if the given attribute may be mass assigned.
|
|
374
|
+
*/
|
|
375
|
+
isFillable(key) {
|
|
376
|
+
const modelClass = this.constructor;
|
|
377
|
+
const fillable = modelClass.fillable || [];
|
|
378
|
+
const guarded = modelClass.guarded || [];
|
|
379
|
+
const snake = toSnakeCase(key);
|
|
380
|
+
const camel = toCamelCase(key);
|
|
381
|
+
const isInList = (list) => list.includes(key) || list.includes(snake) || list.includes(camel);
|
|
382
|
+
// If guarded includes '*', nothing is fillable
|
|
383
|
+
if (guarded.includes("*")) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
// If fillable is non-empty, only listed attributes are fillable
|
|
387
|
+
if (fillable.length > 0) {
|
|
388
|
+
return isInList(fillable);
|
|
389
|
+
}
|
|
390
|
+
// If guarded is non-empty, anything not in guarded is fillable
|
|
391
|
+
if (guarded.length > 0) {
|
|
392
|
+
return !isInList(guarded);
|
|
393
|
+
}
|
|
394
|
+
// Default: allow all if neither fillable nor guarded are set
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Fills model with mass-assignable attributes.
|
|
399
|
+
*/
|
|
400
|
+
fill(attributes) {
|
|
401
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
402
|
+
if (this.isFillable(key)) {
|
|
403
|
+
this.setAttribute(key, value);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return this;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Force fills model with attributes, bypassing fillable/guarded protection.
|
|
410
|
+
*/
|
|
411
|
+
forceFill(attributes) {
|
|
412
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
413
|
+
this.setAttribute(key, value);
|
|
414
|
+
}
|
|
415
|
+
return this;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Fires a model lifecycle event.
|
|
419
|
+
* Calls instance hook methods (e.g. beforeSave/saving) and publishes to eventBus.
|
|
420
|
+
* If any handler returns false, the operation is canceled.
|
|
421
|
+
*/
|
|
422
|
+
async fireModelEvent(event) {
|
|
423
|
+
const modelClass = this.constructor;
|
|
424
|
+
const tableName = modelClass.table || "unknown";
|
|
425
|
+
// 1. Check instance hook methods
|
|
426
|
+
const methodMap = {
|
|
427
|
+
saving: ["saving", "beforeSave"],
|
|
428
|
+
saved: ["saved", "afterSave"],
|
|
429
|
+
creating: ["creating", "beforeCreate"],
|
|
430
|
+
created: ["created", "afterCreate"],
|
|
431
|
+
updating: ["updating", "beforeUpdate"],
|
|
432
|
+
updated: ["updated", "afterUpdate"],
|
|
433
|
+
deleting: ["deleting", "beforeDelete"],
|
|
434
|
+
deleted: ["deleted", "afterDelete"],
|
|
435
|
+
restoring: ["restoring", "beforeRestore"],
|
|
436
|
+
restored: ["restored", "afterRestore"],
|
|
437
|
+
};
|
|
438
|
+
const candidateMethods = methodMap[event] || [event];
|
|
439
|
+
for (const methodName of candidateMethods) {
|
|
440
|
+
if (typeof this[methodName] === "function") {
|
|
441
|
+
const result = await this[methodName]();
|
|
442
|
+
if (result === false) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// 2. Publish to Event Dispatcher if configured
|
|
448
|
+
try {
|
|
449
|
+
const dispatcher = getDatabaseEventDispatcher();
|
|
450
|
+
if (dispatcher) {
|
|
451
|
+
await dispatcher.publish(`model:${event}:${tableName}`, {
|
|
452
|
+
model: this,
|
|
453
|
+
event,
|
|
454
|
+
table: tableName,
|
|
455
|
+
});
|
|
456
|
+
await dispatcher.publish(`model:${event}`, {
|
|
457
|
+
model: this,
|
|
458
|
+
event,
|
|
459
|
+
table: tableName,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (_e) {
|
|
464
|
+
// Gracefully handle if eventDispatcher encounters an issue
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Hydrates attributes from raw database row, applying casts.
|
|
470
|
+
*/
|
|
471
|
+
hydrate(rawRow) {
|
|
472
|
+
const modelClass = this.constructor;
|
|
473
|
+
const casts = modelClass.casts || {};
|
|
474
|
+
const isSoftDelete = modelClass.softDeletes;
|
|
475
|
+
const deletedAtCol = modelClass.deletedAtColumn || "deleted_at";
|
|
476
|
+
for (const [key, rawVal] of Object.entries(rawRow)) {
|
|
477
|
+
let castType = casts[key] || casts[toCamelCase(key)] || casts[toSnakeCase(key)];
|
|
478
|
+
if (!castType && key.endsWith("_count")) {
|
|
479
|
+
castType = "number";
|
|
480
|
+
}
|
|
481
|
+
if (!castType &&
|
|
482
|
+
isSoftDelete &&
|
|
483
|
+
(key === deletedAtCol ||
|
|
484
|
+
key === toCamelCase(deletedAtCol) ||
|
|
485
|
+
key === toSnakeCase(deletedAtCol))) {
|
|
486
|
+
castType = "datetime";
|
|
487
|
+
}
|
|
488
|
+
const val = castType ? castAttribute(rawVal, castType) : rawVal;
|
|
489
|
+
this._attributes[key] = val;
|
|
490
|
+
this._original[key] = val;
|
|
491
|
+
}
|
|
492
|
+
return this;
|
|
493
|
+
}
|
|
494
|
+
getAttribute(key) {
|
|
495
|
+
if (key in this._attributes) {
|
|
496
|
+
return this._attributes[key];
|
|
497
|
+
}
|
|
498
|
+
const snake = toSnakeCase(key);
|
|
499
|
+
if (snake in this._attributes) {
|
|
500
|
+
return this._attributes[snake];
|
|
501
|
+
}
|
|
502
|
+
const camel = toCamelCase(key);
|
|
503
|
+
if (camel in this._attributes) {
|
|
504
|
+
return this._attributes[camel];
|
|
505
|
+
}
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
setAttribute(key, value) {
|
|
509
|
+
const modelClass = this.constructor;
|
|
510
|
+
const casts = modelClass.casts || {};
|
|
511
|
+
const isSoftDelete = modelClass.softDeletes;
|
|
512
|
+
const deletedAtCol = modelClass.deletedAtColumn || "deleted_at";
|
|
513
|
+
let castType = casts[key] || casts[toSnakeCase(key)] || casts[toCamelCase(key)];
|
|
514
|
+
if (!castType &&
|
|
515
|
+
isSoftDelete &&
|
|
516
|
+
(key === deletedAtCol ||
|
|
517
|
+
key === toSnakeCase(deletedAtCol) ||
|
|
518
|
+
key === toCamelCase(deletedAtCol))) {
|
|
519
|
+
castType = "datetime";
|
|
520
|
+
}
|
|
521
|
+
const val = castType ? castAttribute(value, castType) : value;
|
|
522
|
+
this._attributes[key] = val;
|
|
523
|
+
return this;
|
|
524
|
+
}
|
|
525
|
+
isDirty(key) {
|
|
526
|
+
if (key) {
|
|
527
|
+
const current = this.getAttribute(key);
|
|
528
|
+
const original = key in this._original
|
|
529
|
+
? this._original[key]
|
|
530
|
+
: (this._original[toSnakeCase(key)] ??
|
|
531
|
+
this._original[toCamelCase(key)]);
|
|
532
|
+
return current !== original;
|
|
533
|
+
}
|
|
534
|
+
return Object.keys(this._attributes).some((k) => this._attributes[k] !== this._original[k]);
|
|
535
|
+
}
|
|
536
|
+
getRelation(name) {
|
|
537
|
+
return this._relations[name];
|
|
538
|
+
}
|
|
539
|
+
setRelation(name, value) {
|
|
540
|
+
this._relations[name] = value;
|
|
541
|
+
return this;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Invokes and returns the Relation instance for a given relation name,
|
|
545
|
+
* bypassing any cached/loaded relation data in `_relations`.
|
|
546
|
+
*/
|
|
547
|
+
getRelationDefinition(name) {
|
|
548
|
+
let currProto = Object.getPrototypeOf(this);
|
|
549
|
+
while (currProto && currProto !== Object.prototype) {
|
|
550
|
+
const protoDesc = Object.getOwnPropertyDescriptor(currProto, name);
|
|
551
|
+
if (protoDesc && typeof protoDesc.value === "function") {
|
|
552
|
+
return protoDesc.value.call(this);
|
|
553
|
+
}
|
|
554
|
+
currProto = Object.getPrototypeOf(currProto);
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Saves model to database (INSERT if isNew, UPDATE if exists).
|
|
560
|
+
*/
|
|
561
|
+
async save() {
|
|
562
|
+
if ((await this.fireModelEvent("saving")) === false) {
|
|
563
|
+
return this;
|
|
564
|
+
}
|
|
565
|
+
const modelClass = this.constructor;
|
|
566
|
+
const knex = getKnex();
|
|
567
|
+
const pk = modelClass.primaryKey;
|
|
568
|
+
const casts = modelClass.casts || {};
|
|
569
|
+
const clientType = knex.client.config.client;
|
|
570
|
+
const now = new Date();
|
|
571
|
+
if (this.isNew) {
|
|
572
|
+
if ((await this.fireModelEvent("creating")) === false) {
|
|
573
|
+
return this;
|
|
574
|
+
}
|
|
575
|
+
// Auto-assign UUID if enabled and not already provided
|
|
576
|
+
if (modelClass.autoUuid && !this.getAttribute(pk)) {
|
|
577
|
+
this.setAttribute(pk, crypto.randomUUID());
|
|
578
|
+
}
|
|
579
|
+
// Handle timestamps
|
|
580
|
+
if (modelClass.timestamps) {
|
|
581
|
+
const hasCreatedAt = modelClass.timestamps === true ||
|
|
582
|
+
(Array.isArray(modelClass.timestamps) &&
|
|
583
|
+
(modelClass.timestamps.includes("created_at") ||
|
|
584
|
+
modelClass.timestamps.includes("createdAt")));
|
|
585
|
+
const hasUpdatedAt = modelClass.timestamps === true ||
|
|
586
|
+
(Array.isArray(modelClass.timestamps) &&
|
|
587
|
+
(modelClass.timestamps.includes("updated_at") ||
|
|
588
|
+
modelClass.timestamps.includes("updatedAt")));
|
|
589
|
+
if (hasCreatedAt &&
|
|
590
|
+
!this.getAttribute("created_at") &&
|
|
591
|
+
!this.getAttribute("createdAt")) {
|
|
592
|
+
this.setAttribute("created_at", now);
|
|
593
|
+
}
|
|
594
|
+
if (hasUpdatedAt &&
|
|
595
|
+
!this.getAttribute("updated_at") &&
|
|
596
|
+
!this.getAttribute("updatedAt")) {
|
|
597
|
+
this.setAttribute("updated_at", now);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Serialize attributes for storage
|
|
601
|
+
const insertData = {};
|
|
602
|
+
for (const [k, v] of Object.entries(this._attributes)) {
|
|
603
|
+
const col = toSnakeCase(k);
|
|
604
|
+
let castType = casts[k] || casts[col] || casts[toCamelCase(k)];
|
|
605
|
+
if (!castType &&
|
|
606
|
+
modelClass.softDeletes &&
|
|
607
|
+
(k === modelClass.deletedAtColumn ||
|
|
608
|
+
col === toSnakeCase(modelClass.deletedAtColumn || "deleted_at"))) {
|
|
609
|
+
castType = "datetime";
|
|
610
|
+
}
|
|
611
|
+
insertData[col] = serializeForStorage(v, castType, clientType);
|
|
612
|
+
}
|
|
613
|
+
const insertResult = await knex(modelClass.table)
|
|
614
|
+
.insert(insertData)
|
|
615
|
+
.returning(pk);
|
|
616
|
+
if (!this.getAttribute(pk)) {
|
|
617
|
+
if (Array.isArray(insertResult) && insertResult.length > 0) {
|
|
618
|
+
const first = insertResult[0];
|
|
619
|
+
const returnedId = typeof first === "object" && first !== null ? first[pk] : first;
|
|
620
|
+
if (returnedId !== undefined && returnedId !== null) {
|
|
621
|
+
this.setAttribute(pk, returnedId);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
this.isNew = false;
|
|
626
|
+
this._original = { ...this._attributes };
|
|
627
|
+
await this.fireModelEvent("created");
|
|
628
|
+
await this.fireModelEvent("saved");
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
// Only update if dirty
|
|
632
|
+
if (!this.isDirty()) {
|
|
633
|
+
await this.fireModelEvent("saved");
|
|
634
|
+
return this;
|
|
635
|
+
}
|
|
636
|
+
if ((await this.fireModelEvent("updating")) === false) {
|
|
637
|
+
return this;
|
|
638
|
+
}
|
|
639
|
+
if (modelClass.timestamps) {
|
|
640
|
+
const hasUpdatedAt = modelClass.timestamps === true ||
|
|
641
|
+
(Array.isArray(modelClass.timestamps) &&
|
|
642
|
+
(modelClass.timestamps.includes("updated_at") ||
|
|
643
|
+
modelClass.timestamps.includes("updatedAt")));
|
|
644
|
+
if (hasUpdatedAt) {
|
|
645
|
+
if ("updated_at" in this._attributes ||
|
|
646
|
+
!("updatedAt" in this._attributes)) {
|
|
647
|
+
this.setAttribute("updated_at", now);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
this.setAttribute("updatedAt", now);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const updateData = {};
|
|
655
|
+
for (const [k, v] of Object.entries(this._attributes)) {
|
|
656
|
+
if (this._attributes[k] !== this._original[k]) {
|
|
657
|
+
const col = toSnakeCase(k);
|
|
658
|
+
let castType = casts[k] || casts[col] || casts[toCamelCase(k)];
|
|
659
|
+
if (!castType &&
|
|
660
|
+
modelClass.softDeletes &&
|
|
661
|
+
(k === modelClass.deletedAtColumn ||
|
|
662
|
+
col === toSnakeCase(modelClass.deletedAtColumn || "deleted_at"))) {
|
|
663
|
+
castType = "datetime";
|
|
664
|
+
}
|
|
665
|
+
updateData[col] = serializeForStorage(v, castType, clientType);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (Object.keys(updateData).length > 0) {
|
|
669
|
+
await knex(modelClass.table).where(pk, this.id).update(updateData);
|
|
670
|
+
}
|
|
671
|
+
this._original = { ...this._attributes };
|
|
672
|
+
await this.fireModelEvent("updated");
|
|
673
|
+
await this.fireModelEvent("saved");
|
|
674
|
+
}
|
|
675
|
+
return this;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Updates attributes and saves immediately.
|
|
679
|
+
*/
|
|
680
|
+
async update(attributes) {
|
|
681
|
+
this.fill(attributes);
|
|
682
|
+
return await this.save();
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Deletes model from database (soft-deletes if softDeletes is enabled).
|
|
686
|
+
*/
|
|
687
|
+
async delete() {
|
|
688
|
+
if (this.isNew)
|
|
689
|
+
return false;
|
|
690
|
+
if ((await this.fireModelEvent("deleting")) === false) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const modelClass = this.constructor;
|
|
694
|
+
const knex = getKnex();
|
|
695
|
+
const pk = modelClass.primaryKey;
|
|
696
|
+
const clientType = knex.client.config.client;
|
|
697
|
+
if (modelClass.softDeletes) {
|
|
698
|
+
const colName = modelClass.deletedAtColumn || "deleted_at";
|
|
699
|
+
const col = toSnakeCase(colName);
|
|
700
|
+
const now = new Date();
|
|
701
|
+
await knex(modelClass.table)
|
|
702
|
+
.where(pk, this.id)
|
|
703
|
+
.update({
|
|
704
|
+
[col]: serializeForStorage(now, "datetime", clientType),
|
|
705
|
+
});
|
|
706
|
+
this.setAttribute(colName, now);
|
|
707
|
+
this._original = { ...this._attributes };
|
|
708
|
+
await this.fireModelEvent("deleted");
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
const count = await knex(modelClass.table).where(pk, this.id).delete();
|
|
712
|
+
if (count > 0) {
|
|
713
|
+
await this.fireModelEvent("deleted");
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Permanently deletes model from database, bypassing soft delete.
|
|
720
|
+
*/
|
|
721
|
+
async forceDelete() {
|
|
722
|
+
if (this.isNew)
|
|
723
|
+
return false;
|
|
724
|
+
if ((await this.fireModelEvent("deleting")) === false) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
const modelClass = this.constructor;
|
|
728
|
+
const knex = getKnex();
|
|
729
|
+
const pk = modelClass.primaryKey;
|
|
730
|
+
const count = await knex(modelClass.table).where(pk, this.id).delete();
|
|
731
|
+
if (count > 0) {
|
|
732
|
+
await this.fireModelEvent("deleted");
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Restores a soft-deleted model.
|
|
739
|
+
*/
|
|
740
|
+
async restore() {
|
|
741
|
+
if (this.isNew)
|
|
742
|
+
return false;
|
|
743
|
+
const modelClass = this.constructor;
|
|
744
|
+
if (!modelClass.softDeletes)
|
|
745
|
+
return false;
|
|
746
|
+
if ((await this.fireModelEvent("restoring")) === false) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
const knex = getKnex();
|
|
750
|
+
const pk = modelClass.primaryKey;
|
|
751
|
+
const colName = modelClass.deletedAtColumn || "deleted_at";
|
|
752
|
+
const col = toSnakeCase(colName);
|
|
753
|
+
await knex(modelClass.table)
|
|
754
|
+
.where(pk, this.id)
|
|
755
|
+
.update({ [col]: null });
|
|
756
|
+
this.setAttribute(colName, null);
|
|
757
|
+
this._original = { ...this._attributes };
|
|
758
|
+
await this.fireModelEvent("restored");
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Checks if the model has been soft-deleted.
|
|
763
|
+
*/
|
|
764
|
+
trashed() {
|
|
765
|
+
const modelClass = this.constructor;
|
|
766
|
+
if (!modelClass.softDeletes)
|
|
767
|
+
return false;
|
|
768
|
+
const col = modelClass.deletedAtColumn || "deleted_at";
|
|
769
|
+
const val = this.getAttribute(col);
|
|
770
|
+
return val !== null && val !== undefined;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Alias for trashed().
|
|
774
|
+
*/
|
|
775
|
+
isTrashed() {
|
|
776
|
+
return this.trashed();
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Refreshes model attributes from database.
|
|
780
|
+
*/
|
|
781
|
+
async refresh() {
|
|
782
|
+
if (this.isNew)
|
|
783
|
+
return this;
|
|
784
|
+
const modelClass = this.constructor;
|
|
785
|
+
const pk = modelClass.primaryKey;
|
|
786
|
+
const fresh = await modelClass.query().find(this.id);
|
|
787
|
+
if (fresh) {
|
|
788
|
+
this._attributes = { ...fresh._attributes };
|
|
789
|
+
this._original = { ...fresh._original };
|
|
790
|
+
}
|
|
791
|
+
return this;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Converts model to JSON object including loaded relations.
|
|
795
|
+
* Provides both original and camelCase aliases for seamless frontend DX.
|
|
796
|
+
*/
|
|
797
|
+
toJSON() {
|
|
798
|
+
const result = {};
|
|
799
|
+
for (const [k, v] of Object.entries(this._attributes)) {
|
|
800
|
+
result[k] = v;
|
|
801
|
+
const camel = toCamelCase(k);
|
|
802
|
+
if (camel !== k) {
|
|
803
|
+
result[camel] = v;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
for (const [relName, relValue] of Object.entries(this._relations)) {
|
|
807
|
+
if (Array.isArray(relValue)) {
|
|
808
|
+
result[relName] = relValue.map((item) => typeof item?.toJSON === "function" ? item.toJSON() : item);
|
|
809
|
+
}
|
|
810
|
+
else if (relValue && typeof relValue.toJSON === "function") {
|
|
811
|
+
result[relName] = relValue.toJSON();
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
result[relName] = relValue;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const hidden = this.constructor.hidden || [];
|
|
818
|
+
for (const h of hidden) {
|
|
819
|
+
delete result[h];
|
|
820
|
+
delete result[toCamelCase(h)];
|
|
821
|
+
delete result[toSnakeCase(h)];
|
|
822
|
+
}
|
|
823
|
+
return result;
|
|
824
|
+
}
|
|
825
|
+
// --- RELATION BUILDERS ---
|
|
826
|
+
hasOne(related, foreignKey, localKey = this.constructor.primaryKey) {
|
|
827
|
+
const fk = foreignKey
|
|
828
|
+
? toSnakeCase(foreignKey)
|
|
829
|
+
: `${toSnakeCase(this.constructor.table)}_id`;
|
|
830
|
+
return new HasOne(this, related, fk, localKey);
|
|
831
|
+
}
|
|
832
|
+
hasMany(related, foreignKey, localKey = this.constructor.primaryKey) {
|
|
833
|
+
const fk = foreignKey
|
|
834
|
+
? toSnakeCase(foreignKey)
|
|
835
|
+
: `${toSnakeCase(this.constructor.table)}_id`;
|
|
836
|
+
return new HasMany(this, related, fk, localKey);
|
|
837
|
+
}
|
|
838
|
+
belongsTo(related, foreignKey, ownerKey = related.primaryKey || "id") {
|
|
839
|
+
const fallbackFk = `${toSnakeCase(related.name || related.table)}_id`;
|
|
840
|
+
const fk = foreignKey ? toSnakeCase(foreignKey) : fallbackFk;
|
|
841
|
+
return new BelongsTo(this, related, fk, ownerKey);
|
|
842
|
+
}
|
|
843
|
+
belongsToMany(related, pivotTable, foreignPivotKey, relatedPivotKey, localKey = this.constructor.primaryKey, relatedKey = related.primaryKey || "id") {
|
|
844
|
+
const fpk = foreignPivotKey
|
|
845
|
+
? toSnakeCase(foreignPivotKey)
|
|
846
|
+
: `${toSnakeCase(this.constructor.table)}_id`;
|
|
847
|
+
const rpk = relatedPivotKey
|
|
848
|
+
? toSnakeCase(relatedPivotKey)
|
|
849
|
+
: `${toSnakeCase(related.name || related.table)}_id`;
|
|
850
|
+
return new BelongsToMany(this, related, pivotTable, fpk, rpk, localKey, relatedKey);
|
|
851
|
+
}
|
|
852
|
+
// --- POLYMORPHIC RELATION BUILDERS ---
|
|
853
|
+
/**
|
|
854
|
+
* Defines a polymorphic, inverse one-to-one or one-to-many relation.
|
|
855
|
+
* E.g. comment.morphTo('commentable') -> resolves type from commentable_type and id from commentable_id
|
|
856
|
+
*/
|
|
857
|
+
morphTo(name, type, id) {
|
|
858
|
+
const callerName = name || "commentable";
|
|
859
|
+
const typeCol = type
|
|
860
|
+
? toSnakeCase(type)
|
|
861
|
+
: `${toSnakeCase(callerName)}_type`;
|
|
862
|
+
const idCol = id ? toSnakeCase(id) : `${toSnakeCase(callerName)}_id`;
|
|
863
|
+
return new MorphTo(this, callerName, typeCol, idCol);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Defines a polymorphic one-to-one relation.
|
|
867
|
+
* E.g. user.morphOne(Image, 'imageable')
|
|
868
|
+
*/
|
|
869
|
+
morphOne(related, name, type, id, localKey = this.constructor.primaryKey || "id") {
|
|
870
|
+
const typeCol = type ? toSnakeCase(type) : `${toSnakeCase(name)}_type`;
|
|
871
|
+
const idCol = id ? toSnakeCase(id) : `${toSnakeCase(name)}_id`;
|
|
872
|
+
return new MorphOne(this, related, name, typeCol, idCol, localKey);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Defines a polymorphic one-to-many relation.
|
|
876
|
+
* E.g. post.morphMany(Comment, 'commentable')
|
|
877
|
+
*/
|
|
878
|
+
morphMany(related, name, type, id, localKey = this.constructor.primaryKey || "id") {
|
|
879
|
+
const typeCol = type ? toSnakeCase(type) : `${toSnakeCase(name)}_type`;
|
|
880
|
+
const idCol = id ? toSnakeCase(id) : `${toSnakeCase(name)}_id`;
|
|
881
|
+
return new MorphMany(this, related, name, typeCol, idCol, localKey);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Defines a polymorphic many-to-many relation where the parent is the polymorphic entity.
|
|
885
|
+
* E.g. post.morphToMany(Tag, 'taggable', 'taggables')
|
|
886
|
+
*/
|
|
887
|
+
morphToMany(related, name, pivotTable = `${toSnakeCase(name)}s`, foreignPivotKey, relatedPivotKey, parentKey = this.constructor.primaryKey || "id", relatedKey = related.primaryKey || "id") {
|
|
888
|
+
const fpk = foreignPivotKey
|
|
889
|
+
? toSnakeCase(foreignPivotKey)
|
|
890
|
+
: `${toSnakeCase(name)}_id`;
|
|
891
|
+
const relatedAlias = MorphMap.getMorphAlias(related);
|
|
892
|
+
const rpk = relatedPivotKey
|
|
893
|
+
? toSnakeCase(relatedPivotKey)
|
|
894
|
+
: `${toSnakeCase(relatedAlias)}_id`;
|
|
895
|
+
const morphTypeColumn = `${toSnakeCase(name)}_type`;
|
|
896
|
+
return new MorphToMany(this, related, name, pivotTable, fpk, rpk, morphTypeColumn, parentKey, relatedKey);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Defines the inverse of a polymorphic many-to-many relation.
|
|
900
|
+
* E.g. tag.morphedByMany(BlogPost, 'taggable', 'taggables')
|
|
901
|
+
*/
|
|
902
|
+
morphedByMany(related, name, pivotTable = `${toSnakeCase(name)}s`, foreignPivotKey, relatedPivotKey, parentKey = this.constructor.primaryKey || "id", relatedKey = related.primaryKey || "id") {
|
|
903
|
+
const fpk = foreignPivotKey
|
|
904
|
+
? toSnakeCase(foreignPivotKey)
|
|
905
|
+
: `${toSnakeCase(name)}_id`;
|
|
906
|
+
const parentAlias = MorphMap.getMorphAlias(this.constructor);
|
|
907
|
+
const rpk = relatedPivotKey
|
|
908
|
+
? toSnakeCase(relatedPivotKey)
|
|
909
|
+
: `${toSnakeCase(parentAlias)}_id`;
|
|
910
|
+
const morphTypeColumn = `${toSnakeCase(name)}_type`;
|
|
911
|
+
return new MorphedByMany(this, related, name, pivotTable, fpk, rpk, morphTypeColumn, parentKey, relatedKey);
|
|
912
|
+
}
|
|
913
|
+
}
|