@woltz/rich-domain 1.2.4 → 1.3.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/dist/aggregate-changes.d.ts +56 -14
- package/dist/aggregate-changes.d.ts.map +1 -1
- package/dist/aggregate-changes.js +103 -23
- package/dist/aggregate-changes.js.map +1 -1
- package/dist/base-entity.d.ts +1 -1
- package/dist/base-entity.d.ts.map +1 -1
- package/dist/base-entity.js +28 -13
- package/dist/base-entity.js.map +1 -1
- package/dist/change-tracker.d.ts +2 -1
- package/dist/change-tracker.d.ts.map +1 -1
- package/dist/change-tracker.js +61 -35
- package/dist/change-tracker.js.map +1 -1
- package/dist/criteria.d.ts +7 -15
- package/dist/criteria.d.ts.map +1 -1
- package/dist/criteria.js +105 -81
- package/dist/criteria.js.map +1 -1
- package/dist/domain-event-bus.js +4 -4
- package/dist/domain-event-bus.js.map +1 -1
- package/dist/domain-event.js +3 -0
- package/dist/domain-event.js.map +1 -1
- package/dist/entity-changes.js +1 -0
- package/dist/entity-changes.js.map +1 -1
- package/dist/entity-schema-registry.d.ts +137 -3
- package/dist/entity-schema-registry.d.ts.map +1 -1
- package/dist/entity-schema-registry.js +160 -7
- package/dist/entity-schema-registry.js.map +1 -1
- package/dist/exceptions.js +26 -1
- package/dist/exceptions.js.map +1 -1
- package/dist/id.js +2 -0
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/paginated-result.d.ts +4 -4
- package/dist/paginated-result.d.ts.map +1 -1
- package/dist/paginated-result.js +14 -19
- package/dist/paginated-result.js.map +1 -1
- package/dist/repository/unit-of-work.js +3 -7
- package/dist/repository/unit-of-work.js.map +1 -1
- package/dist/types/change-tracker.d.ts +30 -0
- package/dist/types/change-tracker.d.ts.map +1 -1
- package/dist/types/criteria.d.ts +1 -4
- package/dist/types/criteria.d.ts.map +1 -1
- package/dist/types/domain.d.ts +2 -1
- package/dist/types/domain.d.ts.map +1 -1
- package/dist/types/utils.d.ts +2 -2
- package/dist/utils/helpers.d.ts +1 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +23 -0
- package/dist/utils/helpers.js.map +1 -1
- package/dist/validation-error.d.ts +15 -1
- package/dist/validation-error.d.ts.map +1 -1
- package/dist/validation-error.js +46 -3
- package/dist/validation-error.js.map +1 -1
- package/dist/value-object.d.ts +1 -1
- package/dist/value-object.d.ts.map +1 -1
- package/dist/value-object.js +30 -2
- package/dist/value-object.js.map +1 -1
- package/package.json +17 -3
- package/src/aggregate-changes.ts +133 -24
- package/src/base-entity.ts +22 -11
- package/src/change-tracker.ts +113 -54
- package/src/criteria.ts +151 -109
- package/src/entity-schema-registry.ts +256 -6
- package/src/index.ts +1 -1
- package/src/paginated-result.ts +21 -29
- package/src/types/change-tracker.ts +31 -0
- package/src/types/criteria.ts +1 -4
- package/src/types/domain.ts +2 -1
- package/src/types/utils.ts +2 -2
- package/src/utils/helpers.ts +28 -0
- package/src/validation-error.ts +54 -4
- package/src/value-object.ts +6 -1
- package/.versionrc.json +0 -21
- package/CHANGELOG.md +0 -163
- package/tests/aggregate-changes.test.ts +0 -284
- package/tests/criteria.test.ts +0 -716
- package/tests/depth/deep-tracking.test.ts +0 -554
- package/tests/domain-events.test.ts +0 -431
- package/tests/entity-equality.test.ts +0 -464
- package/tests/entity-schema-registry.test.ts +0 -382
- package/tests/entity-validation.test.ts +0 -252
- package/tests/history-tracker.spec.ts +0 -439
- package/tests/id.test.ts +0 -338
- package/tests/load-test/data.json +0 -347211
- package/tests/load-test/entities.ts +0 -97
- package/tests/load-test/generate-data.ts +0 -81
- package/tests/load-test/lead-to-domain.mapper.ts +0 -24
- package/tests/load-test/load.test.ts +0 -38
- package/tests/repository.test.ts +0 -635
- package/tests/to-json.test.ts +0 -99
- package/tests/utils.ts +0 -290
- package/tests/value-object-validation.test.ts +0 -219
- package/tests/value-objects.test.ts +0 -80
- package/tsconfig.json +0 -9
package/src/criteria.ts
CHANGED
|
@@ -27,7 +27,7 @@ export class Criteria<T = any> {
|
|
|
27
27
|
private _filters: Filter<FieldPath<T>, any>[] = [];
|
|
28
28
|
private _orders: Order[] = [];
|
|
29
29
|
private _pagination: Pagination = { page: 1, limit: 20, offset: 0 };
|
|
30
|
-
private _search?: Search
|
|
30
|
+
private _search?: Search;
|
|
31
31
|
private _adapter?: CriteriaAdapter<any, any>;
|
|
32
32
|
|
|
33
33
|
private constructor() {}
|
|
@@ -155,11 +155,8 @@ export class Criteria<T = any> {
|
|
|
155
155
|
return this.orderBy(field, "desc");
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
search
|
|
159
|
-
this._search =
|
|
160
|
-
fields: fields.map(this.resolveFieldPath),
|
|
161
|
-
value,
|
|
162
|
-
};
|
|
158
|
+
search(value: string): this {
|
|
159
|
+
this._search = value;
|
|
163
160
|
return this;
|
|
164
161
|
}
|
|
165
162
|
|
|
@@ -167,13 +164,8 @@ export class Criteria<T = any> {
|
|
|
167
164
|
return !!this._search;
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
getSearch() {
|
|
171
|
-
return this._search
|
|
172
|
-
? {
|
|
173
|
-
fields: this._search.fields.map(this.resolveFieldPath),
|
|
174
|
-
value: this._search.value,
|
|
175
|
-
}
|
|
176
|
-
: undefined;
|
|
167
|
+
getSearch(): Search | undefined {
|
|
168
|
+
return this._search;
|
|
177
169
|
}
|
|
178
170
|
|
|
179
171
|
paginate(page: number, limit: number): this {
|
|
@@ -241,12 +233,7 @@ export class Criteria<T = any> {
|
|
|
241
233
|
})),
|
|
242
234
|
];
|
|
243
235
|
cloned._pagination = { ...this._pagination };
|
|
244
|
-
cloned._search = this._search
|
|
245
|
-
? {
|
|
246
|
-
fields: this._search.fields.map(this.resolveFieldPath),
|
|
247
|
-
value: this._search.value,
|
|
248
|
-
}
|
|
249
|
-
: undefined;
|
|
236
|
+
cloned._search = this._search;
|
|
250
237
|
|
|
251
238
|
if (this._adapter) {
|
|
252
239
|
cloned.useAdapter(this._adapter);
|
|
@@ -268,12 +255,7 @@ export class Criteria<T = any> {
|
|
|
268
255
|
direction: order.direction,
|
|
269
256
|
})),
|
|
270
257
|
pagination: this._pagination,
|
|
271
|
-
search: this._search
|
|
272
|
-
? {
|
|
273
|
-
fields: this._search.fields.map(this.resolveFieldPath),
|
|
274
|
-
value: this._search.value,
|
|
275
|
-
}
|
|
276
|
-
: undefined,
|
|
258
|
+
search: this._search,
|
|
277
259
|
};
|
|
278
260
|
}
|
|
279
261
|
|
|
@@ -282,7 +264,7 @@ export class Criteria<T = any> {
|
|
|
282
264
|
filters?: TypedFilter<T>[];
|
|
283
265
|
orders?: TypedOrder<T>[];
|
|
284
266
|
pagination?: Pagination;
|
|
285
|
-
search?:
|
|
267
|
+
search?: Search;
|
|
286
268
|
},
|
|
287
269
|
adapter?: CriteriaAdapter<any, any>
|
|
288
270
|
): Criteria<T> {
|
|
@@ -307,11 +289,7 @@ export class Criteria<T = any> {
|
|
|
307
289
|
})),
|
|
308
290
|
];
|
|
309
291
|
if (obj.pagination) criteria._pagination = { ...obj.pagination };
|
|
310
|
-
if (obj.search)
|
|
311
|
-
criteria._search = {
|
|
312
|
-
...obj.search,
|
|
313
|
-
fields: obj.search.fields.map(criteria.resolveFieldPath),
|
|
314
|
-
};
|
|
292
|
+
if (obj.search) criteria._search = obj.search;
|
|
315
293
|
|
|
316
294
|
return criteria;
|
|
317
295
|
}
|
|
@@ -337,10 +315,12 @@ export class Criteria<T = any> {
|
|
|
337
315
|
return field;
|
|
338
316
|
}
|
|
339
317
|
|
|
340
|
-
static fromQueryParams<T>(
|
|
341
|
-
query: Record<string, any
|
|
318
|
+
static fromQueryParams<T = any>(
|
|
319
|
+
query: Record<string, any> | undefined,
|
|
342
320
|
adapter?: CriteriaAdapter<any, any>
|
|
343
321
|
): Criteria<T> {
|
|
322
|
+
if (!query) return Criteria.create<T>();
|
|
323
|
+
|
|
344
324
|
const criteria = Criteria.create<T>();
|
|
345
325
|
|
|
346
326
|
if (adapter) {
|
|
@@ -354,80 +334,95 @@ export class Criteria<T = any> {
|
|
|
354
334
|
if (key === "limit") {
|
|
355
335
|
continue;
|
|
356
336
|
}
|
|
357
|
-
if (key === "sort") {
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
337
|
|
|
361
|
-
|
|
338
|
+
if (key === "filters") {
|
|
339
|
+
const filters: Record<string, any> = criteria.parseFilterValue(value);
|
|
362
340
|
|
|
363
|
-
|
|
341
|
+
for (let [filterKey, filterValue] of Object.entries(filters)) {
|
|
342
|
+
const [field, operatorWithQuantifier] = filterKey.split(":");
|
|
364
343
|
|
|
365
|
-
|
|
366
|
-
const operator = isOperator(operatorRaw) ? operatorRaw : null;
|
|
367
|
-
if (!operator) {
|
|
368
|
-
throw new InvalidCriteriaError(`Invalid filter operator`, operatorRaw);
|
|
369
|
-
}
|
|
344
|
+
if (!operatorWithQuantifier || !field) continue;
|
|
370
345
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
quantifierRaw
|
|
381
|
-
);
|
|
382
|
-
}
|
|
346
|
+
const [operatorRaw, quantifierRaw] =
|
|
347
|
+
operatorWithQuantifier.split("@");
|
|
348
|
+
const operator = isOperator(operatorRaw) ? operatorRaw : null;
|
|
349
|
+
if (!operator) {
|
|
350
|
+
throw new InvalidCriteriaError(
|
|
351
|
+
`Invalid filter operator`,
|
|
352
|
+
operatorRaw
|
|
353
|
+
);
|
|
354
|
+
}
|
|
383
355
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
356
|
+
const validQuantifiers = ["some", "every", "none"];
|
|
357
|
+
const quantifier =
|
|
358
|
+
quantifierRaw && validQuantifiers.includes(quantifierRaw)
|
|
359
|
+
? (quantifierRaw as CriteriaOptions["quantifier"])
|
|
360
|
+
: undefined;
|
|
361
|
+
|
|
362
|
+
if (quantifierRaw && !quantifier) {
|
|
363
|
+
throw new InvalidCriteriaError(
|
|
364
|
+
`Invalid quantifier. Valid values: ${validQuantifiers.join(
|
|
365
|
+
", "
|
|
366
|
+
)}`,
|
|
367
|
+
quantifierRaw
|
|
368
|
+
);
|
|
369
|
+
}
|
|
387
370
|
|
|
388
|
-
|
|
371
|
+
const options: CriteriaOptions | undefined = quantifier
|
|
372
|
+
? { quantifier }
|
|
373
|
+
: undefined;
|
|
389
374
|
|
|
390
|
-
|
|
375
|
+
let parsedValue: any = filterValue;
|
|
391
376
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
.split(",")
|
|
395
|
-
.map((v: any) => parseQueryValue(v.trim()));
|
|
396
|
-
if (parsedValue.length === 2) {
|
|
397
|
-
criteria.where(
|
|
398
|
-
resolvedField,
|
|
399
|
-
"between" as OperatorsForType<PathValue<T, FieldPath<T>>>,
|
|
400
|
-
[parsedValue[0], parsedValue[1]] as [
|
|
401
|
-
PathValue<T, FieldPath<T>>,
|
|
402
|
-
PathValue<T, FieldPath<T>>
|
|
403
|
-
],
|
|
404
|
-
options
|
|
377
|
+
const resolvedField = criteria.resolveFieldPath(
|
|
378
|
+
field as FieldPath<T>
|
|
405
379
|
);
|
|
406
|
-
}
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
380
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
381
|
+
if (operator === "between") {
|
|
382
|
+
parsedValue = criteria
|
|
383
|
+
.parseFilterValue(filterValue)
|
|
384
|
+
.map((v: any) => parseQueryValue(v.trim()));
|
|
385
|
+
|
|
386
|
+
if (parsedValue.length === 2) {
|
|
387
|
+
criteria.where(
|
|
388
|
+
resolvedField,
|
|
389
|
+
"between" as OperatorsForType<PathValue<T, FieldPath<T>>>,
|
|
390
|
+
[parsedValue[0], parsedValue[1]] as [
|
|
391
|
+
PathValue<T, FieldPath<T>>,
|
|
392
|
+
PathValue<T, FieldPath<T>>
|
|
393
|
+
],
|
|
394
|
+
options
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
420
399
|
|
|
421
|
-
|
|
400
|
+
if (operator === "in" || operator === "notIn") {
|
|
401
|
+
parsedValue = criteria
|
|
402
|
+
.parseFilterValue(filterValue)
|
|
403
|
+
.map(parseQueryValue);
|
|
404
|
+
|
|
405
|
+
criteria.where(
|
|
406
|
+
field as any,
|
|
407
|
+
operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
|
|
408
|
+
parsedValue,
|
|
409
|
+
options
|
|
410
|
+
);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
422
413
|
|
|
423
|
-
|
|
414
|
+
const parsedFinalValue = parseQueryValue(filterValue);
|
|
424
415
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
416
|
+
criteria.validateOperator(operator, parsedFinalValue);
|
|
417
|
+
|
|
418
|
+
criteria.where(
|
|
419
|
+
field as FieldPath<T>,
|
|
420
|
+
operator as OperatorsForType<PathValue<T, FieldPath<T>>>,
|
|
421
|
+
parsedFinalValue,
|
|
422
|
+
options
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
431
426
|
}
|
|
432
427
|
|
|
433
428
|
const page = query.page ? parseInt(query.page) : undefined;
|
|
@@ -437,24 +432,60 @@ export class Criteria<T = any> {
|
|
|
437
432
|
criteria.paginate(page, limit);
|
|
438
433
|
}
|
|
439
434
|
|
|
435
|
+
// 1. orderBy=["field:asc","field2:desc"]
|
|
440
436
|
if (query.orderBy) {
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
437
|
+
const orderByValue = query.orderBy;
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
typeof orderByValue === "string" &&
|
|
441
|
+
orderByValue.trim().startsWith("[")
|
|
442
|
+
) {
|
|
443
|
+
try {
|
|
444
|
+
const orderArray = JSON.parse(orderByValue);
|
|
445
|
+
if (Array.isArray(orderArray)) {
|
|
446
|
+
orderArray.forEach((item: string) => {
|
|
447
|
+
const [field, direction] = item.split(":");
|
|
448
|
+
criteria.orderBy(
|
|
449
|
+
field as FieldPath<T>,
|
|
450
|
+
(direction as OrderDirection) || "asc"
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
throw new InvalidCriteriaError(
|
|
456
|
+
"Invalid JSON array format for orderBy",
|
|
457
|
+
orderByValue
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
} else if (Array.isArray(orderByValue)) {
|
|
461
|
+
orderByValue.forEach((item: string) => {
|
|
462
|
+
const [field, direction] = item.split(":");
|
|
463
|
+
criteria.orderBy(
|
|
464
|
+
field as FieldPath<T>,
|
|
465
|
+
(direction as OrderDirection) || "asc"
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// 2. orderBy="field:asc,field2:desc"
|
|
470
|
+
else if (typeof orderByValue === "string" && orderByValue.includes(":")) {
|
|
471
|
+
const sortParts = orderByValue.split(",");
|
|
472
|
+
sortParts.forEach((part: string) => {
|
|
473
|
+
const [field, direction] = part.split(":");
|
|
474
|
+
criteria.orderBy(
|
|
475
|
+
field as FieldPath<T>,
|
|
476
|
+
(direction as OrderDirection) || "asc"
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
// 3. orderBy="field" + orderDirection="asc"
|
|
481
|
+
else {
|
|
482
|
+
const direction = (query.orderDirection as OrderDirection) || "asc";
|
|
483
|
+
criteria.orderBy(orderByValue as FieldPath<T>, direction);
|
|
484
|
+
}
|
|
449
485
|
}
|
|
450
486
|
|
|
451
|
-
if (query.search && query.
|
|
452
|
-
|
|
453
|
-
.split(",")
|
|
454
|
-
.filter(Boolean) as FieldPath<T>[];
|
|
455
|
-
|
|
456
|
-
const resolvedFields = fields.map(criteria.resolveFieldPath);
|
|
457
|
-
criteria.search(resolvedFields, query.search as string);
|
|
487
|
+
if (query.search && typeof query.search === "string") {
|
|
488
|
+
criteria.search(query.search);
|
|
458
489
|
}
|
|
459
490
|
|
|
460
491
|
return criteria;
|
|
@@ -476,4 +507,15 @@ export class Criteria<T = any> {
|
|
|
476
507
|
);
|
|
477
508
|
}
|
|
478
509
|
}
|
|
510
|
+
|
|
511
|
+
private parseFilterValue(value: any) {
|
|
512
|
+
if (typeof value === "string") {
|
|
513
|
+
try {
|
|
514
|
+
return JSON.parse(value);
|
|
515
|
+
} catch {
|
|
516
|
+
throw new InvalidCriteriaError(`Invalid filter value`, value);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return parseQueryValue(value);
|
|
520
|
+
}
|
|
479
521
|
}
|
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import { Entity } from "./entity";
|
|
2
2
|
import { ValueObject } from "./value-object";
|
|
3
3
|
import { Id } from "./id";
|
|
4
|
+
import { ConfigurationError } from "./exceptions";
|
|
5
|
+
import { levenshteinDistance } from "./utils/helpers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Type of collection relationship.
|
|
9
|
+
* - 'owned': Parent owns the children (1:N). Delete parent = delete children.
|
|
10
|
+
* - 'reference': Parent references existing entities (N:N). Delete parent = unlink only.
|
|
11
|
+
*/
|
|
12
|
+
export type CollectionType = "owned" | "reference";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration for a collection (1:N or N:N relationship).
|
|
16
|
+
*/
|
|
17
|
+
export interface CollectionConfig {
|
|
18
|
+
/**
|
|
19
|
+
* Type of relationship.
|
|
20
|
+
* - 'owned': Children are created/deleted with the parent (default for 1:N)
|
|
21
|
+
* - 'reference': Only the link is created/removed (for N:N)
|
|
22
|
+
* @default 'owned'
|
|
23
|
+
*/
|
|
24
|
+
type: CollectionType;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Target entity name (required for 'reference' type).
|
|
28
|
+
* @example 'Tag'
|
|
29
|
+
*/
|
|
30
|
+
entity?: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Junction table configuration (optional, for ORMs that need it like Drizzle).
|
|
34
|
+
* Prisma handles this automatically, so it's optional.
|
|
35
|
+
*/
|
|
36
|
+
junction?: {
|
|
37
|
+
/** Junction table name (e.g., 'post_tags', '_PostToTag') */
|
|
38
|
+
table: string;
|
|
39
|
+
/** FK field pointing to the source entity (e.g., 'post_id') */
|
|
40
|
+
sourceKey: string;
|
|
41
|
+
/** FK field pointing to the target entity (e.g., 'tag_id') */
|
|
42
|
+
targetKey: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
4
45
|
|
|
5
46
|
/**
|
|
6
47
|
* Mapping schema for a domain entity.
|
|
@@ -17,7 +58,7 @@ export interface EntitySchema {
|
|
|
17
58
|
*/
|
|
18
59
|
fields?: Record<string, string>;
|
|
19
60
|
/**
|
|
20
|
-
* FK configuration for parent relation.
|
|
61
|
+
* FK configuration for parent relation (1:N owned).
|
|
21
62
|
*/
|
|
22
63
|
parentFk?: {
|
|
23
64
|
/** Name of the FK field in the database (e.g., 'author_id') */
|
|
@@ -25,6 +66,18 @@ export interface EntitySchema {
|
|
|
25
66
|
/** Name of the parent entity (e.g., 'User') */
|
|
26
67
|
parentEntity: string;
|
|
27
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Collection configurations for this entity's relations.
|
|
71
|
+
* Key is the property name in the domain entity.
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* collections: {
|
|
75
|
+
* comments: { type: 'owned' },
|
|
76
|
+
* tags: { type: 'reference', entity: 'Tag' }
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
collections?: Record<string, CollectionConfig>;
|
|
28
81
|
}
|
|
29
82
|
|
|
30
83
|
/**
|
|
@@ -50,11 +103,15 @@ export interface MappedEntityData {
|
|
|
50
103
|
* table: 'blog_posts',
|
|
51
104
|
* fields: { content: 'post_content' },
|
|
52
105
|
* parentFk: { field: 'author_id', parentEntity: 'User' },
|
|
106
|
+
* collections: {
|
|
107
|
+
* comments: { type: 'owned' },
|
|
108
|
+
* tags: { type: 'reference', entity: 'Tag' }
|
|
109
|
+
* }
|
|
53
110
|
* });
|
|
54
111
|
*
|
|
55
112
|
* const table = registry.getTable('Post'); // 'blog_posts'
|
|
56
|
-
* const
|
|
57
|
-
* // {
|
|
113
|
+
* const tagConfig = registry.getCollectionConfig('Post', 'tags');
|
|
114
|
+
* // { type: 'reference', entity: 'Tag' }
|
|
58
115
|
* ```
|
|
59
116
|
*/
|
|
60
117
|
export class EntitySchemaRegistry {
|
|
@@ -93,14 +150,31 @@ export class EntitySchemaRegistry {
|
|
|
93
150
|
getSchema(entity: string): EntitySchema {
|
|
94
151
|
const schema = this.schemas.get(entity);
|
|
95
152
|
if (!schema) {
|
|
96
|
-
throw new
|
|
153
|
+
throw new ConfigurationError(
|
|
97
154
|
`EntitySchemaRegistry: No schema registered for entity '${entity}'. ` +
|
|
98
|
-
`Available entities: ${
|
|
155
|
+
`Available entities: ${
|
|
156
|
+
Array.from(this.schemas.keys()).join(", ") || "none"
|
|
157
|
+
}`
|
|
99
158
|
);
|
|
100
159
|
}
|
|
101
160
|
return schema;
|
|
102
161
|
}
|
|
103
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Gets all registered schemas.
|
|
165
|
+
*/
|
|
166
|
+
getAllSchemas(): EntitySchema[] {
|
|
167
|
+
return Array.from(this.schemas.values());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Tries to get the schema of an entity, returns null if not found.
|
|
172
|
+
* @param entity - Entity name.
|
|
173
|
+
*/
|
|
174
|
+
tryGetSchema(entity: string): EntitySchema | null {
|
|
175
|
+
return this.schemas.get(entity) ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
104
178
|
/**
|
|
105
179
|
* Checks if an entity is registered.
|
|
106
180
|
* @param entity - Entity name.
|
|
@@ -212,6 +286,104 @@ export class EntitySchemaRegistry {
|
|
|
212
286
|
return schema.parentFk?.field ?? null;
|
|
213
287
|
}
|
|
214
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Gets the collection configuration for a specific field.
|
|
291
|
+
*
|
|
292
|
+
* @param entity - Parent entity name (e.g., 'Post')
|
|
293
|
+
* @param fieldName - Collection field name (e.g., 'tags')
|
|
294
|
+
* @returns CollectionConfig or null if not configured
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* const config = registry.getCollectionConfig('Post', 'tags');
|
|
299
|
+
* if (config?.type === 'reference') {
|
|
300
|
+
* // Handle N:N relation - use connect/disconnect
|
|
301
|
+
* } else {
|
|
302
|
+
* // Handle 1:N relation - use create/delete
|
|
303
|
+
* }
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
getCollectionConfig(
|
|
307
|
+
entity: string,
|
|
308
|
+
fieldName: string
|
|
309
|
+
): CollectionConfig | null {
|
|
310
|
+
const schema = this.tryGetSchema(entity);
|
|
311
|
+
if (!schema?.collections) return null;
|
|
312
|
+
return schema.collections[fieldName] ?? null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Checks if a collection is a reference type (N:N).
|
|
317
|
+
*
|
|
318
|
+
* @param entity - Parent entity name
|
|
319
|
+
* @param fieldName - Collection field name
|
|
320
|
+
* @returns true if the collection is a reference (N:N), false otherwise
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* if (registry.isReferenceCollection('Post', 'tags')) {
|
|
325
|
+
* // Use connect/disconnect instead of create/delete
|
|
326
|
+
* }
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
isReferenceCollection(entity: string, fieldName: string): boolean {
|
|
330
|
+
const config = this.getCollectionConfig(entity, fieldName);
|
|
331
|
+
return config?.type === "reference";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Checks if a collection is owned (1:N).
|
|
336
|
+
* Returns true if explicitly configured as 'owned' or if not configured at all.
|
|
337
|
+
*
|
|
338
|
+
* @param entity - Parent entity name
|
|
339
|
+
* @param fieldName - Collection field name
|
|
340
|
+
* @returns true if the collection is owned (1:N), false if reference
|
|
341
|
+
*/
|
|
342
|
+
isOwnedCollection(entity: string, fieldName: string): boolean {
|
|
343
|
+
const config = this.getCollectionConfig(entity, fieldName);
|
|
344
|
+
// Default to owned if not configured
|
|
345
|
+
return config?.type !== "reference";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Gets all collections configured for an entity.
|
|
350
|
+
*
|
|
351
|
+
* @param entity - Entity name
|
|
352
|
+
* @returns Record of field names to collection configs, or empty object
|
|
353
|
+
*/
|
|
354
|
+
getCollections(entity: string): Record<string, CollectionConfig> {
|
|
355
|
+
const schema = this.tryGetSchema(entity);
|
|
356
|
+
return schema?.collections ?? {};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Gets all reference (N:N) collections for an entity.
|
|
361
|
+
*
|
|
362
|
+
* @param entity - Entity name
|
|
363
|
+
* @returns Array of field names that are reference collections
|
|
364
|
+
*/
|
|
365
|
+
getReferenceCollections(entity: string): string[] {
|
|
366
|
+
const collections = this.getCollections(entity);
|
|
367
|
+
return Object.entries(collections)
|
|
368
|
+
.filter(([_, config]) => config.type === "reference")
|
|
369
|
+
.map(([field]) => field);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Gets the junction table configuration for a reference collection.
|
|
374
|
+
*
|
|
375
|
+
* @param entity - Parent entity name
|
|
376
|
+
* @param fieldName - Collection field name
|
|
377
|
+
* @returns Junction config or null
|
|
378
|
+
*/
|
|
379
|
+
getJunctionConfig(
|
|
380
|
+
entity: string,
|
|
381
|
+
fieldName: string
|
|
382
|
+
): CollectionConfig["junction"] | null {
|
|
383
|
+
const config = this.getCollectionConfig(entity, fieldName);
|
|
384
|
+
return config?.junction ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
215
387
|
/**
|
|
216
388
|
* Lists all registered entities.
|
|
217
389
|
*/
|
|
@@ -234,7 +406,12 @@ export class EntitySchemaRegistry {
|
|
|
234
406
|
if (Array.isArray(value)) return true;
|
|
235
407
|
if (value instanceof Entity) return true;
|
|
236
408
|
if (value instanceof ValueObject) return true;
|
|
237
|
-
if (
|
|
409
|
+
if (
|
|
410
|
+
typeof value === "object" &&
|
|
411
|
+
value.id &&
|
|
412
|
+
typeof value.id === "object" &&
|
|
413
|
+
"value" in value.id
|
|
414
|
+
) {
|
|
238
415
|
return true;
|
|
239
416
|
}
|
|
240
417
|
return false;
|
|
@@ -252,4 +429,77 @@ export class EntitySchemaRegistry {
|
|
|
252
429
|
}
|
|
253
430
|
return value;
|
|
254
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Validates that a relation field exists in the entity's collections.
|
|
435
|
+
*
|
|
436
|
+
* @param entity - Parent entity name
|
|
437
|
+
* @param relationField - Relation field to validate
|
|
438
|
+
* @throws ConfigurationError if the field doesn't exist
|
|
439
|
+
*
|
|
440
|
+
*/
|
|
441
|
+
public validateRelationField(entity: string, relationField: string): void {
|
|
442
|
+
const schema = this.tryGetSchema(entity);
|
|
443
|
+
|
|
444
|
+
const uuidPattern =
|
|
445
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
446
|
+
|
|
447
|
+
if (uuidPattern.test(entity)) {
|
|
448
|
+
throw new ConfigurationError(
|
|
449
|
+
`EntitySchemaRegistry: Received an ID '${entity}' instead of an entity name. ` +
|
|
450
|
+
`This usually means 'parentEntity' is not being set correctly in the ChangeTracker. ` +
|
|
451
|
+
`Check that addDelete/addCreate are receiving the entity NAME (e.g., 'Post'), not the ID.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!schema) {
|
|
456
|
+
throw new ConfigurationError(
|
|
457
|
+
`EntitySchemaRegistry: Cannot validate relation '${relationField}' - ` +
|
|
458
|
+
`entity '${entity}' is not registered. ` +
|
|
459
|
+
`Available entities: ${
|
|
460
|
+
this.getRegisteredEntities().join(", ") || "none"
|
|
461
|
+
}`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const collections = schema.collections ?? {};
|
|
466
|
+
const availableCollections = Object.keys(collections);
|
|
467
|
+
|
|
468
|
+
if (availableCollections.length === 0) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!collections[relationField]) {
|
|
473
|
+
const suggestions = this.findSimilarNames(
|
|
474
|
+
relationField,
|
|
475
|
+
availableCollections
|
|
476
|
+
);
|
|
477
|
+
const suggestionText =
|
|
478
|
+
suggestions.length > 0
|
|
479
|
+
? ` Did you mean: '${suggestions.join("' or '")}'?`
|
|
480
|
+
: "";
|
|
481
|
+
|
|
482
|
+
throw new ConfigurationError(
|
|
483
|
+
`EntitySchemaRegistry: Unknown relation '${relationField}' for entity '${entity}'. ` +
|
|
484
|
+
`Available collections: ${availableCollections.join(
|
|
485
|
+
", "
|
|
486
|
+
)}.${suggestionText}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private findSimilarNames(input: string, candidates: string[]): string[] {
|
|
492
|
+
return candidates
|
|
493
|
+
.map((candidate) => ({
|
|
494
|
+
name: candidate,
|
|
495
|
+
distance: levenshteinDistance(
|
|
496
|
+
input.toLowerCase(),
|
|
497
|
+
candidate.toLowerCase()
|
|
498
|
+
),
|
|
499
|
+
}))
|
|
500
|
+
.filter(({ distance }) => distance <= 3)
|
|
501
|
+
.sort((a, b) => a.distance - b.distance)
|
|
502
|
+
.slice(0, 2)
|
|
503
|
+
.map(({ name }) => name);
|
|
504
|
+
}
|
|
255
505
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { ValueObject } from "./value-object";
|
|
|
11
11
|
export { Mapper } from "./mapper";
|
|
12
12
|
export { EntitySchemaRegistry } from "./entity-schema-registry";
|
|
13
13
|
export { AggregateChanges } from "./aggregate-changes";
|
|
14
|
-
export {
|
|
14
|
+
export type {
|
|
15
15
|
DomainEventHandler,
|
|
16
16
|
EntityHooks,
|
|
17
17
|
Filter,
|