@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -2
  3. package/dist/casts.d.ts +9 -0
  4. package/dist/casts.js +78 -0
  5. package/dist/composition.d.ts +15 -0
  6. package/dist/composition.js +26 -0
  7. package/dist/connection.d.ts +49 -0
  8. package/dist/connection.js +193 -0
  9. package/dist/errors.d.ts +7 -0
  10. package/dist/errors.js +17 -0
  11. package/dist/events.d.ts +5 -0
  12. package/dist/events.js +7 -0
  13. package/dist/factory.d.ts +68 -0
  14. package/dist/factory.js +161 -0
  15. package/dist/index.d.ts +19 -0
  16. package/dist/index.js +19 -0
  17. package/dist/logger.d.ts +11 -0
  18. package/dist/logger.js +21 -0
  19. package/dist/migration-provider.d.ts +7 -0
  20. package/dist/migration-provider.js +36 -0
  21. package/dist/migrations/generator.d.ts +4 -0
  22. package/dist/migrations/generator.js +58 -0
  23. package/dist/migrations/index.d.ts +3 -0
  24. package/dist/migrations/index.js +3 -0
  25. package/dist/migrations/runner.d.ts +12 -0
  26. package/dist/migrations/runner.js +179 -0
  27. package/dist/migrations/schema-builder.d.ts +84 -0
  28. package/dist/migrations/schema-builder.js +409 -0
  29. package/dist/model.d.ts +379 -0
  30. package/dist/model.js +913 -0
  31. package/dist/provider.d.ts +13 -0
  32. package/dist/provider.js +112 -0
  33. package/dist/query-builder.d.ts +197 -0
  34. package/dist/query-builder.js +749 -0
  35. package/dist/relations/belongs-to-many.d.ts +15 -0
  36. package/dist/relations/belongs-to-many.js +80 -0
  37. package/dist/relations/belongs-to.d.ts +8 -0
  38. package/dist/relations/belongs-to.js +37 -0
  39. package/dist/relations/eager-loader.d.ts +5 -0
  40. package/dist/relations/eager-loader.js +68 -0
  41. package/dist/relations/has-many.d.ts +9 -0
  42. package/dist/relations/has-many.js +48 -0
  43. package/dist/relations/has-one.d.ts +8 -0
  44. package/dist/relations/has-one.js +37 -0
  45. package/dist/relations/index.d.ts +11 -0
  46. package/dist/relations/index.js +11 -0
  47. package/dist/relations/morph-many.d.ts +14 -0
  48. package/dist/relations/morph-many.js +65 -0
  49. package/dist/relations/morph-map.d.ts +32 -0
  50. package/dist/relations/morph-map.js +73 -0
  51. package/dist/relations/morph-one.d.ts +14 -0
  52. package/dist/relations/morph-one.js +65 -0
  53. package/dist/relations/morph-to-many.d.ts +44 -0
  54. package/dist/relations/morph-to-many.js +215 -0
  55. package/dist/relations/morph-to.d.ts +19 -0
  56. package/dist/relations/morph-to.js +90 -0
  57. package/dist/relations/relation.d.ts +22 -0
  58. package/dist/relations/relation.js +13 -0
  59. package/dist/scopes.d.ts +16 -0
  60. package/dist/scopes.js +1 -0
  61. package/dist/seeder.d.ts +25 -0
  62. package/dist/seeder.js +49 -0
  63. package/dist/tokens.d.ts +10 -0
  64. package/dist/tokens.js +8 -0
  65. package/dist/types.d.ts +31 -0
  66. package/dist/types.js +1 -0
  67. package/dist/utils.d.ts +9 -0
  68. package/dist/utils.js +20 -0
  69. package/package.json +58 -4
