metal-orm 1.1.8 → 1.1.10

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 (75) hide show
  1. package/README.md +769 -764
  2. package/dist/index.cjs +2352 -226
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +605 -40
  5. package/dist/index.d.ts +605 -40
  6. package/dist/index.js +2324 -226
  7. package/dist/index.js.map +1 -1
  8. package/package.json +22 -17
  9. package/src/bulk/bulk-context.ts +83 -0
  10. package/src/bulk/bulk-delete-executor.ts +89 -0
  11. package/src/bulk/bulk-executor.base.ts +73 -0
  12. package/src/bulk/bulk-insert-executor.ts +74 -0
  13. package/src/bulk/bulk-types.ts +70 -0
  14. package/src/bulk/bulk-update-executor.ts +192 -0
  15. package/src/bulk/bulk-upsert-executor.ts +95 -0
  16. package/src/bulk/bulk-utils.ts +91 -0
  17. package/src/bulk/index.ts +18 -0
  18. package/src/codegen/typescript.ts +30 -21
  19. package/src/core/ast/expression-builders.ts +107 -10
  20. package/src/core/ast/expression-nodes.ts +52 -22
  21. package/src/core/ast/expression-visitor.ts +23 -13
  22. package/src/core/dialect/abstract.ts +30 -17
  23. package/src/core/dialect/mysql/index.ts +20 -5
  24. package/src/core/execution/db-executor.ts +96 -64
  25. package/src/core/execution/executors/better-sqlite3-executor.ts +94 -0
  26. package/src/core/execution/executors/mssql-executor.ts +66 -34
  27. package/src/core/execution/executors/mysql-executor.ts +98 -66
  28. package/src/core/execution/executors/postgres-executor.ts +33 -11
  29. package/src/core/execution/executors/sqlite-executor.ts +86 -30
  30. package/src/decorators/bootstrap.ts +482 -398
  31. package/src/decorators/column-decorator.ts +87 -96
  32. package/src/decorators/decorator-metadata.ts +100 -24
  33. package/src/decorators/entity.ts +27 -24
  34. package/src/decorators/relations.ts +231 -149
  35. package/src/decorators/transformers/transformer-decorators.ts +26 -29
  36. package/src/decorators/validators/country-validators-decorators.ts +9 -15
  37. package/src/dto/apply-filter.ts +568 -551
  38. package/src/index.ts +16 -9
  39. package/src/orm/entity-hydration.ts +116 -72
  40. package/src/orm/entity-metadata.ts +347 -301
  41. package/src/orm/entity-relations.ts +264 -207
  42. package/src/orm/entity.ts +199 -199
  43. package/src/orm/execute.ts +13 -13
  44. package/src/orm/lazy-batch/morph-many.ts +70 -0
  45. package/src/orm/lazy-batch/morph-one.ts +69 -0
  46. package/src/orm/lazy-batch/morph-to.ts +59 -0
  47. package/src/orm/lazy-batch.ts +4 -1
  48. package/src/orm/orm-session.ts +170 -104
  49. package/src/orm/pooled-executor-factory.ts +99 -58
  50. package/src/orm/query-logger.ts +49 -40
  51. package/src/orm/relation-change-processor.ts +198 -96
  52. package/src/orm/relations/belongs-to.ts +143 -143
  53. package/src/orm/relations/has-many.ts +204 -204
  54. package/src/orm/relations/has-one.ts +174 -174
  55. package/src/orm/relations/many-to-many.ts +288 -288
  56. package/src/orm/relations/morph-many.ts +156 -0
  57. package/src/orm/relations/morph-one.ts +151 -0
  58. package/src/orm/relations/morph-to.ts +162 -0
  59. package/src/orm/save-graph.ts +116 -1
  60. package/src/query-builder/expression-table-mapper.ts +5 -0
  61. package/src/query-builder/hydration-manager.ts +345 -345
  62. package/src/query-builder/hydration-planner.ts +178 -148
  63. package/src/query-builder/relation-conditions.ts +171 -151
  64. package/src/query-builder/relation-cte-builder.ts +5 -1
  65. package/src/query-builder/relation-filter-utils.ts +9 -6
  66. package/src/query-builder/relation-include-strategies.ts +44 -2
  67. package/src/query-builder/relation-join-strategies.ts +8 -1
  68. package/src/query-builder/relation-service.ts +250 -241
  69. package/src/query-builder/select/cursor-pagination.ts +323 -0
  70. package/src/query-builder/select/select-operations.ts +110 -105
  71. package/src/query-builder/select.ts +42 -1
  72. package/src/query-builder/update-include.ts +4 -0
  73. package/src/schema/relation.ts +296 -188
  74. package/src/schema/types.ts +138 -123
  75. package/src/tree/tree-decorator.ts +127 -137
