@bunyad/orm 0.2.0-beta.1 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,11 +81,10 @@ Pick one and install it next to `@bunyad/database`: `better-sqlite3`, `pg` or `m
81
81
 
82
82
  - Express on Node: [`docs/examples/orm-node-express`](https://github.com/bunyadjs/framework/tree/main/docs/examples/orm-node-express)
83
83
  - Next.js (server-only, one shared pool): [`docs/examples/orm-node-next`](https://github.com/bunyadjs/framework/tree/main/docs/examples/orm-node-next)
84
- - NestJS: `@bunyad/nestjs` (`BunyadOrmModule`), in the monorepo and not published yet.
85
84
 
86
85
  ## Status
87
86
 
88
- Beta scope: SQLite, PostgreSQL and MySQL are tested on Bun and Node 20+ (the PostgreSQL and MySQL suites run when `BUNYAD_TEST_POSTGRES_URL` / `BUNYAD_TEST_MYSQL_URL` are set). SQL Server is experimental and needs `mssql` installed. Conditional aggregate helpers (`sumCase`, `countCase`) are not available yet.
87
+ Beta scope: SQLite, PostgreSQL and MySQL are tested on Bun and Node 20+ (the PostgreSQL and MySQL suites run when `BUNYAD_TEST_POSTGRES_URL` / `BUNYAD_TEST_MYSQL_URL` are set). SQL Server is experimental and needs `mssql` installed.
89
88
 
90
89
  ## License
91
90
 
package/dist/casts.d.ts CHANGED
@@ -15,21 +15,33 @@ export declare class Attribute {
15
15
  }): Attribute;
16
16
  }
17
17
  /** Built-in cast type names. */
18
- export type CastType = "boolean" | "bool" | "number" | "integer" | "int" | "float" | "double" | "real" | "decimal" | "bigint" | "string" | "json" | "date" | "datetime" | "array" | "collection" | "encrypted" | "encrypted:array" | "encrypted:json" | "encrypted:collection" | "hashed";
18
+ export type CastType = "boolean" | "bool" | "number" | "integer" | "int" | "float" | "double" | "real" | "decimal" | "bigint" | "string" | "json" | "date" | "datetime" | "array" | "collection" | "encrypted" | "encrypted:array" | "encrypted:json" | "encrypted:collection" | "hashed" | "object" | "immutable_date" | "immutable_datetime" | `decimal:${number}` | `date:${string}` | `datetime:${string}` | `immutable_date:${string}` | `immutable_datetime:${string}`;
19
19
  /**
20
20
  * A TypeScript string/number enum object (or similar value map).
21
21
  */
22
22
  export type EnumCast = Record<string, string | number>;
23
23
  /** Anything accepted in `casts` / `casts()`. */
24
24
  export type CastDefinition = CastType | EnumCast | Attribute;
25
+ /** Base cast + optional argument for the parameterized forms (`decimal:2`, `date:Y-m-d`). */
26
+ export type ParsedCast = {
27
+ type: BaseCastType;
28
+ arg?: string;
29
+ };
30
+ type BaseCastType = "boolean" | "bool" | "number" | "integer" | "int" | "float" | "double" | "real" | "decimal" | "bigint" | "string" | "json" | "date" | "datetime" | "array" | "collection" | "encrypted" | "encrypted:array" | "encrypted:json" | "encrypted:collection" | "hashed";
31
+ export declare function parseCast(definition: string): ParsedCast | null;
25
32
  export declare function isCastType(value: unknown): value is CastType;
33
+ /**
34
+ * Format a Date with a PHP-style `date()` format (`Y-m-d H:i:s`). Used by
35
+ * `date:FORMAT` / `datetime:FORMAT` casts when a model is serialized. UTC.
36
+ */
37
+ export declare function formatPhpDate(date: Date, format: string): string;
26
38
  export declare function isAttribute(value: unknown): value is Attribute;
27
39
  export declare function isEnumCast(value: unknown): value is EnumCast;
28
40
  /** Async bcrypt for the `hashed` cast (same output as the sync path, off the event loop). */
29
41
  export declare function hashCastValue(value: string): Promise<string>;
30
42
  export declare function isAlreadyHashed(value: string): boolean;
31
- export declare function castFromStorage(value: unknown, type: CastType): unknown;
32
- export declare function castToStorage(value: unknown, type: CastType, driver?: string): unknown;
43
+ export declare function castFromStorage(value: unknown, definition: CastType): unknown;
44
+ export declare function castToStorage(value: unknown, definition: CastType, driver?: string): unknown;
33
45
  export declare function enumFromStorage(value: unknown, enumObj: EnumCast): unknown;
34
46
  export declare function enumToStorage(value: unknown, enumObj: EnumCast): unknown;
35
47
  /** Cast JSON column to a `Collection`. */
@@ -48,3 +60,4 @@ export declare const AsFluent: Attribute;
48
60
  export declare const AsEnumCollection: {
49
61
  of(enumObj: EnumCast): Attribute;
50
62
  };
63
+ export {};
package/dist/casts.js CHANGED
@@ -38,6 +38,18 @@ const CAST_TYPE_SET = new Set([
38
38
  "encrypted:collection",
39
39
  "hashed",
40
40
  ]);
41
+ /** Fixed-digit string rounded half away from zero on the decimal text, so `1.005` gives `1.01`. */
42
+ function toFixedDecimal(value, digits) {
43
+ const n = toNumber(value);
44
+ if (!Number.isFinite(n) || digits < 0 || digits > 20)
45
+ return n.toFixed(Math.max(0, digits));
46
+ const sign = n < 0 ? -1 : 1;
47
+ const shifted = Number(`${Math.abs(n)}e${digits}`);
48
+ if (!Number.isFinite(shifted))
49
+ return n.toFixed(digits);
50
+ const rounded = Number(`${Math.round(shifted)}e-${digits}`);
51
+ return (sign * rounded).toFixed(digits);
52
+ }
41
53
  function toNumber(value) {
42
54
  if (typeof value === "number") {
43
55
  return Number.isFinite(value) ? value : 0;
@@ -75,8 +87,122 @@ function toBoolean(value) {
75
87
  }
76
88
  return Boolean(value);
77
89
  }
90
+ const parsedCastCache = new Map();
91
+ export function parseCast(definition) {
92
+ const hit = parsedCastCache.get(definition);
93
+ if (hit !== undefined)
94
+ return hit;
95
+ let parsed = null;
96
+ if (CAST_TYPE_SET.has(definition)) {
97
+ parsed = { type: definition };
98
+ }
99
+ else if (definition === "object") {
100
+ parsed = { type: "json" };
101
+ }
102
+ else if (definition === "immutable_date") {
103
+ parsed = { type: "date" };
104
+ }
105
+ else if (definition === "immutable_datetime") {
106
+ parsed = { type: "datetime" };
107
+ }
108
+ else {
109
+ const colon = definition.indexOf(":");
110
+ if (colon > 0) {
111
+ const head = definition.slice(0, colon);
112
+ const arg = definition.slice(colon + 1);
113
+ if (head === "decimal" && /^\d+$/.test(arg))
114
+ parsed = { type: "decimal", arg };
115
+ else if ((head === "date" || head === "immutable_date") && arg)
116
+ parsed = { type: "date", arg };
117
+ else if ((head === "datetime" || head === "immutable_datetime") && arg)
118
+ parsed = { type: "datetime", arg };
119
+ }
120
+ }
121
+ parsedCastCache.set(definition, parsed);
122
+ return parsed;
123
+ }
78
124
  export function isCastType(value) {
79
- return typeof value === "string" && CAST_TYPE_SET.has(value);
125
+ return typeof value === "string" && parseCast(value) !== null;
126
+ }
127
+ /**
128
+ * Format a Date with a PHP-style `date()` format (`Y-m-d H:i:s`). Used by
129
+ * `date:FORMAT` / `datetime:FORMAT` casts when a model is serialized. UTC.
130
+ */
131
+ export function formatPhpDate(date, format) {
132
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
133
+ const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
134
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
135
+ const h24 = date.getUTCHours();
136
+ const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
137
+ let out = "";
138
+ for (let i = 0; i < format.length; i++) {
139
+ const ch = format[i];
140
+ switch (ch) {
141
+ case "\\":
142
+ i++;
143
+ out += format[i] ?? "";
144
+ break;
145
+ case "d":
146
+ out += pad(date.getUTCDate());
147
+ break;
148
+ case "j":
149
+ out += date.getUTCDate();
150
+ break;
151
+ case "m":
152
+ out += pad(date.getUTCMonth() + 1);
153
+ break;
154
+ case "n":
155
+ out += date.getUTCMonth() + 1;
156
+ break;
157
+ case "Y":
158
+ out += pad(date.getUTCFullYear(), 4);
159
+ break;
160
+ case "y":
161
+ out += pad(date.getUTCFullYear() % 100);
162
+ break;
163
+ case "H":
164
+ out += pad(h24);
165
+ break;
166
+ case "G":
167
+ out += h24;
168
+ break;
169
+ case "h":
170
+ out += pad(h12);
171
+ break;
172
+ case "g":
173
+ out += h12;
174
+ break;
175
+ case "i":
176
+ out += pad(date.getUTCMinutes());
177
+ break;
178
+ case "s":
179
+ out += pad(date.getUTCSeconds());
180
+ break;
181
+ case "A":
182
+ out += h24 < 12 ? "AM" : "PM";
183
+ break;
184
+ case "a":
185
+ out += h24 < 12 ? "am" : "pm";
186
+ break;
187
+ case "U":
188
+ out += Math.floor(date.getTime() / 1000);
189
+ break;
190
+ case "M":
191
+ out += months[date.getUTCMonth()].slice(0, 3);
192
+ break;
193
+ case "F":
194
+ out += months[date.getUTCMonth()];
195
+ break;
196
+ case "D":
197
+ out += days[date.getUTCDay()].slice(0, 3);
198
+ break;
199
+ case "l":
200
+ out += days[date.getUTCDay()];
201
+ break;
202
+ default: out += ch;
203
+ }
204
+ }
205
+ return out;
80
206
  }
81
207
  export function isAttribute(value) {
82
208
  return value instanceof Attribute;
@@ -125,9 +251,15 @@ function decryptPayload(value) {
125
251
  return String(value ?? "");
126
252
  return Crypt.decrypt(value);
127
253
  }
128
- export function castFromStorage(value, type) {
254
+ export function castFromStorage(value, definition) {
255
+ const parsed = parseCast(definition);
256
+ const type = parsed.type;
129
257
  if (value == null)
130
258
  return value;
259
+ // `decimal:2` keeps money-like values exact as a fixed-digit string.
260
+ if (type === "decimal" && parsed.arg !== undefined) {
261
+ return toFixedDecimal(value, Number(parsed.arg));
262
+ }
131
263
  switch (type) {
132
264
  case "boolean":
133
265
  case "bool":
@@ -147,6 +279,8 @@ export function castFromStorage(value, type) {
147
279
  return String(value);
148
280
  case "json":
149
281
  case "array":
282
+ if (value === "")
283
+ return null;
150
284
  return typeof value === "string" ? JSON.parse(value) : value;
151
285
  case "collection": {
152
286
  // From the database (JSON text), or already in memory: `fill()` passes a Collection or array.
@@ -174,9 +308,19 @@ export function castFromStorage(value, type) {
174
308
  return value;
175
309
  }
176
310
  }
177
- export function castToStorage(value, type, driver) {
311
+ function assertValidDate(value, cast) {
312
+ if (Number.isNaN(toDate(value).getTime())) {
313
+ throw new Error(`Cannot cast ${JSON.stringify(String(value))} to ${cast}: not a valid date.`);
314
+ }
315
+ }
316
+ export function castToStorage(value, definition, driver) {
178
317
  if (value == null)
179
318
  return value;
319
+ const parsed = parseCast(definition);
320
+ const type = parsed.type;
321
+ if (type === "decimal" && parsed.arg !== undefined) {
322
+ return toFixedDecimal(value, Number(parsed.arg));
323
+ }
180
324
  const driverName = (driver ?? "sqlite");
181
325
  switch (type) {
182
326
  case "boolean":
@@ -201,14 +345,16 @@ export function castToStorage(value, type, driver) {
201
345
  return String(value);
202
346
  case "json":
203
347
  case "array":
204
- return typeof value === "string" ? value : JSON.stringify(value);
348
+ return JSON.stringify(value);
205
349
  case "collection": {
206
350
  const items = collectionItems(value);
207
351
  return JSON.stringify(items);
208
352
  }
209
353
  case "date":
354
+ assertValidDate(value, definition);
210
355
  return dateForStorage(value);
211
356
  case "datetime":
357
+ assertValidDate(value, definition);
212
358
  return dateTimeForStorage(value, driverName);
213
359
  case "encrypted":
214
360
  return encryptPayload(value);
package/dist/eager.js CHANGED
@@ -4,10 +4,11 @@
4
4
  import { wrapSqlName } from "@bunyad/database";
5
5
  import { OrmCollection } from "./orm-collection.js";
6
6
  import { softDeleteAliasSql, uniqueKeys, coerceEagerRelations, } from "./model-helpers.js";
7
+ import { hasGlobalScopes } from "./scopes.js";
7
8
  import { eagerFetchByKeys } from "./eager-fetch.js";
8
9
  import { Model } from "./model.js";
9
10
  import { enterEagerLoad, leaveEagerLoad } from "./model-strictness.js";
10
- import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, aggregateRelation, applyPivotAttributes, resolveMorphType, resolveRelation, } from "./relations.js";
11
+ import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, MorphedByMany, aggregateRelation, applyPivotAttributes, applyPivotWheres, setInverse, resolveMorphType, resolveRelation, } from "./relations.js";
11
12
  /** Eager-load named relations onto models (`with` / `load` / collections).
12
13
  * Batches BelongsTo / HasMany / HasOne / BelongsToMany / Morph* / ofMany with
13
14
  * `WHERE IN`. Nested paths like `parent.children` are supported.
@@ -110,6 +111,27 @@ function applyConstraint(q, constraint) {
110
111
  constraint(q);
111
112
  return q;
112
113
  }
114
+ /** Apply the constraint, then lift its limit/offset off the batched query (see `takePaging`). */
115
+ function applyConstraintPaged(q, constraint, paging) {
116
+ applyConstraint(q, constraint);
117
+ if (constraint) {
118
+ const taken = q.takePaging();
119
+ if (taken.limit !== undefined)
120
+ paging.limit = taken.limit;
121
+ if (taken.offset !== undefined)
122
+ paging.offset = taken.offset;
123
+ }
124
+ return q;
125
+ }
126
+ /** Slice every parent's list to `offset` / `limit` (rows are already in the constraint's order). */
127
+ function pagePerParent(byParent, paging) {
128
+ if (paging.limit === undefined && paging.offset === undefined)
129
+ return;
130
+ const start = paging.offset ?? 0;
131
+ for (const [key, list] of byParent) {
132
+ byParent.set(key, paging.limit === undefined ? list.slice(start) : list.slice(start, start + paging.limit));
133
+ }
134
+ }
113
135
  function relationFromModel(model, relation) {
114
136
  try {
115
137
  return model.related(relation);
@@ -143,7 +165,7 @@ async function forEachIdChunk(ids, run) {
143
165
  });
144
166
  await Promise.all(workers);
145
167
  }
146
- function eagerLoadOneRelation(models, relation, constraint) {
168
+ function eagerLoadOneRelationRaw(models, relation, constraint) {
147
169
  const sampleRel = relationFromModel(models[0], relation);
148
170
  if (!sampleRel)
149
171
  return;
@@ -168,6 +190,9 @@ function eagerLoadOneRelation(models, relation, constraint) {
168
190
  if (sampleRel instanceof MorphToMany) {
169
191
  return eagerLoadMorphToMany(models, relation, sampleRel, constraint);
170
192
  }
193
+ if (sampleRel instanceof MorphedByMany) {
194
+ return eagerLoadMorphedByMany(models, relation, sampleRel, constraint);
195
+ }
171
196
  if (sampleRel instanceof MorphMany) {
172
197
  return eagerLoadMorphMany(models, relation, sampleRel, constraint);
173
198
  }
@@ -184,6 +209,45 @@ function eagerLoadOneRelation(models, relation, constraint) {
184
209
  return eagerLoadHasOneThrough(models, relation, sampleRel, constraint);
185
210
  }
186
211
  }
212
+ function fillDefaults(models, relation) {
213
+ for (const model of models) {
214
+ const row = model;
215
+ if (row[relation] == null) {
216
+ const rel = model.related(relation);
217
+ row[relation] = rel.makeDefault();
218
+ }
219
+ }
220
+ }
221
+ /** Load one relation, then fill `withDefault()` relations that matched nothing. */
222
+ function eagerLoadOneRelation(models, relation, constraint) {
223
+ const sampleRel = relationFromModel(models[0], relation);
224
+ const withDefault = Boolean(sampleRel?.hasDefault?.());
225
+ const loaded = eagerLoadOneRelationRaw(models, relation, constraint);
226
+ const inverseName = sampleRel?.getInverse?.();
227
+ if (inverseName !== undefined) {
228
+ const attach = () => {
229
+ for (const model of models) {
230
+ const value = model[relation];
231
+ const children = value == null ? [] : Array.isArray(value) ? value : typeof value.all === "function" ? value.all() : [value];
232
+ for (const child of children)
233
+ setInverse(child, inverseName, model);
234
+ }
235
+ };
236
+ if (loaded instanceof Promise) {
237
+ const chained = loaded.then(attach);
238
+ if (!withDefault)
239
+ return chained;
240
+ return chained.then(() => fillDefaults(models, relation));
241
+ }
242
+ attach();
243
+ }
244
+ if (!withDefault)
245
+ return loaded;
246
+ const fill = () => fillDefaults(models, relation);
247
+ if (loaded instanceof Promise)
248
+ return loaded.then(fill);
249
+ fill();
250
+ }
187
251
  /** Single-query ofMany via GROUP BY join (avoids per-row correlated subqueries). */
188
252
  async function eagerLoadHasOneOfMany(models, relation, sample, constraint) {
189
253
  const foreignKey = sample.getForeignKeyName();
@@ -360,11 +424,12 @@ async function eagerLoadHasMany(models, relation, sample, constraint) {
360
424
  const Related = sample.getRelated();
361
425
  const parentIds = uniqueKeys(models.map((model) => model[localKey]));
362
426
  const byParent = new Map();
427
+ const paging = {};
363
428
  await forEachIdChunk(parentIds, async (chunk) => {
364
429
  let related;
365
430
  if (constraint) {
366
431
  let q = Related.whereIn(foreignKey, chunk);
367
- applyConstraint(q, constraint);
432
+ applyConstraintPaged(q, constraint, paging);
368
433
  related = (await q.get()).all();
369
434
  }
370
435
  else {
@@ -377,6 +442,7 @@ async function eagerLoadHasMany(models, relation, sample, constraint) {
377
442
  byParent.set(key, list);
378
443
  }
379
444
  });
445
+ pagePerParent(byParent, paging);
380
446
  for (const model of models) {
381
447
  const id = model[localKey];
382
448
  model[relation] = new OrmCollection(id == null ? [] : (byParent.get(String(id)) ?? []), { owned: true });
@@ -481,11 +547,16 @@ async function eagerLoadBelongsToMany(models, relation, sample, constraint) {
481
547
  const foreignPivotKey = sample.getForeignPivotKeyName();
482
548
  const relatedPivotKey = sample.getRelatedPivotKeyName();
483
549
  const pivotColumns = sample.getPivotColumns();
484
- const pivotKeys = pivotColumns.length > 0
550
+ const pivotWheres = sample.getPivotWheres();
551
+ const pivotAccessor = sample.getPivotAccessor();
552
+ const pivotClass = sample.getPivotClass();
553
+ const pivotOrders = sample.getPivotOrders();
554
+ const pivotKeys = pivotColumns.length > 0 || pivotClass || pivotAccessor !== "pivot"
485
555
  ? [foreignPivotKey, relatedPivotKey, ...pivotColumns.filter((c) => c !== foreignPivotKey && c !== relatedPivotKey)]
486
556
  : [];
487
557
  const parentIds = uniqueKeys(models.map((model) => model[parentKey]));
488
558
  const byParent = new Map();
559
+ const paging = {};
489
560
  if (parentIds.length > 0) {
490
561
  const dialect = parent.getConnection().dialect;
491
562
  const qRelated = wrapSqlName(dialect, Related.table);
@@ -500,7 +571,10 @@ async function eagerLoadBelongsToMany(models, relation, sample, constraint) {
500
571
  .join(", ")}`