@@ -0,0 +1,749 @@
1
+ import { serializeForStorage } from "./casts.js";
2
+ import { getKnex } from "./connection.js";
3
+ import { DatabaseError } from "./errors.js";
4
+ import { eagerLoadRelations } from "./relations/eager-loader.js";
5
+ import { BelongsTo, BelongsToMany, MorphedByMany, MorphMany, MorphOne, MorphToMany, } from "./relations/index.js";
6
+ import { toCamelCase, toSnakeCase } from "./utils.js";
7
+ export class ModelQueryBuilder {
8
+ modelClass;
9
+ knexBuilder;
10
+ eagerRelations = [];
11
+ _withTrashed = false;
12
+ _onlyTrashed = false;
13
+ _softDeleteScopeApplied = false;
14
+ _scopesApplied = false;
15
+ _ignoredGlobalScopes = new Set();
16
+ _ignoreAllGlobalScopes = false;
17
+ constructor(modelClass, // typeof Model
18
+ customKnexBuilder) {
19
+ this.modelClass = modelClass;
20
+ const knex = getKnex();
21
+ this.knexBuilder = customKnexBuilder || knex(modelClass.table);
22
+ // Return Proxy to enable dynamic local scope dispatching (e.g. query.active(), query.popular(10))
23
+ return new Proxy(this, {
24
+ get(target, prop, receiver) {
25
+ if (typeof prop === "string") {
26
+ // 1. Direct property or method on ModelQueryBuilder
27
+ if (prop in target) {
28
+ const val = Reflect.get(target, prop, receiver);
29
+ if (typeof val === "function") {
30
+ return (...args) => {
31
+ const res = val.apply(target, args);
32
+ return res === target ? receiver : res;
33
+ };
34
+ }
35
+ return val;
36
+ }
37
+ // 2. Check for local scope method on modelClass (prototype or static)
38
+ // e.g. "active" -> "scopeActive", "popular" -> "scopePopular"
39
+ const scopeName = `scope${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
40
+ const proto = target.modelClass?.prototype;
41
+ const fn = proto?.[scopeName] || target.modelClass?.[scopeName];
42
+ if (typeof fn === "function") {
43
+ return (...args) => {
44
+ const result = fn.call(proto || target.modelClass, receiver, ...args);
45
+ return result === target || result === undefined
46
+ ? receiver
47
+ : result;
48
+ };
49
+ }
50
+ // 3. Delegate to Knex query builder if method exists (e.g. groupBy, having, distinct, joinRaw)
51
+ if (target.knexBuilder &&
52
+ typeof target.knexBuilder[prop] === "function") {
53
+ return (...args) => {
54
+ const result = target.knexBuilder[prop](...args);
55
+ return result === target.knexBuilder ? receiver : result;
56
+ };
57
+ }
58
+ }
59
+ return Reflect.get(target, prop, receiver);
60
+ },
61
+ });
62
+ }
63
+ /**
64
+ * Explicitly applies a local scope method by name.
65
+ */
66
+ scope(name, ...args) {
67
+ const scopeName = name.startsWith("scope")
68
+ ? name
69
+ : `scope${name.charAt(0).toUpperCase()}${name.slice(1)}`;
70
+ const proto = this.modelClass?.prototype;
71
+ const fn = proto?.[scopeName] || this.modelClass?.[scopeName];
72
+ if (typeof fn !== "function") {
73
+ throw DatabaseError.Internal(`[veap:ORM] Scope "${name}" (${scopeName}) not found on model ${this.modelClass?.name || this.modelClass?.table}.`);
74
+ }
75
+ const result = fn.call(proto || this.modelClass, this, ...args);
76
+ return result !== undefined ? result : this;
77
+ }
78
+ /**
79
+ * Excludes a specific global scope from this query.
80
+ */
81
+ withoutGlobalScope(scope) {
82
+ this._ignoredGlobalScopes.add(scope);
83
+ if (scope === "softDeletes" || scope === "softDelete") {
84
+ this.withTrashed();
85
+ }
86
+ return this;
87
+ }
88
+ /**
89
+ * Excludes multiple or all global scopes from this query.
90
+ */
91
+ withoutGlobalScopes(scopes) {
92
+ if (!scopes || scopes.length === 0) {
93
+ this._ignoreAllGlobalScopes = true;
94
+ this.withTrashed();
95
+ }
96
+ else {
97
+ for (const s of scopes) {
98
+ this.withoutGlobalScope(s);
99
+ }
100
+ }
101
+ return this;
102
+ }
103
+ /**
104
+ * Includes soft-deleted models in query results.
105
+ */
106
+ withTrashed() {
107
+ this._withTrashed = true;
108
+ this._onlyTrashed = false;
109
+ return this;
110
+ }
111
+ /**
112
+ * Restricts query to only soft-deleted models.
113
+ */
114
+ onlyTrashed() {
115
+ this._onlyTrashed = true;
116
+ this._withTrashed = false;
117
+ return this;
118
+ }
119
+ /**
120
+ * Applies both user-defined global scopes and soft-delete scope.
121
+ */
122
+ applyGlobalScopes() {
123
+ if (this._scopesApplied) {
124
+ return;
125
+ }
126
+ this._scopesApplied = true;
127
+ // 1. User-defined global scopes from modelClass
128
+ if (!this._ignoreAllGlobalScopes && this.modelClass) {
129
+ const globalScopes = typeof this.modelClass.getGlobalScopes === "function"
130
+ ? this.modelClass.getGlobalScopes()
131
+ : this.modelClass.globalScopes || {};
132
+ for (const [name, scope] of Object.entries(globalScopes)) {
133
+ if (!this._ignoredGlobalScopes.has(name)) {
134
+ if (typeof scope === "function") {
135
+ scope(this, this.modelClass);
136
+ }
137
+ else if (scope && typeof scope.apply === "function") {
138
+ scope.apply(this, this.modelClass);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ // 2. Soft deletes
144
+ this.applySoftDeleteScope();
145
+ }
146
+ /**
147
+ * Applies the soft delete filter to the underlying query if enabled.
148
+ */
149
+ applySoftDeleteScope() {
150
+ if (this._softDeleteScopeApplied || !this.modelClass?.softDeletes) {
151
+ return;
152
+ }
153
+ const colName = toSnakeCase(this.modelClass.deletedAtColumn || "deleted_at");
154
+ const column = this.modelClass?.table
155
+ ? `${this.modelClass.table}.${colName}`
156
+ : colName;
157
+ if (this._onlyTrashed) {
158
+ this.knexBuilder.whereNotNull(column);
159
+ }
160
+ else if (!this._withTrashed) {
161
+ this.knexBuilder.whereNull(column);
162
+ }
163
+ this._softDeleteScopeApplied = true;
164
+ }
165
+ /**
166
+ * Returns the underlying Knex query builder.
167
+ */
168
+ toKnex() {
169
+ return this.knexBuilder;
170
+ }
171
+ /**
172
+ * Basic WHERE clause. Automatically maps camelCase to snake_case columns.
173
+ */
174
+ where(column, operator, value) {
175
+ if (typeof column === "function") {
176
+ this.knexBuilder.where(column);
177
+ }
178
+ else if (typeof column === "object" && column !== null) {
179
+ const converted = {};
180
+ for (const [k, v] of Object.entries(column)) {
181
+ converted[toSnakeCase(k)] = v;
182
+ }
183
+ this.knexBuilder.where(converted);
184
+ }
185
+ else if (typeof column === "string") {
186
+ const col = toSnakeCase(column);
187
+ if (value === undefined) {
188
+ this.knexBuilder.where(col, operator);
189
+ }
190
+ else {
191
+ this.knexBuilder.where(col, operator, value);
192
+ }
193
+ }
194
+ else {
195
+ this.knexBuilder.where(column, operator, value);
196
+ }
197
+ return this;
198
+ }
199
+ orWhere(column, operator, value) {
200
+ if (typeof column === "function") {
201
+ this.knexBuilder.orWhere(column);
202
+ }
203
+ else if (typeof column === "object" && column !== null) {
204
+ const converted = {};
205
+ for (const [k, v] of Object.entries(column)) {
206
+ converted[toSnakeCase(k)] = v;
207
+ }
208
+ this.knexBuilder.orWhere(converted);
209
+ }
210
+ else if (typeof column === "string") {
211
+ const col = toSnakeCase(column);
212
+ if (value === undefined) {
213
+ this.knexBuilder.orWhere(col, operator);
214
+ }
215
+ else {
216
+ this.knexBuilder.orWhere(col, operator, value);
217
+ }
218
+ }
219
+ else {
220
+ this.knexBuilder.orWhere(column, operator, value);
221
+ }
222
+ return this;
223
+ }
224
+ whereIn(column, values) {
225
+ this.knexBuilder.whereIn(toSnakeCase(column), values);
226
+ return this;
227
+ }
228
+ whereNotIn(column, values) {
229
+ this.knexBuilder.whereNotIn(toSnakeCase(column), values);
230
+ return this;
231
+ }
232
+ whereNull(column) {
233
+ this.knexBuilder.whereNull(toSnakeCase(column));
234
+ return this;
235
+ }
236
+ whereNotNull(column) {
237
+ this.knexBuilder.whereNotNull(toSnakeCase(column));
238
+ return this;
239
+ }
240
+ whereBetween(column, range) {
241
+ this.knexBuilder.whereBetween(toSnakeCase(column), range);
242
+ return this;
243
+ }
244
+ whereLike(column, pattern) {
245
+ this.knexBuilder.whereLike(toSnakeCase(column), pattern);
246
+ return this;
247
+ }
248
+ whereILike(column, pattern) {
249
+ this.knexBuilder.whereILike(toSnakeCase(column), pattern);
250
+ return this;
251
+ }
252
+ select(...columns) {
253
+ const converted = columns.map((col) => typeof col === "string" ? toSnakeCase(col) : col);
254
+ this.knexBuilder.select(...converted);
255
+ return this;
256
+ }
257
+ orderBy(column, direction = "asc") {
258
+ this.knexBuilder.orderBy(toSnakeCase(column), direction);
259
+ return this;
260
+ }
261
+ latest(column = "created_at") {
262
+ return this.orderBy(column, "desc");
263
+ }
264
+ oldest(column = "created_at") {
265
+ return this.orderBy(column, "asc");
266
+ }
267
+ limit(count) {
268
+ this.knexBuilder.limit(count);
269
+ return this;
270
+ }
271
+ take(count) {
272
+ return this.limit(count);
273
+ }
274
+ offset(count) {
275
+ this.knexBuilder.offset(count);
276
+ return this;
277
+ }
278
+ skip(count) {
279
+ return this.offset(count);
280
+ }
281
+ groupBy(...columns) {
282
+ const converted = columns.map((col) => typeof col === "string" ? toSnakeCase(col) : col);
283
+ this.knexBuilder.groupBy(...converted);
284
+ return this;
285
+ }
286
+ having(column, operatorOrValue, value) {
287
+ if (value === undefined) {
288
+ if (operatorOrValue === undefined) {
289
+ this.knexBuilder.having(column);
290
+ }
291
+ else {
292
+ this.knexBuilder.having(column, "=", operatorOrValue);
293
+ }
294
+ }
295
+ else {
296
+ this.knexBuilder.having(column, operatorOrValue, value);
297
+ }
298
+ return this;
299
+ }
300
+ distinct(...columns) {
301
+ const converted = columns.map((col) => typeof col === "string" ? toSnakeCase(col) : col);
302
+ this.knexBuilder.distinct(...converted);
303
+ return this;
304
+ }
305
+ join(table, first, operatorOrSecond, second) {
306
+ if (second === undefined) {
307
+ this.knexBuilder.join(table, first, operatorOrSecond);
308
+ }
309
+ else {
310
+ this.knexBuilder.join(table, first, operatorOrSecond, second);
311
+ }
312
+ return this;
313
+ }
314
+ leftJoin(table, first, operatorOrSecond, second) {
315
+ if (second === undefined) {
316
+ this.knexBuilder.leftJoin(table, first, operatorOrSecond);
317
+ }
318
+ else {
319
+ this.knexBuilder.leftJoin(table, first, operatorOrSecond, second);
320
+ }
321
+ return this;
322
+ }
323
+ /**
324
+ * Eager load relations (e.g. .with('author', 'posts.comments'))
325
+ */
326
+ with(...relations) {
327
+ const flattened = relations.flat();
328
+ this.eagerRelations.push(...flattened);
329
+ return this;
330
+ }
331
+ /**
332
+ * Helper to retrieve a relation instance from a temporary model instance.
333
+ */
334
+ getRelationInstance(relationName) {
335
+ const dummy = new this.modelClass({}, false);
336
+ if (typeof dummy[relationName] !== "function") {
337
+ throw DatabaseError.Internal(`[veap:ORM] Relation "${relationName}" is not defined on model ${this.modelClass.name || this.modelClass.table}.`);
338
+ }
339
+ const rel = dummy[relationName]();
340
+ if (!rel) {
341
+ throw DatabaseError.Internal(`[veap:ORM] Method "${relationName}" on model ${this.modelClass.name || this.modelClass.table} did not return a valid Relation instance.`);
342
+ }
343
+ return rel;
344
+ }
345
+ /**
346
+ * Builds an EXISTS or COUNT subquery for a relation.
347
+ */
348
+ buildRelationSubquery(sub, relationName, callback, isCount = false) {
349
+ const rel = this.getRelationInstance(relationName);
350
+ const parentTable = this.modelClass.table;
351
+ const relatedTable = rel.related.table;
352
+ const knex = getKnex();
353
+ if (isCount) {
354
+ sub.from(relatedTable).count("* as count");
355
+ }
356
+ else {
357
+ sub.from(relatedTable).select(knex.raw(1));
358
+ }
359
+ if (rel instanceof MorphToMany) {
360
+ const pivotTable = rel.pivotTable;
361
+ const foreignPivotKey = toSnakeCase(rel.foreignPivotKey);
362
+ const relatedPivotKey = toSnakeCase(rel.relatedPivotKey);
363
+ const relatedKey = toSnakeCase(rel.relatedKey || "id");
364
+ const localKey = toSnakeCase(rel.localKey || "id");
365
+ const typeColumn = toSnakeCase(rel.morphTypeColumn);
366
+ sub.join(pivotTable, `${relatedTable}.${relatedKey}`, `${pivotTable}.${relatedPivotKey}`);
367
+ sub.where(`${pivotTable}.${typeColumn}`, rel.morphType);
368
+ sub.whereRaw("CAST(?? AS TEXT) = CAST(?? AS TEXT)", [
369
+ `${pivotTable}.${foreignPivotKey}`,
370
+ `${parentTable}.${localKey}`,
371
+ ]);
372
+ }
373
+ else if (rel instanceof MorphedByMany) {
374
+ const pivotTable = rel.pivotTable;
375
+ const foreignPivotKey = toSnakeCase(rel.foreignPivotKey);
376
+ const relatedPivotKey = toSnakeCase(rel.relatedPivotKey);
377
+ const relatedKey = toSnakeCase(rel.relatedKey || "id");
378
+ const localKey = toSnakeCase(rel.localKey || "id");
379
+ const typeColumn = toSnakeCase(rel.morphTypeColumn);
380
+ sub.join(pivotTable, function () {
381
+ this.on(knex.raw("CAST(?? AS TEXT) = CAST(?? AS TEXT)", [
382
+ `${relatedTable}.${relatedKey}`,
383
+ `${pivotTable}.${foreignPivotKey}`,
384
+ ]));
385
+ });
386
+ sub.where(`${pivotTable}.${typeColumn}`, rel.morphType);
387
+ sub.whereRaw("?? = ??", [
388
+ `${pivotTable}.${relatedPivotKey}`,
389
+ `${parentTable}.${localKey}`,
390
+ ]);
391
+ }
392
+ else if (rel instanceof BelongsToMany || rel.pivotTable) {
393
+ const pivotTable = rel.pivotTable;
394
+ const foreignPivotKey = toSnakeCase(rel.foreignPivotKey);
395
+ const relatedPivotKey = toSnakeCase(rel.relatedPivotKey);
396
+ const relatedKey = toSnakeCase(rel.relatedKey || "id");
397
+ const localKey = toSnakeCase(rel.localKey || "id");
398
+ sub.join(pivotTable, `${relatedTable}.${relatedKey}`, `${pivotTable}.${relatedPivotKey}`);
399
+ sub.whereRaw("?? = ??", [
400
+ `${pivotTable}.${foreignPivotKey}`,
401
+ `${parentTable}.${localKey}`,
402
+ ]);
403
+ }
404
+ else if (rel instanceof BelongsTo) {
405
+ const foreignKey = toSnakeCase(rel.foreignKey);
406
+ const localKey = toSnakeCase(rel.localKey || "id");
407
+ sub.whereRaw("?? = ??", [
408
+ `${relatedTable}.${localKey}`,
409
+ `${parentTable}.${foreignKey}`,
410
+ ]);
411
+ }
412
+ else if (rel instanceof MorphMany || rel instanceof MorphOne) {
413
+ const foreignKey = toSnakeCase(rel.idColumn || rel.foreignKey);
414
+ const localKey = toSnakeCase(rel.localKey || "id");
415
+ const typeColumn = toSnakeCase(rel.typeColumn);
416
+ sub.where(`${relatedTable}.${typeColumn}`, rel.morphType);
417
+ sub.whereRaw("CAST(?? AS TEXT) = CAST(?? AS TEXT)", [
418
+ `${relatedTable}.${foreignKey}`,
419
+ `${parentTable}.${localKey}`,
420
+ ]);
421
+ }
422
+ else {
423
+ // HasOne or HasMany
424
+ const foreignKey = toSnakeCase(rel.foreignKey);
425
+ const localKey = toSnakeCase(rel.localKey || "id");
426
+ sub.whereRaw("?? = ??", [
427
+ `${relatedTable}.${foreignKey}`,
428
+ `${parentTable}.${localKey}`,
429
+ ]);
430
+ }
431
+ if (rel.related.softDeletes) {
432
+ const delCol = toSnakeCase(rel.related.deletedAtColumn || "deleted_at");
433
+ sub.whereNull(`${relatedTable}.${delCol}`);
434
+ }
435
+ if (callback) {
436
+ const relatedQuery = new ModelQueryBuilder(rel.related, sub);
437
+ callback(relatedQuery);
438
+ }
439
+ }
440
+ /**
441
+ * Adds a relationship count condition to the query with where clause.
442
+ */
443
+ has(relation, operator = ">=", count = 1, boolean = "and", callback) {
444
+ if (operator === ">=" && count === 1 && !callback) {
445
+ return boolean === "or"
446
+ ? this.orWhereHas(relation)
447
+ : this.whereHas(relation);
448
+ }
449
+ if (((operator === "<" && count === 1) ||
450
+ (operator === "=" && count === 0)) &&
451
+ !callback) {
452
+ return boolean === "or"
453
+ ? this.orWhereDoesntHave(relation)
454
+ : this.whereDoesntHave(relation);
455
+ }
456
+ const knex = getKnex();
457
+ const sub = knex.queryBuilder();
458
+ this.buildRelationSubquery(sub, relation, callback, true);
459
+ const rawClause = `(${sub.toQuery()}) ${operator} ?`;
460
+ if (boolean === "or") {
461
+ this.knexBuilder.orWhereRaw(rawClause, [count]);
462
+ }
463
+ else {
464
+ this.knexBuilder.whereRaw(rawClause, [count]);
465
+ }
466
+ return this;
467
+ }
468
+ /**
469
+ * Adds an OR relationship count condition to the query.
470
+ */
471
+ orHas(relation, operator = ">=", count = 1, callback) {
472
+ return this.has(relation, operator, count, "or", callback);
473
+ }
474
+ /**
475
+ * Adds a relationship existence condition with an optional callback constraint.
476
+ */
477
+ whereHas(relation, callback) {
478
+ this.knexBuilder.whereExists((sub) => {
479
+ this.buildRelationSubquery(sub, relation, callback, false);
480
+ });
481
+ return this;
482
+ }
483
+ /**
484
+ * Adds an OR relationship existence condition with an optional callback constraint.
485
+ */
486
+ orWhereHas(relation, callback) {
487
+ this.knexBuilder.orWhereExists((sub) => {
488
+ this.buildRelationSubquery(sub, relation, callback, false);
489
+ });
490
+ return this;
491
+ }
492
+ /**
493
+ * Adds a relationship absence condition with an optional callback constraint.
494
+ */
495
+ whereDoesntHave(relation, callback) {
496
+ this.knexBuilder.whereNotExists((sub) => {
497
+ this.buildRelationSubquery(sub, relation, callback, false);
498
+ });
499
+ return this;
500
+ }
501
+ /**
502
+ * Adds an OR relationship absence condition with an optional callback constraint.
503
+ */
504
+ orWhereDoesntHave(relation, callback) {
505
+ this.knexBuilder.orWhereNotExists((sub) => {
506
+ this.buildRelationSubquery(sub, relation, callback, false);
507
+ });
508
+ return this;
509
+ }
510
+ /**
511
+ * Alias for whereDoesntHave.
512
+ */
513
+ doesntHave(relation) {
514
+ return this.whereDoesntHave(relation);
515
+ }
516
+ /**
517
+ * Alias for orWhereDoesntHave.
518
+ */
519
+ orDoesntHave(relation) {
520
+ return this.orWhereDoesntHave(relation);
521
+ }
522
+ /**
523
+ * Adds subquery counts for related models as `{relation}_count`.
524
+ */
525
+ withCount(...relations) {
526
+ const normalized = {};
527
+ for (const item of relations) {
528
+ if (typeof item === "string") {
529
+ normalized[item] = undefined;
530
+ }
531
+ else if (Array.isArray(item)) {
532
+ for (const subItem of item) {
533
+ normalized[subItem] = undefined;
534
+ }
535
+ }
536
+ else if (typeof item === "object" && item !== null) {
537
+ for (const [key, callback] of Object.entries(item)) {
538
+ normalized[key] = callback;
539
+ }
540
+ }
541
+ }
542
+ // Ensure main table columns are selected if no columns have been explicitly selected yet
543
+ const statements = this.knexBuilder._statements || [];
544
+ const hasColumns = statements.some((s) => s.grouping === "columns");
545
+ if (!hasColumns) {
546
+ this.knexBuilder.select(`${this.modelClass.table}.*`);
547
+ }
548
+ const knex = getKnex();
549
+ for (const [relName, callback] of Object.entries(normalized)) {
550
+ const sub = knex.queryBuilder();
551
+ this.buildRelationSubquery(sub, relName, callback, true);
552
+ const colAlias = `${toSnakeCase(relName)}_count`;
553
+ this.knexBuilder.select(knex.raw(`(${sub.toQuery()}) as ??`, [colAlias]));
554
+ }
555
+ return this;
556
+ }
557
+ /**
558
+ * Executes query and returns hydrated Model instances.
559
+ */
560
+ async get() {
561
+ this.applyGlobalScopes();
562
+ const rows = await this.knexBuilder;
563
+ const models = rows.map((row) => {
564
+ return new this.modelClass(row, false);
565
+ });
566
+ if (this.eagerRelations.length > 0 && models.length > 0) {
567
+ await eagerLoadRelations(models, this.eagerRelations);
568
+ }
569
+ return models;
570
+ }
571
+ /**
572
+ * Returns the first matched Model instance or null.
573
+ */
574
+ async first() {
575
+ const models = await this.limit(1).get();
576
+ return models[0] || null;
577
+ }
578
+ /**
579
+ * Returns the first matched Model or throws an error.
580
+ */
581
+ async firstOrFail() {
582
+ const model = await this.first();
583
+ if (!model) {
584
+ throw DatabaseError.Internal(`[veap:ORM] Model ${this.modelClass.name} not found matching query.`);
585
+ }
586
+ return model;
587
+ }
588
+ /**
589
+ * Get the first record matching the attributes or instantiate it.
590
+ */
591
+ async firstOrNew(attributes, values = {}) {
592
+ const existing = await this.where(attributes).first();
593
+ if (existing) {
594
+ return existing;
595
+ }
596
+ return new this.modelClass({ ...attributes, ...values }, true);
597
+ }
598
+ /**
599
+ * Get the first record matching the attributes or create it.
600
+ */
601
+ async firstOrCreate(attributes, values = {}) {
602
+ const existing = await this.where(attributes).first();
603
+ if (existing) {
604
+ return existing;
605
+ }
606
+ const instance = new this.modelClass({ ...attributes, ...values }, true);
607
+ await instance.save();
608
+ return instance;
609
+ }
610
+ /**
611
+ * Create or update a record matching the attributes, and fill it with values.
612
+ */
613
+ async updateOrCreate(attributes, values = {}) {
614
+ const existing = await this.where(attributes).first();
615
+ if (existing) {
616
+ existing.fill(values);
617
+ await existing.save();
618
+ return existing;
619
+ }
620
+ const instance = new this.modelClass({ ...attributes, ...values }, true);
621
+ await instance.save();
622
+ return instance;
623
+ }
624
+ /**
625
+ * Finds a record by its primary key.
626
+ */
627
+ async find(id) {
628
+ const pk = this.modelClass.primaryKey || "id";
629
+ return await this.where(pk, id).first();
630
+ }
631
+ /**
632
+ * Finds a record by primary key or throws an error.
633
+ */
634
+ async findOrFail(id) {
635
+ const model = await this.find(id);
636
+ if (!model) {
637
+ throw DatabaseError.Internal(`[veap:ORM] Record not found in ${this.modelClass.name} with ID: ${id}`);
638
+ }
639
+ return model;
640
+ }
641
+ /**
642
+ * Counts rows matching criteria.
643
+ */
644
+ async count(column = "*") {
645
+ this.applyGlobalScopes();
646
+ const col = column === "*" ? "*" : toSnakeCase(column);
647
+ const [result] = await this.knexBuilder.count({ count: col });
648
+ const countVal = result?.count;
649
+ return Number(countVal || 0);
650
+ }
651
+ /**
652
+ * Checks if any records exist matching criteria.
653
+ */
654
+ async exists() {
655
+ return (await this.count()) > 0;
656
+ }
657
+ /**
658
+ * Plucks a single column's values into an array.
659
+ */
660
+ async pluck(column) {
661
+ this.applyGlobalScopes();
662
+ const col = toSnakeCase(column);
663
+ const rows = await this.knexBuilder.select(col);
664
+ return rows.map((row) => row[col] !== undefined ? row[col] : row[column]);
665
+ }
666
+ /**
667
+ * Paginates results.
668
+ */
669
+ async paginate(page = 1, perPage = 15) {
670
+ this.applyGlobalScopes();
671
+ const countBuilder = this.knexBuilder.clone().clearSelect().clearOrder();
672
+ const [countResult] = await countBuilder.count({ total: "*" });
673
+ const total = Number(countResult?.total || 0);
674
+ const offset = Math.max(0, (page - 1) * perPage);
675
+ const data = await this.offset(offset).limit(perPage).get();
676
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
677
+ return {
678
+ data,
679
+ total,
680
+ page,
681
+ currentPage: page,
682
+ perPage,
683
+ lastPage,
684
+ };
685
+ }
686
+ /**
687
+ * Bulk updates matching records.
688
+ */
689
+ async update(attributes) {
690
+ this.applyGlobalScopes();
691
+ const converted = {};
692
+ const casts = this.modelClass?.casts || {};
693
+ const knex = getKnex();
694
+ const clientType = knex.client?.config?.client;
695
+ for (const [k, v] of Object.entries(attributes)) {
696
+ const col = toSnakeCase(k);
697
+ const castType = casts[k] || casts[col] || casts[toCamelCase(k)];
698
+ converted[col] = serializeForStorage(v, castType, clientType);
699
+ }
700
+ return await this.knexBuilder.update(converted);
701
+ }
702
+ /**
703
+ * Bulk deletes matching records (soft-deletes if softDeletes is enabled).
704
+ */
705
+ async delete() {
706
+ if (this.modelClass?.softDeletes) {
707
+ this.applyGlobalScopes();
708
+ const col = toSnakeCase(this.modelClass.deletedAtColumn || "deleted_at");
709
+ return await this.update({ [col]: new Date() });
710
+ }
711
+ this.applyGlobalScopes();
712
+ return await this.knexBuilder.delete();
713
+ }
714
+ /**
715
+ * Bulk force-deletes matching records (hard delete regardless of softDeletes setting).
716
+ */
717
+ async forceDelete() {
718
+ return await this.knexBuilder.delete();
719
+ }
720
+ /**
721
+ * Bulk restores soft-deleted matching records.
722
+ */
723
+ async restore() {
724
+ if (this.modelClass?.softDeletes) {
725
+ if (this._onlyTrashed) {
726
+ this.applyGlobalScopes();
727
+ }
728
+ const col = toSnakeCase(this.modelClass.deletedAtColumn || "deleted_at");
729
+ return await this.update({ [col]: null });
730
+ }
731
+ return 0;
732
+ }
733
+ /**
734
+ * Increments column value.
735
+ */
736
+ async increment(column, amount = 1) {
737
+ return await this.knexBuilder.increment(toSnakeCase(column), amount);
738
+ }
739
+ /**
740
+ * Decrements column value.
741
+ */
742
+ async decrement(column, amount = 1) {
743
+ return await this.knexBuilder.decrement(toSnakeCase(column), amount);
744
+ }
745
+ toSQL() {
746
+ this.applyGlobalScopes();
747
+ return this.knexBuilder.toSQL();
748
+ }
749
+ }