@@ -1,14 +1,14 @@
1
- import { and, eq } from '../core/ast/expression.js';
2
- import type { ValueOperandInput } from '../core/ast/expression.js';
3
- import type { Dialect } from '../core/dialect/abstract.js';
4
- import { DeleteQueryBuilder } from '../query-builder/delete.js';
5
- import { InsertQueryBuilder } from '../query-builder/insert.js';
6
- import { UpdateQueryBuilder } from '../query-builder/update.js';
7
- import { findPrimaryKey } from '../query-builder/hydration-planner.js';
8
- import type { BelongsToManyRelation, HasManyRelation, HasOneRelation } from '../schema/relation.js';
9
- import { RelationKinds } from '../schema/relation.js';
10
- import type { TableDef } from '../schema/table.js';
11
- import type { DbExecutor } from '../core/execution/db-executor.js';
1
+ import { and, eq } from '../core/ast/expression.js';
2
+ import type { ValueOperandInput } from '../core/ast/expression.js';
3
+ import type { Dialect } from '../core/dialect/abstract.js';
4
+ import { DeleteQueryBuilder } from '../query-builder/delete.js';
5
+ import { InsertQueryBuilder } from '../query-builder/insert.js';
6
+ import { UpdateQueryBuilder } from '../query-builder/update.js';
7
+ import { findPrimaryKey } from '../query-builder/hydration-planner.js';
8
+ import type { BelongsToManyRelation, HasManyRelation, HasOneRelation, MorphOneRelation, MorphManyRelation, MorphToRelation } from '../schema/relation.js';
9
+ import { RelationKinds } from '../schema/relation.js';
10
+ import type { TableDef } from '../schema/table.js';
11
+ import type { DbExecutor } from '../core/execution/db-executor.js';
12
12
  import type { RelationChangeEntry } from './runtime-types.js';
13
13
  import { UnitOfWork } from './unit-of-work.js';
14
14
 
@@ -67,6 +67,15 @@ export class RelationChangeProcessor {
67
67
  case RelationKinds.BelongsTo:
68
68
  await this.handleBelongsToChange(entry);
69
69
  break;
70
+ case RelationKinds.MorphOne:
71
+ await this.handleMorphOneChange(entry);
72
+ break;
73
+ case RelationKinds.MorphMany:
74
+ await this.handleMorphManyChange(entry);
75
+ break;
76
+ case RelationKinds.MorphTo:
77
+ await this.handleMorphToChange(entry);
78
+ break;
70
79
  }
71
80
  }
72
81
  }