501
572
  : "";
502
573
  await forEachIdChunk(parentIds, async (chunk) => {
503
- if (constraint) {
574
+ if (constraint ||
575
+ hasGlobalScopes(Related) ||
576
+ pivotWheres.length > 0 ||
577
+ pivotOrders.length > 0) {
504
578
  let q = Related.newQuery()
505
579
  .join(pivotTable, `${pivotTable}.${relatedPivotKey}`, "=", `${Related.table}.${Related.primaryKey}`)
506
580
  .whereIn(`${pivotTable}.${foreignPivotKey}`, chunk)
@@ -509,13 +583,16 @@ async function eagerLoadBelongsToMany(models, relation, sample, constraint) {
509
583
  for (const col of pivotKeys) {
510
584
  q = q.selectRaw(`${pivotTable}.${col} as __pivot_${col}`);
511
585
  }
512
- applyConstraint(q, constraint);
586
+ applyPivotWheres(q, pivotTable, pivotWheres);
587
+ for (const o of pivotOrders)
588
+ q = q.orderBy(`${pivotTable}.${o.column}`, o.direction);
589
+ applyConstraintPaged(q, constraint, paging);
513
590
  const related = await q.get();
514
591
  for (const row of related.all()) {
515
592
  const rec = row;
516
593
  const parentId = rec.__bunyad_parent_id;
517
594
  delete rec.__bunyad_parent_id;
518
- applyPivotAttributes(row, rec, pivotKeys);
595
+ applyPivotAttributes(row, rec, pivotKeys, { accessor: pivotAccessor, pivotClass });
519
596
  const key = String(parentId);
520
597
  const list = byParent.get(key) ?? [];
521
598
  list.push(row);
@@ -534,7 +611,7 @@ async function eagerLoadBelongsToMany(models, relation, sample, constraint) {
534
611
  const { __bunyad_parent_id: _, ...attrs } = row;
535
612
  const model = new Related(attrs);
536
613
  model.exists = true;
537
- applyPivotAttributes(model, attrs, pivotKeys);
614
+ applyPivotAttributes(model, attrs, pivotKeys, { accessor: pivotAccessor, pivotClass });
538
615
  const key = String(parentId);
539
616
  const list = byParent.get(key) ?? [];
540
617
  list.push(model);
@@ -542,6 +619,7 @@ async function eagerLoadBelongsToMany(models, relation, sample, constraint) {
542
619
  }
543
620
  });
544
621
  }
622
+ pagePerParent(byParent, paging);
545
623
  for (const model of models) {
546
624
  const id = model[parentKey];
547
625
  model[relation] = new OrmCollection(id == null ? [] : (byParent.get(String(id)) ?? []), { owned: true });
@@ -560,6 +638,7 @@ async function eagerLoadMorphToMany(models, relation, sample, constraint) {
560
638
  const parentTenantKey = sample.getParentTenantKey();
561
639
  const parentIds = uniqueKeys(models.map((model) => model[parentKey]));
562
640
  const byParent = new Map();
641
+ const paging = {};
563
642
  if (parentIds.length > 0 && morphTypes.length > 0) {
564
643
  const dialect = parent.getConnection().dialect;
565
644
  const qRelated = wrapSqlName(dialect, Related.table);
@@ -571,7 +650,7 @@ async function eagerLoadMorphToMany(models, relation, sample, constraint) {
571
650
  const soft = softDeleteAliasSql(Related, qRelated);
572
651
  const morphPlaceholders = morphTypes.map(() => "?").join(", ");
573
652
  await forEachIdChunk(parentIds, async (chunk) => {
574
- if (constraint) {
653
+ if (constraint || hasGlobalScopes(Related)) {
575
654
  let q = Related.newQuery()
576
655
  .join(pivotTable, `${pivotTable}.${relatedPivotKey}`, "=", `${Related.table}.${Related.primaryKey}`)
577
656
  .whereIn(`${pivotTable}.${foreignPivotKey}`, chunk)
@@ -584,7 +663,7 @@ async function eagerLoadMorphToMany(models, relation, sample, constraint) {
584
663
  q = q.whereIn(`${pivotTable}.${pivotTenantKey}`, tenantIds);
585
664
  }
586
665
  }
587
- applyConstraint(q, constraint);
666
+ applyConstraintPaged(q, constraint, paging);
588
667
  const related = await q.get();
589
668
  for (const row of related.all()) {
590
669
  const rec = row;
@@ -624,6 +703,42 @@ async function eagerLoadMorphToMany(models, relation, sample, constraint) {
624
703
  }
625
704
  });
626
705
  }
706
+ pagePerParent(byParent, paging);
707
+ for (const model of models) {
708
+ const id = model[parentKey];
709
+ model[relation] = new OrmCollection(id == null ? [] : (byParent.get(String(id)) ?? []), { owned: true });
710
+ }
711
+ }
712
+ async function eagerLoadMorphedByMany(models, relation, sample, constraint) {
713
+ const Related = sample.getRelated();
714
+ const parent = models[0].constructor;
715
+ const parentKey = parent.primaryKey;
716
+ const pivotTable = sample.getPivotTable();
717
+ const foreignPivotKey = sample.getForeignPivotKeyName();
718
+ const relatedPivotKey = sample.getRelatedPivotKeyName();
719
+ const morphTypeColumn = sample.getMorphTypeColumn();
720
+ const morphType = sample.getMorphType();
721
+ const parentIds = uniqueKeys(models.map((model) => model[parentKey]));
722
+ const byParent = new Map();
723
+ const paging = {};
724
+ await forEachIdChunk(parentIds, async (chunk) => {
725
+ const q = Related.newQuery()
726
+ .join(pivotTable, `${pivotTable}.${relatedPivotKey}`, "=", `${Related.table}.${Related.primaryKey}`)
727
+ .whereIn(`${pivotTable}.${foreignPivotKey}`, chunk)
728
+ .where(`${pivotTable}.${morphTypeColumn}`, morphType)
729
+ .select(`${Related.table}.*`)
730
+ .selectRaw(`${pivotTable}.${foreignPivotKey} as __bunyad_parent_id`);
731
+ applyConstraintPaged(q, constraint, paging);
732
+ for (const row of (await q.get()).all()) {
733
+ const rec = row;
734
+ const key = String(rec.__bunyad_parent_id);
735
+ delete rec.__bunyad_parent_id;
736
+ const list = byParent.get(key) ?? [];
737
+ list.push(row);
738
+ byParent.set(key, list);
739
+ }
740
+ });
741
+ pagePerParent(byParent, paging);
627
742
  for (const model of models) {
628
743
  const id = model[parentKey];
629
744
  model[relation] = new OrmCollection(id == null ? [] : (byParent.get(String(id)) ?? []), { owned: true });
@@ -637,9 +752,10 @@ async function eagerLoadMorphMany(models, relation, sample, constraint) {
637
752
  const localKey = sample.getLocalKeyName();
638
753
  const parentIds = uniqueKeys(models.map((model) => model[localKey]));
639
754
  const byParent = new Map();
755
+ const paging = {};
640
756
  await forEachIdChunk(parentIds, async (chunk) => {
641
757
  let q = Related.where(typeColumn, morphType).whereIn(idColumn, chunk);
642
- applyConstraint(q, constraint);
758
+ applyConstraintPaged(q, constraint, paging);
643
759
  const related = await q.get();
644
760
  for (const row of related.all()) {
645
761
  const key = String(row[idColumn]);
@@ -648,6 +764,7 @@ async function eagerLoadMorphMany(models, relation, sample, constraint) {
648
764
  byParent.set(key, list);
649
765
  }
650
766
  });
767
+ pagePerParent(byParent, paging);
651
768
  for (const model of models) {
652
769
  const id = model[localKey];
653
770
  model[relation] = new OrmCollection(id == null ? [] : (byParent.get(String(id)) ?? []), { owned: true });
@@ -748,6 +865,8 @@ async function eagerLoadOneAggregate(models, Parent, spec) {
748
865
  const dialect = meta.related.getConnection().dialect;
749
866
  const fk = wrapSqlName(dialect, meta.foreignKey);
750
867
  let q = meta.related.whereIn(meta.foreignKey, parentIds).selectRaw(`${fk} as __bunyad_k`);
868
+ if (meta.typeColumn)
869
+ q = q.where(meta.typeColumn, meta.morphType);
751
870
  if (spec.fn === "exists") {
752
871
  q = q.selectRaw("1 as __bunyad_v");
753
872
  }
package/dist/factory.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare abstract class Factory<T extends Model = Model> {
33
33
  /** Public model class accessor for relationship factories. */
34
34
  modelClass(): ModelClass;
35
35
  definition(): FactoryAttributes | Promise<FactoryAttributes>;
36
+ /** Runs once, before the first `make` / `create` (`afterMaking`, default states, …). */
37
+ configure(): this;
36
38
  /** Number of models to make/create. */
37
39
  count(amount: number): this & ManyModels;
38
40
  /** Merge attribute overrides (object or callback) into every model. */
@@ -57,12 +59,29 @@ export declare abstract class Factory<T extends Model = Model> {
57
59
  /**
58
60
  * Attach related models on a belongs-to-many after create.
59
61
  */
60
- hasAttached(related: Factory<any> | Model | Model[], pivot?: FactoryAttributes, relationship?: string): this;
62
+ hasAttached(related: Factory<any> | Model | Model[], pivot?: FactoryAttributes | ((model: Model) => FactoryAttributes), relationship?: string): this;
63
+ /**
64
+ * `recycle` — reuse existing models when this factory (or its nested `for` /
65
+ * `has` factories) would otherwise create a parent of the same class.
66
+ */
67
+ recycle(models: Model | Model[] | {
68
+ all(): Model[];
69
+ }): this;
61
70
  /** `Factory::new()`. */
62
71
  static new<T extends Factory>(this: new () => T): T;
63
72
  /** Build an in-memory model instance (not persisted). */
64
73
  make(attributes?: FactoryAttributes): Promise<this extends ManyModels ? T[] : T>;
65
74
  /** Persist model(s) via `Model.forceCreate` (factories set guarded columns too). */
66
75
  create(attributes?: FactoryAttributes): Promise<this extends ManyModels ? T[] : T>;
76
+ /** `makeOne`. */
77
+ makeOne(attributes?: FactoryAttributes): Promise<T>;
78
+ /** `createOne`. */
79
+ createOne(attributes?: FactoryAttributes): Promise<T>;
80
+ /** `createMany(3)` or `createMany([{ name: "a" }, { name: "b" }])`. */
81
+ createMany(records: number | FactoryAttributes[]): Promise<T[]>;
82
+ /** `createQuietly` — no model events. */
83
+ createQuietly(attributes?: FactoryAttributes): Promise<this extends ManyModels ? T[] : T>;
84
+ /** `createManyQuietly`. */
85
+ createManyQuietly(records: number | FactoryAttributes[]): Promise<T[]>;
67
86
  }
68
87
  export {};