@@ -138,28 +147,28 @@ export class RelationChangeProcessor {
138
147
  * Handles changes for belongs-to-many relations.
139
148
  * @param entry - The relation change entry
140
149
  */
141
- private async handleBelongsToManyChange(entry: RelationChangeEntry): Promise<void> {
142
- const relation = entry.relation as BelongsToManyRelation;
143
- const rootKey = relation.localKey || findPrimaryKey(entry.rootTable);
144
- const rootId = entry.root[rootKey];
145
- if (rootId === undefined || rootId === null) return;
146
-
147
- const targetId = this.resolvePrimaryKeyValue(entry.change.entity as Record<string, unknown>, relation.target);
148
- if (targetId === null) return;
149
-
150
- const pivotPayload = this.buildPivotPayload(relation, entry.change.pivot);
151
-
152
- if (entry.change.kind === 'update') {
153
- if (pivotPayload) {
154
- await this.updatePivotRow(relation, rootId, targetId, pivotPayload);
155
- }
156
- return;
157
- }
158
-
159
- if (entry.change.kind === 'attach' || entry.change.kind === 'add') {
160
- await this.insertPivotRow(relation, rootId, targetId, pivotPayload);
161
- return;
162
- }
150
+ private async handleBelongsToManyChange(entry: RelationChangeEntry): Promise<void> {
151
+ const relation = entry.relation as BelongsToManyRelation;
152
+ const rootKey = relation.localKey || findPrimaryKey(entry.rootTable);
153
+ const rootId = entry.root[rootKey];
154
+ if (rootId === undefined || rootId === null) return;
155
+
156
+ const targetId = this.resolvePrimaryKeyValue(entry.change.entity as Record<string, unknown>, relation.target);
157
+ if (targetId === null) return;
158
+
159
+ const pivotPayload = this.buildPivotPayload(relation, entry.change.pivot);
160
+
161
+ if (entry.change.kind === 'update') {
162
+ if (pivotPayload) {
163
+ await this.updatePivotRow(relation, rootId, targetId, pivotPayload);
164
+ }
165
+ return;
166
+ }
167
+
168
+ if (entry.change.kind === 'attach' || entry.change.kind === 'add') {
169
+ await this.insertPivotRow(relation, rootId, targetId, pivotPayload);
170
+ return;
171
+ }
163
172
 
164
173
  if (entry.change.kind === 'detach' || entry.change.kind === 'remove') {
165
174
  await this.deletePivotRow(relation, rootId, targetId);
@@ -228,46 +237,46 @@ export class RelationChangeProcessor {
228
237
  * @param rootId - The root entity's primary key value
229
238
  * @param targetId - The target entity's primary key value
230
239
  */
231
- private async insertPivotRow(
232
- relation: BelongsToManyRelation,
233
- rootId: string | number,
234
- targetId: string | number,
235
- pivotPayload?: Record<string, unknown>
236
- ): Promise<void> {
237
- const payload = {
238
- [relation.pivotForeignKeyToRoot]: rootId,
239
- [relation.pivotForeignKeyToTarget]: targetId,
240
- ...(pivotPayload ?? {})
241
- };
242
- const builder = new InsertQueryBuilder(relation.pivotTable)
243
- .values(payload as Record<string, ValueOperandInput>);
244
- const compiled = builder.compile(this.dialect);
245
- await this.executor.executeSql(compiled.sql, compiled.params);
246
- }
247
-
248
- /**
249
- * Updates a pivot row for belongs-to-many relations.
250
- * @param relation - The belongs-to-many relation
251
- * @param rootId - The root entity's primary key value
252
- * @param targetId - The target entity's primary key value
253
- * @param pivotPayload - The pivot columns to update
254
- */
255
- private async updatePivotRow(
256
- relation: BelongsToManyRelation,
257
- rootId: string | number,
258
- targetId: string | number,
259
- pivotPayload: Record<string, unknown>
260
- ): Promise<void> {
261
- const rootCol = relation.pivotTable.columns[relation.pivotForeignKeyToRoot];
262
- const targetCol = relation.pivotTable.columns[relation.pivotForeignKeyToTarget];
263
- if (!rootCol || !targetCol) return;
264
-
265
- const builder = new UpdateQueryBuilder(relation.pivotTable)
266
- .set(pivotPayload)
267
- .where(and(eq(rootCol, rootId), eq(targetCol, targetId)));
268
- const compiled = builder.compile(this.dialect);
269
- await this.executor.executeSql(compiled.sql, compiled.params);
270
- }
240
+ private async insertPivotRow(
241
+ relation: BelongsToManyRelation,
242
+ rootId: string | number,
243
+ targetId: string | number,
244
+ pivotPayload?: Record<string, unknown>
245
+ ): Promise<void> {
246
+ const payload = {
247
+ [relation.pivotForeignKeyToRoot]: rootId,
248
+ [relation.pivotForeignKeyToTarget]: targetId,
249
+ ...(pivotPayload ?? {})
250
+ };
251
+ const builder = new InsertQueryBuilder(relation.pivotTable)
252
+ .values(payload as Record<string, ValueOperandInput>);
253
+ const compiled = builder.compile(this.dialect);
254
+ await this.executor.executeSql(compiled.sql, compiled.params);
255
+ }
256
+
257
+ /**
258
+ * Updates a pivot row for belongs-to-many relations.
259
+ * @param relation - The belongs-to-many relation
260
+ * @param rootId - The root entity's primary key value
261
+ * @param targetId - The target entity's primary key value
262
+ * @param pivotPayload - The pivot columns to update
263
+ */
264
+ private async updatePivotRow(
265
+ relation: BelongsToManyRelation,
266
+ rootId: string | number,
267
+ targetId: string | number,
268
+ pivotPayload: Record<string, unknown>
269
+ ): Promise<void> {
270
+ const rootCol = relation.pivotTable.columns[relation.pivotForeignKeyToRoot];
271
+ const targetCol = relation.pivotTable.columns[relation.pivotForeignKeyToTarget];
272
+ if (!rootCol || !targetCol) return;
273
+
274
+ const builder = new UpdateQueryBuilder(relation.pivotTable)
275
+ .set(pivotPayload)
276
+ .where(and(eq(rootCol, rootId), eq(targetCol, targetId)));
277
+ const compiled = builder.compile(this.dialect);
278
+ await this.executor.executeSql(compiled.sql, compiled.params);
279
+ }
271
280
 
272
281
  /**
273
282
  * Deletes a pivot row for belongs-to-many relations.
@@ -293,26 +302,119 @@ export class RelationChangeProcessor {
293
302
  * @param table - The table definition
294
303
  * @returns The primary key value or null
295
304
  */
296
- private resolvePrimaryKeyValue(entity: Record<string, unknown>, table: TableDef): string | number | null {
297
- if (!entity) return null;
298
- const key = findPrimaryKey(table);
299
- const value = entity[key];
300
- if (value === undefined || value === null) return null;
301
- return (value as string | number | null | undefined) ?? null;
302
- }
303
-
304
- private buildPivotPayload(
305
- relation: BelongsToManyRelation,
306
- pivot: Record<string, unknown> | undefined
307
- ): Record<string, unknown> | undefined {
308
- if (!pivot) return undefined;
309
- const payload: Record<string, unknown> = {};
310
- for (const [key, value] of Object.entries(pivot)) {
311
- if (value === undefined) continue;
312
- if (!relation.pivotTable.columns[key]) continue;
313
- if (key === relation.pivotForeignKeyToRoot || key === relation.pivotForeignKeyToTarget) continue;
314
- payload[key] = value;
315
- }
316
- return Object.keys(payload).length ? payload : undefined;
317
- }
318
- }
305
+ private resolvePrimaryKeyValue(entity: Record<string, unknown>, table: TableDef): string | number | null {
306
+ if (!entity) return null;
307
+ const key = findPrimaryKey(table);
308
+ const value = entity[key];
309
+ if (value === undefined || value === null) return null;
310
+ return (value as string | number | null | undefined) ?? null;
311
+ }
312
+
313
+ private buildPivotPayload(
314
+ relation: BelongsToManyRelation,
315
+ pivot: Record<string, unknown> | undefined
316
+ ): Record<string, unknown> | undefined {
317
+ if (!pivot) return undefined;
318
+ const payload: Record<string, unknown> = {};
319
+ for (const [key, value] of Object.entries(pivot)) {
320
+ if (value === undefined) continue;
321
+ if (!relation.pivotTable.columns[key]) continue;
322
+ if (key === relation.pivotForeignKeyToRoot || key === relation.pivotForeignKeyToTarget) continue;
323
+ payload[key] = value;
324
+ }
325
+ return Object.keys(payload).length ? payload : undefined;
326
+ }
327
+
328
+ private async handleMorphOneChange(entry: RelationChangeEntry): Promise<void> {
329
+ const relation = entry.relation as MorphOneRelation;
330
+ const target = entry.change.entity;
331
+ if (!target) return;
332
+
333
+ const tracked = this.unitOfWork.findTracked(target as object);
334
+ if (!tracked) return;
335
+
336
+ const localKey = relation.localKey || findPrimaryKey(entry.rootTable);
337
+ const rootValue = entry.root[localKey];
338
+ if (rootValue === undefined || rootValue === null) return;
339
+
340
+ if (entry.change.kind === 'add' || entry.change.kind === 'attach') {
341
+ const child = tracked.entity as Record<string, unknown>;
342
+ child[relation.idField] = rootValue;
343
+ child[relation.typeField] = relation.typeValue;
344
+ this.unitOfWork.markDirty(tracked.entity);
345
+ return;
346
+ }
347
+
348
+ if (entry.change.kind === 'remove') {
349
+ const child = tracked.entity as Record<string, unknown>;
350
+ if (relation.cascade === 'all' || relation.cascade === 'remove') {
351
+ this.unitOfWork.markRemoved(child);
352
+ return;
353
+ }
354
+ child[relation.idField] = null;
355
+ child[relation.typeField] = null;
356
+ this.unitOfWork.markDirty(child);
357
+ }
358
+ }
359
+
360
+ private async handleMorphManyChange(entry: RelationChangeEntry): Promise<void> {
361
+ const relation = entry.relation as MorphManyRelation;
362
+ const target = entry.change.entity;
363
+ if (!target) return;
364
+
365
+ const tracked = this.unitOfWork.findTracked(target as object);
366
+ if (!tracked) return;
367
+
368
+ const localKey = relation.localKey || findPrimaryKey(entry.rootTable);
369
+ const rootValue = entry.root[localKey];
370
+ if (rootValue === undefined || rootValue === null) return;
371
+
372
+ if (entry.change.kind === 'add' || entry.change.kind === 'attach') {
373
+ const child = tracked.entity as Record<string, unknown>;
374
+ child[relation.idField] = rootValue;
375
+ child[relation.typeField] = relation.typeValue;
376
+ this.unitOfWork.markDirty(tracked.entity);
377
+ return;
378
+ }
379
+
380
+ if (entry.change.kind === 'remove') {
381
+ const child = tracked.entity as Record<string, unknown>;
382
+ if (relation.cascade === 'all' || relation.cascade === 'remove') {
383
+ this.unitOfWork.markRemoved(child);
384
+ return;
385
+ }
386
+ child[relation.idField] = null;
387
+ child[relation.typeField] = null;
388
+ this.unitOfWork.markDirty(child);
389
+ }
390
+ }
391
+
392
+ private async handleMorphToChange(entry: RelationChangeEntry): Promise<void> {
393
+ const relation = entry.relation as MorphToRelation;
394
+ const target = entry.change.entity;
395
+ if (!target) return;
396
+
397
+ if (entry.change.kind === 'attach' || entry.change.kind === 'add') {
398
+ const targetEntity = target as Record<string, unknown>;
399
+ for (const [typeValue, targetTable] of Object.entries(relation.targets)) {
400
+ const targetPk = relation.targetKey || findPrimaryKey(targetTable);
401
+ const pkValue = targetEntity[targetPk];
402
+ if (pkValue !== undefined && pkValue !== null) {
403
+ const rootEntity = entry.root as Record<string, unknown>;
404
+ rootEntity[relation.typeField] = typeValue;
405
+ rootEntity[relation.idField] = pkValue;
406
+ this.unitOfWork.markDirty(entry.root as object);
407
+ break;
408
+ }
409
+ }
410
+ return;
411
+ }
412
+
413
+ if (entry.change.kind === 'remove') {
414
+ const rootEntity = entry.root as Record<string, unknown>;
415
+ rootEntity[relation.typeField] = null;
416
+ rootEntity[relation.idField] = null;
417
+ this.unitOfWork.markDirty(entry.root as object);
418
+ }
419
+ }
420
+ }
@@ -1,143 +1,143 @@
1
- import { BelongsToReferenceApi } from '../../schema/types.js';
2
- import { EntityContext } from '../entity-context.js';
3
- import { RelationKey } from '../runtime-types.js';
4
- import { BelongsToRelation } from '../../schema/relation.js';
5
- import { TableDef } from '../../schema/table.js';
6
- import { EntityMeta, getHydrationRecord, hasEntityMeta } from '../entity-meta.js';
7
-
8
- type Rows = Record<string, unknown>;
9
-
10
- const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
11
-
12
- const hideInternal = (obj: object, keys: string[]): void => {
13
- for (const key of keys) {
14
- Object.defineProperty(obj, key, {
15
- value: obj[key],
16
- writable: false,
17
- configurable: false,
18
- enumerable: false
19
- });
20
- }
21
- };
22
-
23
- const hideWritable = (obj: object, keys: string[]): void => {
24
- for (const key of keys) {
25
- const value = obj[key as keyof typeof obj];
26
- Object.defineProperty(obj, key, {
27
- value,
28
- writable: true,
29
- configurable: true,
30
- enumerable: false
31
- });
32
- }
33
- };
34
-
35
- /**
36
- * Default implementation of a belongs-to reference.
37
- * Manages a reference to a parent entity from a child entity through a foreign key.
38
- *
39
- * @template TParent The type of the parent entity.
40
- */
41
- export class DefaultBelongsToReference<TParent extends object> implements BelongsToReferenceApi<TParent> {
42
- private loaded = false;
43
- private current: TParent | null = null;
44
-
45
- /**
46
- * @param ctx The entity context for tracking changes.
47
- * @param meta Metadata for the child entity.
48
- * @param root The child entity instance (carrying the foreign key).
49
- * @param relationName The name of the relation.
50
- * @param relation Relation definition.
51
- * @param rootTable Table definition of the child entity.
52
- * @param loader Function to load the parent entity.
53
- * @param createEntity Function to create entity instances from rows.
54
- * @param targetKey The primary key of the target (parent) table.
55
- */
56
- constructor(
57
- private readonly ctx: EntityContext,
58
- private readonly meta: EntityMeta<TableDef>,
59
- private readonly root: unknown,
60
- private readonly relationName: string,
61
- private readonly relation: BelongsToRelation,
62
- private readonly rootTable: TableDef,
63
- private readonly loader: () => Promise<Map<string, Rows>>,
64
- private readonly createEntity: (row: Record<string, unknown>) => TParent,
65
- private readonly targetKey: string
66
- ) {
67
- hideInternal(this, ['ctx', 'meta', 'root', 'relationName', 'relation', 'rootTable', 'loader', 'createEntity', 'targetKey']);
68
- hideWritable(this, ['loaded', 'current']);
69
- this.populateFromHydrationCache();
70
- }
71
-
72
- async load(): Promise<TParent | null> {
73
- if (this.loaded) return this.current;
74
- const map = await this.loader();
75
- const fkValue = (this.root as Record<string, unknown>)[this.relation.foreignKey];
76
- if (fkValue === null || fkValue === undefined) {
77
- this.current = null;
78
- } else {
79
- const row = map.get(toKey(fkValue));
80
- this.current = row ? this.createEntity(row) : null;
81
- }
82
- this.loaded = true;
83
- return this.current;
84
- }
85
-
86
- get(): TParent | null {
87
- return this.current;
88
- }
89
-
90
- set(data: Partial<TParent> | TParent | null): TParent | null {
91
- if (data === null) {
92
- const previous = this.current;
93
- (this.root as Record<string, unknown>)[this.relation.foreignKey] = null;
94
- this.current = null;
95
- this.ctx.registerRelationChange(
96
- this.root,
97
- this.relationKey,
98
- this.rootTable,
99
- this.relationName,
100
- this.relation,
101
- { kind: 'remove', entity: previous }
102
- );
103
- return null;
104
- }
105
-
106
- const entity = hasEntityMeta(data) ? (data as TParent) : this.createEntity(data as Record<string, unknown>);
107
- const pkValue = (entity as Record<string, unknown>)[this.targetKey];
108
- if (pkValue !== undefined) {
109
- (this.root as Record<string, unknown>)[this.relation.foreignKey] = pkValue;
110
- }
111
- this.current = entity;
112
- this.ctx.registerRelationChange(
113
- this.root,
114
- this.relationKey,
115
- this.rootTable,
116
- this.relationName,
117
- this.relation,
118
- { kind: 'attach', entity }
119
- );
120
- return entity;
121
- }
122
-
123
- private get relationKey(): RelationKey {
124
- return `${this.rootTable.name}.${this.relationName}`;
125
- }
126
-
127
- private populateFromHydrationCache(): void {
128
- const fkValue = (this.root as Record<string, unknown>)[this.relation.foreignKey];
129
- if (fkValue === undefined || fkValue === null) return;
130
- const row = getHydrationRecord(this.meta, this.relationName, fkValue);
131
- if (!row) return;
132
- this.current = this.createEntity(row);
133
- this.loaded = true;
134
- }
135
-
136
- toJSON(): unknown {
137
- if (!this.current) return null;
138
- const entityWithToJSON = this.current as { toJSON?: () => unknown };
139
- return typeof entityWithToJSON.toJSON === 'function'
140
- ? entityWithToJSON.toJSON()
141
- : this.current;
142
- }
143
- }
1
+ import { BelongsToReferenceApi } from '../../schema/types.js';
2
+ import { EntityContext } from '../entity-context.js';
3
+ import { RelationKey } from '../runtime-types.js';
4
+ import { BelongsToRelation } from '../../schema/relation.js';
5
+ import { TableDef } from '../../schema/table.js';
6
+ import { EntityMeta, getHydrationRecord, hasEntityMeta } from '../entity-meta.js';
7
+
8
+ type Rows = Record<string, unknown>;
9
+
10
+ const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
11
+
12
+ const hideInternal = (obj: object, keys: string[]): void => {
13
+ for (const key of keys) {
14
+ Object.defineProperty(obj, key, {
15
+ value: obj[key],
16
+ writable: false,
17
+ configurable: false,
18
+ enumerable: false
19
+ });
20
+ }
21
+ };
22
+
23
+ const hideWritable = (obj: object, keys: string[]): void => {
24
+ for (const key of keys) {
25
+ const value = obj[key as keyof typeof obj];
26
+ Object.defineProperty(obj, key, {
27
+ value,
28
+ writable: true,
29
+ configurable: true,
30
+ enumerable: false
31
+ });
32
+ }
33
+ };
34
+
35
+ /**
36
+ * Default implementation of a belongs-to reference.
37
+ * Manages a reference to a parent entity from a child entity through a foreign key.
38
+ *
39
+ * @template TParent The type of the parent entity.
40
+ */
41
+ export class DefaultBelongsToReference<TParent extends object> implements BelongsToReferenceApi<TParent> {
42
+ private loaded = false;
43
+ private current: TParent | null = null;
44
+
45
+ /**
46
+ * @param ctx The entity context for tracking changes.
47
+ * @param meta Metadata for the child entity.
48
+ * @param root The child entity instance (carrying the foreign key).
49
+ * @param relationName The name of the relation.
50
+ * @param relation Relation definition.
51
+ * @param rootTable Table definition of the child entity.
52
+ * @param loader Function to load the parent entity.
53
+ * @param createEntity Function to create entity instances from rows.
54
+ * @param targetKey The primary key of the target (parent) table.
55
+ */
56
+ constructor(
57
+ private readonly ctx: EntityContext,
58
+ private readonly meta: EntityMeta<TableDef>,
59
+ private readonly root: unknown,
60
+ private readonly relationName: string,
61
+ private readonly relation: BelongsToRelation,
62
+ private readonly rootTable: TableDef,
63
+ private readonly loader: () => Promise<Map<string, Rows>>,
64
+ private readonly createEntity: (row: Record<string, unknown>) => TParent,
65
+ private readonly targetKey: string
66
+ ) {
67
+ hideInternal(this, ['ctx', 'meta', 'root', 'relationName', 'relation', 'rootTable', 'loader', 'createEntity', 'targetKey']);
68
+ hideWritable(this, ['loaded', 'current']);
69
+ this.populateFromHydrationCache();
70
+ }
71
+
72
+ async load(): Promise<TParent | null> {
73
+ if (this.loaded) return this.current;
74
+ const map = await this.loader();
75
+ const fkValue = (this.root as Record<string, unknown>)[this.relation.foreignKey];
76
+ if (fkValue === null || fkValue === undefined) {
77
+ this.current = null;
78
+ } else {
79
+ const row = map.get(toKey(fkValue));
80
+ this.current = row ? this.createEntity(row) : null;
81
+ }
82
+ this.loaded = true;
83
+ return this.current;
84
+ }
85
+
86
+ get(): TParent | null {
87
+ return this.current;
88
+ }
89
+
90
+ set(data: Partial<TParent> | TParent | null): TParent | null {
91
+ if (data === null) {
92
+ const previous = this.current;
93
+ (this.root as Record<string, unknown>)[this.relation.foreignKey] = null;
94
+ this.current = null;
95
+ this.ctx.registerRelationChange(
96
+ this.root,
97
+ this.relationKey,
98
+ this.rootTable,
99
+ this.relationName,
100
+ this.relation,
101
+ { kind: 'remove', entity: previous }
102
+ );
103
+ return null;
104
+ }
105
+
106
+ const entity = hasEntityMeta(data) ? (data as TParent) : this.createEntity(data as Record<string, unknown>);
107
+ const pkValue = (entity as Record<string, unknown>)[this.targetKey];
108
+ if (pkValue !== undefined) {
109
+ (this.root as Record<string, unknown>)[this.relation.foreignKey] = pkValue;
110
+ }
111
+ this.current = entity;
112
+ this.ctx.registerRelationChange(
113
+ this.root,
114
+ this.relationKey,
115
+ this.rootTable,
116
+ this.relationName,
117
+ this.relation,
118
+ { kind: 'attach', entity }
119
+ );
120
+ return entity;
121
+ }
122
+
123
+ private get relationKey(): RelationKey {
124
+ return `${this.rootTable.name}.${this.relationName}`;
125
+ }
126
+
127
+ private populateFromHydrationCache(): void {
128
+ const fkValue = (this.root as Record<string, unknown>)[this.relation.foreignKey];
129
+ if (fkValue === undefined || fkValue === null) return;
130
+ const row = getHydrationRecord(this.meta, this.relationName, fkValue);
131
+ if (!row) return;
132
+ this.current = this.createEntity(row);
133
+ this.loaded = true;
134
+ }
135
+
136
+ toJSON(): unknown {
137
+ if (!this.current) return null;
138
+ const entityWithToJSON = this.current as { toJSON?: () => unknown };
139
+ return typeof entityWithToJSON.toJSON === 'function'
140
+ ? entityWithToJSON.toJSON()
141
+ : this.current;
142
+ }
143
+ }