@woltz/rich-domain 1.1.0 → 1.2.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/CHANGELOG.md +87 -21
- package/dist/aggregate-changes.d.ts +164 -0
- package/dist/aggregate-changes.d.ts.map +1 -0
- package/dist/aggregate-changes.js +281 -0
- package/dist/aggregate-changes.js.map +1 -0
- package/dist/base-entity.d.ts +32 -8
- package/dist/base-entity.d.ts.map +1 -1
- package/dist/base-entity.js +117 -86
- package/dist/base-entity.js.map +1 -1
- package/dist/criteria.d.ts +31 -15
- package/dist/criteria.d.ts.map +1 -1
- package/dist/criteria.js +151 -61
- package/dist/criteria.js.map +1 -1
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +29 -0
- package/dist/crypto.js.map +1 -0
- package/dist/entity-changes.d.ts +84 -0
- package/dist/entity-changes.d.ts.map +1 -0
- package/dist/entity-changes.js +135 -0
- package/dist/entity-changes.js.map +1 -0
- package/dist/entity-schema-registry.d.ts +148 -0
- package/dist/entity-schema-registry.d.ts.map +1 -0
- package/dist/entity-schema-registry.js +219 -0
- package/dist/entity-schema-registry.js.map +1 -0
- package/dist/history-tracker.d.ts +97 -0
- package/dist/history-tracker.d.ts.map +1 -0
- package/dist/history-tracker.js +805 -0
- package/dist/history-tracker.js.map +1 -0
- package/dist/id.d.ts +11 -10
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +4 -28
- package/dist/id.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mapper.d.ts +1 -1
- package/dist/mapper.d.ts.map +1 -1
- package/dist/mapper.js.map +1 -1
- package/dist/paginated-result.d.ts.map +1 -1
- package/dist/paginated-result.js +7 -6
- package/dist/paginated-result.js.map +1 -1
- package/dist/repository/base-repository.d.ts +6 -32
- package/dist/repository/base-repository.d.ts.map +1 -1
- package/dist/repository/base-repository.js +0 -27
- package/dist/repository/base-repository.js.map +1 -1
- package/dist/repository/unit-of-work.d.ts +0 -25
- package/dist/repository/unit-of-work.d.ts.map +1 -1
- package/dist/repository/unit-of-work.js +0 -25
- package/dist/repository/unit-of-work.js.map +1 -1
- package/dist/types/change-tracker.d.ts +186 -0
- package/dist/types/change-tracker.d.ts.map +1 -0
- package/dist/types/change-tracker.js +2 -0
- package/dist/types/change-tracker.js.map +1 -0
- package/dist/types/criteria.d.ts +31 -7
- package/dist/types/criteria.d.ts.map +1 -1
- package/dist/types/history-tracker.d.ts +11 -0
- package/dist/types/history-tracker.d.ts.map +1 -1
- package/dist/types/utils.d.ts +0 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/utils/criteria-operator-validation.d.ts +5 -0
- package/dist/utils/criteria-operator-validation.d.ts.map +1 -0
- package/dist/utils/criteria-operator-validation.js +143 -0
- package/dist/utils/criteria-operator-validation.js.map +1 -0
- package/dist/utils/helpers.d.ts +2 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +10 -0
- package/dist/utils/helpers.js.map +1 -0
- package/dist/validation-error.d.ts.map +1 -1
- package/dist/validation-error.js +0 -3
- package/dist/validation-error.js.map +1 -1
- package/dist/value-object.d.ts +57 -8
- package/dist/value-object.d.ts.map +1 -1
- package/dist/value-object.js +49 -21
- package/dist/value-object.js.map +1 -1
- package/eslint.config.js +6 -0
- package/package.json +2 -1
- package/src/aggregate-changes.ts +335 -0
- package/src/base-entity.ts +140 -100
- package/src/criteria.ts +264 -82
- package/src/crypto.ts +31 -0
- package/src/entity-changes.ts +151 -0
- package/src/entity-schema-registry.ts +275 -0
- package/src/history-tracker.ts +1114 -0
- package/src/id.ts +17 -26
- package/src/index.ts +8 -2
- package/src/mapper.ts +4 -1
- package/src/paginated-result.ts +7 -8
- package/src/repository/base-repository.ts +6 -37
- package/src/repository/unit-of-work.ts +0 -25
- package/src/types/change-tracker.ts +221 -0
- package/src/types/criteria.ts +95 -17
- package/src/types/history-tracker.ts +13 -0
- package/src/types/utils.ts +0 -9
- package/src/utils/criteria-operator-validation.ts +171 -0
- package/src/utils/helpers.ts +6 -0
- package/src/validation-error.ts +0 -4
- package/src/value-object.ts +84 -23
- package/tests/aggregate-changes.test.ts +284 -0
- package/tests/criteria.test.ts +366 -90
- package/tests/entity-equality.test.ts +38 -61
- package/tests/entity-schema-registry.test.ts +382 -0
- package/tests/entity-validation.test.ts +7 -94
- package/tests/history-tracker.spec.ts +349 -617
- package/tests/id.test.ts +41 -44
- package/tests/load-test/data.json +346041 -0
- package/tests/load-test/entities.ts +97 -0
- package/tests/load-test/generate-data.ts +81 -0
- package/tests/load-test/lead-to-domain.mapper.ts +24 -0
- package/tests/load-test/load.test.ts +38 -0
- package/tests/repository.test.ts +30 -54
- package/tests/to-json.test.ts +14 -18
- package/tests/utils.ts +138 -102
- package/tests/value-objects.test.ts +57 -29
- package/dist/deep-proxy.d.ts +0 -36
- package/dist/deep-proxy.d.ts.map +0 -1
- package/dist/deep-proxy.js +0 -384
- package/dist/deep-proxy.js.map +0 -1
- package/src/deep-proxy.ts +0 -447
- package/tests/entity.test.ts +0 -33
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrayOperators,
|
|
3
|
+
BooleanOperators,
|
|
4
|
+
DateOperators,
|
|
5
|
+
FILTER_OPERATORS,
|
|
6
|
+
FilterOperator,
|
|
7
|
+
NumberOperators,
|
|
8
|
+
StringOperators,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
export function isValidOperatorForType(
|
|
12
|
+
value: unknown,
|
|
13
|
+
operator: FilterOperator
|
|
14
|
+
): boolean {
|
|
15
|
+
// Handle null/undefined
|
|
16
|
+
if (value === null || value === undefined) {
|
|
17
|
+
return ["isNull", "isNotNull", "equals", "notEquals"].includes(operator);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Special case: between operator with array [min, max]
|
|
21
|
+
if (operator === "between" && Array.isArray(value) && value.length === 2) {
|
|
22
|
+
// Validate based on the type of the first element
|
|
23
|
+
const elementType = typeof value[0];
|
|
24
|
+
if (elementType === "number" || value[0] instanceof Date) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const valueType = typeof value;
|
|
31
|
+
|
|
32
|
+
// String operators
|
|
33
|
+
if (valueType === "string") {
|
|
34
|
+
const validOps: StringOperators[] = [
|
|
35
|
+
"equals",
|
|
36
|
+
"notEquals",
|
|
37
|
+
"contains",
|
|
38
|
+
"startsWith",
|
|
39
|
+
"endsWith",
|
|
40
|
+
"in",
|
|
41
|
+
"notIn",
|
|
42
|
+
"isNull",
|
|
43
|
+
"isNotNull",
|
|
44
|
+
];
|
|
45
|
+
return validOps.includes(operator as StringOperators);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Number operators
|
|
49
|
+
if (valueType === "number") {
|
|
50
|
+
const validOps: NumberOperators[] = [
|
|
51
|
+
"equals",
|
|
52
|
+
"notEquals",
|
|
53
|
+
"greaterThan",
|
|
54
|
+
"greaterThanOrEqual",
|
|
55
|
+
"lessThan",
|
|
56
|
+
"lessThanOrEqual",
|
|
57
|
+
"in",
|
|
58
|
+
"notIn",
|
|
59
|
+
"between",
|
|
60
|
+
"isNull",
|
|
61
|
+
"isNotNull",
|
|
62
|
+
];
|
|
63
|
+
return validOps.includes(operator as NumberOperators);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Boolean operators
|
|
67
|
+
if (valueType === "boolean") {
|
|
68
|
+
const validOps: BooleanOperators[] = [
|
|
69
|
+
"equals",
|
|
70
|
+
"notEquals",
|
|
71
|
+
"isNull",
|
|
72
|
+
"isNotNull",
|
|
73
|
+
];
|
|
74
|
+
return validOps.includes(operator as BooleanOperators);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Date operators
|
|
78
|
+
if (value instanceof Date) {
|
|
79
|
+
const validOps: DateOperators[] = [
|
|
80
|
+
"equals",
|
|
81
|
+
"notEquals",
|
|
82
|
+
"greaterThan",
|
|
83
|
+
"greaterThanOrEqual",
|
|
84
|
+
"lessThan",
|
|
85
|
+
"lessThanOrEqual",
|
|
86
|
+
"in",
|
|
87
|
+
"notIn",
|
|
88
|
+
"between",
|
|
89
|
+
"isNull",
|
|
90
|
+
"isNotNull",
|
|
91
|
+
];
|
|
92
|
+
return validOps.includes(operator as DateOperators);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Array operators
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
const validOps: ArrayOperators[] = ["in", "notIn", "isNull", "isNotNull"];
|
|
98
|
+
return validOps.includes(operator as ArrayOperators);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// For unknown types, allow all operators
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getValidOperatorsForType(value: unknown): FilterOperator[] {
|
|
106
|
+
if (value === null || value === undefined) {
|
|
107
|
+
return ["isNull", "isNotNull", "equals", "notEquals"];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const valueType = typeof value;
|
|
111
|
+
|
|
112
|
+
if (valueType === "string" && Number.isNaN(Number(value))) {
|
|
113
|
+
return [
|
|
114
|
+
"equals",
|
|
115
|
+
"notEquals",
|
|
116
|
+
"contains",
|
|
117
|
+
"startsWith",
|
|
118
|
+
"endsWith",
|
|
119
|
+
"in",
|
|
120
|
+
"notIn",
|
|
121
|
+
"isNull",
|
|
122
|
+
"isNotNull",
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (valueType === "number") {
|
|
127
|
+
return [
|
|
128
|
+
"equals",
|
|
129
|
+
"notEquals",
|
|
130
|
+
"greaterThan",
|
|
131
|
+
"greaterThanOrEqual",
|
|
132
|
+
"lessThan",
|
|
133
|
+
"lessThanOrEqual",
|
|
134
|
+
"in",
|
|
135
|
+
"notIn",
|
|
136
|
+
"between",
|
|
137
|
+
"isNull",
|
|
138
|
+
"isNotNull",
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (valueType === "boolean") {
|
|
143
|
+
return ["equals", "notEquals", "isNull", "isNotNull"];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (value instanceof Date) {
|
|
147
|
+
return [
|
|
148
|
+
"equals",
|
|
149
|
+
"notEquals",
|
|
150
|
+
"greaterThan",
|
|
151
|
+
"greaterThanOrEqual",
|
|
152
|
+
"lessThan",
|
|
153
|
+
"lessThanOrEqual",
|
|
154
|
+
"in",
|
|
155
|
+
"notIn",
|
|
156
|
+
"between",
|
|
157
|
+
"isNull",
|
|
158
|
+
"isNotNull",
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
return ["in", "notIn", "isNull", "isNotNull"];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return [...FILTER_OPERATORS];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function isOperator(value: string): value is FilterOperator {
|
|
170
|
+
return FILTER_OPERATORS.includes(value as FilterOperator);
|
|
171
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function parseQueryValue(value: string): any {
|
|
2
|
+
if (!isNaN(Number(value))) return Number(value); // number
|
|
3
|
+
if (value === "true" || value === "false") return value === "true"; // boolean
|
|
4
|
+
if (!isNaN(Date.parse(value))) return new Date(value); // Date
|
|
5
|
+
return value; // string
|
|
6
|
+
}
|
package/src/validation-error.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// ============================================================================
|
|
2
|
-
// Validation Error - Domain Validation Errors
|
|
3
|
-
// ============================================================================
|
|
4
|
-
|
|
5
1
|
export interface ValidationIssue {
|
|
6
2
|
path: string[];
|
|
7
3
|
message: string;
|
package/src/value-object.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// ============================================================================
|
|
2
|
-
// Value Object - Immutable Domain Objects
|
|
3
|
-
// ============================================================================
|
|
4
|
-
|
|
5
1
|
import { ValidationError } from "./validation-error";
|
|
6
2
|
import { IDomainEvent } from ".";
|
|
7
3
|
import {
|
|
@@ -21,6 +17,11 @@ function getStaticProperty<T>(
|
|
|
21
17
|
return instance.constructor[propertyName];
|
|
22
18
|
}
|
|
23
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Identity key type for a Value Object. Can be a single key or an array of keys (composite key).
|
|
22
|
+
*/
|
|
23
|
+
export type IdentityKeyDefinition<T> = (keyof T)[] | keyof T;
|
|
24
|
+
|
|
24
25
|
export abstract class ValueObject<T> {
|
|
25
26
|
protected readonly props!: T;
|
|
26
27
|
private validationConfig: Required<ValidationConfig>;
|
|
@@ -28,12 +29,29 @@ export abstract class ValueObject<T> {
|
|
|
28
29
|
private domainSchema?: StandardSchema<T>;
|
|
29
30
|
private domainEvents: IDomainEvent[] = [];
|
|
30
31
|
|
|
31
|
-
// Static properties that subclasses can override
|
|
32
32
|
protected static validation?: EntityValidation<any>;
|
|
33
33
|
protected static hooks?: VOHooks<any, any>;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Identity key for identification in collections.
|
|
37
|
+
* Used by HistoryTracker to track changes in arrays of Value Objects.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* // Simple key
|
|
42
|
+
* class TagReference extends ValueObject<{ tagId: string }> {
|
|
43
|
+
* static readonly identityKey = 'tagId';
|
|
44
|
+
* }
|
|
45
|
+
*
|
|
46
|
+
* // Composite key
|
|
47
|
+
* class Like extends ValueObject<{ postId: string; userId: string }> {
|
|
48
|
+
* static readonly identityKey = ['postId', 'userId'];
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
protected static identityKey?: IdentityKeyDefinition<any>;
|
|
53
|
+
|
|
35
54
|
constructor(props: T) {
|
|
36
|
-
// Get static configuration from subclass
|
|
37
55
|
const validation = getStaticProperty<EntityValidation<T>>(
|
|
38
56
|
this,
|
|
39
57
|
"validation"
|
|
@@ -53,23 +71,18 @@ export abstract class ValueObject<T> {
|
|
|
53
71
|
|
|
54
72
|
let finalProps = { ...props } as T;
|
|
55
73
|
|
|
56
|
-
// Validate schema on creation
|
|
57
74
|
if (this.domainSchema && this.validationConfig.onCreate) {
|
|
58
75
|
this.validateProps(finalProps);
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
// Set props (not frozen yet) so rules can access them
|
|
62
78
|
(this as any).props = finalProps;
|
|
63
79
|
|
|
64
|
-
// Execute rules (custom validations) - after props is set but before freezing
|
|
65
80
|
if (hooks?.rules) {
|
|
66
81
|
hooks.rules(this as any);
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
// Now freeze the props for immutability
|
|
70
84
|
Object.freeze(this.props);
|
|
71
85
|
|
|
72
|
-
// Hook onCreate
|
|
73
86
|
if (hooks?.onCreate) {
|
|
74
87
|
hooks.onCreate(this as any);
|
|
75
88
|
}
|
|
@@ -98,7 +111,6 @@ export abstract class ValueObject<T> {
|
|
|
98
111
|
throw validationError;
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
// If not throwing, store error for later retrieval
|
|
102
114
|
(this as any)._validationError = validationError;
|
|
103
115
|
}
|
|
104
116
|
}
|
|
@@ -107,63 +119,112 @@ export abstract class ValueObject<T> {
|
|
|
107
119
|
if (pathSegment === null || pathSegment === undefined) {
|
|
108
120
|
return "";
|
|
109
121
|
}
|
|
110
|
-
// Handle PropertyKey (string | number | symbol)
|
|
111
122
|
if (typeof pathSegment === "string" || typeof pathSegment === "number") {
|
|
112
123
|
return String(pathSegment);
|
|
113
124
|
}
|
|
114
125
|
if (typeof pathSegment === "symbol") {
|
|
115
126
|
return pathSegment.toString();
|
|
116
127
|
}
|
|
117
|
-
// Handle object with 'key' property (Zod's PathSegment)
|
|
118
128
|
if (typeof pathSegment === "object" && "key" in pathSegment) {
|
|
119
129
|
return String((pathSegment as { key: unknown }).key);
|
|
120
130
|
}
|
|
121
|
-
// Fallback
|
|
122
131
|
return String(pathSegment);
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
/**
|
|
126
|
-
*
|
|
135
|
+
* Returns true if the value object has validation errors (when throwOnError is false).
|
|
127
136
|
*/
|
|
128
137
|
get hasValidationErrors(): boolean {
|
|
129
138
|
return !!(this as any)._validationError;
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/**
|
|
133
|
-
*
|
|
142
|
+
* Returns the validation errors (when throwOnError is false).
|
|
134
143
|
*/
|
|
135
144
|
get validationErrors(): ValidationError | undefined {
|
|
136
145
|
return (this as any)._validationError;
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Compare this ValueObject with another for equality based on their properties.
|
|
150
|
+
*/
|
|
139
151
|
equals(other: ValueObject<T>): boolean {
|
|
140
152
|
if (!other || !(other instanceof ValueObject)) return false;
|
|
141
153
|
return JSON.stringify(this.props) === JSON.stringify(other.props);
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
/**
|
|
145
|
-
*
|
|
157
|
+
* Returns the identity key for this Value Object.
|
|
158
|
+
* Used for identification in collections when identityKey is set.
|
|
159
|
+
*
|
|
160
|
+
* @returns String with the identity key or null if not defined
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* const like = new Like({ postId: 'p1', userId: 'u1' });
|
|
165
|
+
* like.getIdentityKey(); // 'p1:u1'
|
|
166
|
+
*
|
|
167
|
+
* const tag = new TagReference({ tagId: 'tag-123' });
|
|
168
|
+
* tag.getIdentityKey(); // 'tag-123'
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
getIdentityKey(): string | null {
|
|
172
|
+
const keyDef = getStaticProperty<IdentityKeyDefinition<T>>(
|
|
173
|
+
this,
|
|
174
|
+
"identityKey"
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (!keyDef) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(keyDef)) {
|
|
182
|
+
return keyDef.map((k) => String(this.props[k])).join(":");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return String(this.props[keyDef]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Returns true if this Value Object has an identity key defined.
|
|
190
|
+
*/
|
|
191
|
+
hasIdentityKey(): boolean {
|
|
192
|
+
return (
|
|
193
|
+
getStaticProperty<IdentityKeyDefinition<T>>(this, "identityKey") !==
|
|
194
|
+
undefined
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns the identity key definition (if any).
|
|
200
|
+
*/
|
|
201
|
+
static getIdentityKeyDefinition<P>(): IdentityKeyDefinition<P> | undefined {
|
|
202
|
+
return (this as any).identityKey;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Adds a domain event to this value object.
|
|
146
207
|
*/
|
|
147
208
|
protected addDomainEvent(event: IDomainEvent): void {
|
|
148
209
|
this.domainEvents.push(event);
|
|
149
210
|
}
|
|
150
211
|
|
|
151
212
|
/**
|
|
152
|
-
*
|
|
213
|
+
* Returns all uncommitted domain events.
|
|
153
214
|
*/
|
|
154
215
|
getUncommittedEvents(): IDomainEvent[] {
|
|
155
216
|
return [...this.domainEvents];
|
|
156
217
|
}
|
|
157
218
|
|
|
158
219
|
/**
|
|
159
|
-
*
|
|
220
|
+
* Clears all domain events (call after publishing).
|
|
160
221
|
*/
|
|
161
222
|
clearEvents(): void {
|
|
162
223
|
this.domainEvents = [];
|
|
163
224
|
}
|
|
164
225
|
|
|
165
226
|
/**
|
|
166
|
-
*
|
|
227
|
+
* Returns true if the value object has uncommitted events.
|
|
167
228
|
*/
|
|
168
229
|
hasUncommittedEvents(): boolean {
|
|
169
230
|
return this.domainEvents.length > 0;
|
|
@@ -174,8 +235,8 @@ export abstract class ValueObject<T> {
|
|
|
174
235
|
}
|
|
175
236
|
|
|
176
237
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
238
|
+
* Creates a new ValueObject with updated properties.
|
|
239
|
+
* ValueObjects are immutable, so this returns a new instance.
|
|
179
240
|
*/
|
|
180
241
|
protected clone(updates: Partial<T>): this {
|
|
181
242
|
const Constructor = this.constructor as new (props: T) => this;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Tests: AggregateChanges
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { AggregateChanges } from "../src/aggregate-changes";
|
|
6
|
+
|
|
7
|
+
describe("AggregateChanges", () => {
|
|
8
|
+
let changes: AggregateChanges;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
changes = new AggregateChanges();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("isEmpty / hasChanges", () => {
|
|
15
|
+
it("should be empty initially", () => {
|
|
16
|
+
expect(changes.isEmpty()).toBe(true);
|
|
17
|
+
expect(changes.hasChanges()).toBe(false);
|
|
18
|
+
expect(changes.count).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should not be empty after adding operation", () => {
|
|
22
|
+
changes.addCreate("User", { id: "1", name: "Test" }, 0);
|
|
23
|
+
expect(changes.isEmpty()).toBe(false);
|
|
24
|
+
expect(changes.hasChanges()).toBe(true);
|
|
25
|
+
expect(changes.count).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("addCreate", () => {
|
|
30
|
+
it("should add create operation", () => {
|
|
31
|
+
const data = { id: "1", name: "Test User" };
|
|
32
|
+
changes.addCreate("User", data, 0);
|
|
33
|
+
|
|
34
|
+
expect(changes.hasCreates()).toBe(true);
|
|
35
|
+
expect(changes.creates()).toHaveLength(1);
|
|
36
|
+
expect(changes.creates()[0]).toMatchObject({
|
|
37
|
+
type: "create",
|
|
38
|
+
entity: "User",
|
|
39
|
+
data,
|
|
40
|
+
depth: 0,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should add create with parent info", () => {
|
|
45
|
+
const data = { id: "post-1", title: "Test Post" };
|
|
46
|
+
changes.addCreate("Post", data, 1, "user-1", "User");
|
|
47
|
+
|
|
48
|
+
const create = changes.creates()[0];
|
|
49
|
+
expect(create.parentId).toBe("user-1");
|
|
50
|
+
expect(create.parentEntity).toBe("User");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("addUpdate", () => {
|
|
55
|
+
it("should add update operation", () => {
|
|
56
|
+
const data = { id: "1", name: "Updated" };
|
|
57
|
+
const changedFields = { name: "Updated" };
|
|
58
|
+
changes.addUpdate("User", "1", data, changedFields, 0);
|
|
59
|
+
|
|
60
|
+
expect(changes.hasUpdates()).toBe(true);
|
|
61
|
+
expect(changes.updates()).toHaveLength(1);
|
|
62
|
+
expect(changes.updates()[0]).toMatchObject({
|
|
63
|
+
type: "update",
|
|
64
|
+
entity: "User",
|
|
65
|
+
id: "1",
|
|
66
|
+
changedFields,
|
|
67
|
+
depth: 0,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("addDelete", () => {
|
|
73
|
+
it("should add delete operation", () => {
|
|
74
|
+
const data = { id: "1", name: "To Delete" };
|
|
75
|
+
changes.addDelete("User", "1", data, 0);
|
|
76
|
+
|
|
77
|
+
expect(changes.hasDeletes()).toBe(true);
|
|
78
|
+
expect(changes.deletes()).toHaveLength(1);
|
|
79
|
+
expect(changes.deletes()[0]).toMatchObject({
|
|
80
|
+
type: "delete",
|
|
81
|
+
entity: "User",
|
|
82
|
+
id: "1",
|
|
83
|
+
depth: 0,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("ordering", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
// Add operations in random order
|
|
91
|
+
changes.addCreate("Comment", { id: "c1" }, 2);
|
|
92
|
+
changes.addDelete("Like", "l1", { id: "l1" }, 3);
|
|
93
|
+
changes.addCreate("User", { id: "u1" }, 0);
|
|
94
|
+
changes.addDelete("Comment", "c2", { id: "c2" }, 2);
|
|
95
|
+
changes.addCreate("Post", { id: "p1" }, 1);
|
|
96
|
+
changes.addDelete("Post", "p2", { id: "p2" }, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should order creates by depth ASC (root → leaf)", () => {
|
|
100
|
+
const creates = changes.creates();
|
|
101
|
+
expect(creates[0].entity).toBe("User"); // depth 0
|
|
102
|
+
expect(creates[1].entity).toBe("Post"); // depth 1
|
|
103
|
+
expect(creates[2].entity).toBe("Comment"); // depth 2
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should order deletes by depth DESC (leaf → root)", () => {
|
|
107
|
+
const deletes = changes.deletes();
|
|
108
|
+
expect(deletes[0].entity).toBe("Like"); // depth 3
|
|
109
|
+
expect(deletes[1].entity).toBe("Comment"); // depth 2
|
|
110
|
+
expect(deletes[2].entity).toBe("Post"); // depth 1
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("operations iterator", () => {
|
|
115
|
+
it("should yield operations in correct order: deletes, creates, updates", () => {
|
|
116
|
+
changes.addCreate("Post", { id: "p1" }, 1);
|
|
117
|
+
changes.addUpdate("User", "u1", { id: "u1" }, { name: "New" }, 0);
|
|
118
|
+
changes.addDelete("Comment", "c1", { id: "c1" }, 2);
|
|
119
|
+
|
|
120
|
+
const ops = [...changes.operations()];
|
|
121
|
+
|
|
122
|
+
expect(ops[0].type).toBe("delete"); // deletes first
|
|
123
|
+
expect(ops[1].type).toBe("create"); // creates second
|
|
124
|
+
expect(ops[2].type).toBe("update"); // updates last
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("toBatchOperations", () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
// Creates
|
|
131
|
+
changes.addCreate("User", { id: "u1" }, 0);
|
|
132
|
+
changes.addCreate("Post", { id: "p1" }, 1, "u1", "User");
|
|
133
|
+
changes.addCreate("Post", { id: "p2" }, 1, "u1", "User");
|
|
134
|
+
changes.addCreate("Comment", { id: "c1" }, 2, "p1", "Post");
|
|
135
|
+
|
|
136
|
+
// Updates
|
|
137
|
+
changes.addUpdate("User", "u2", { id: "u2" }, { name: "Updated" }, 0);
|
|
138
|
+
changes.addUpdate("Post", "p3", { id: "p3" }, { title: "New Title" }, 1);
|
|
139
|
+
|
|
140
|
+
// Deletes
|
|
141
|
+
changes.addDelete("Comment", "c2", { id: "c2" }, 2);
|
|
142
|
+
changes.addDelete("Comment", "c3", { id: "c3" }, 2);
|
|
143
|
+
changes.addDelete("Post", "p4", { id: "p4" }, 1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should group deletes by entity and order by depth DESC", () => {
|
|
147
|
+
const batch = changes.toBatchOperations();
|
|
148
|
+
|
|
149
|
+
expect(batch.deletes).toHaveLength(2); // Comment and Post
|
|
150
|
+
|
|
151
|
+
// Comments should come first (depth 2)
|
|
152
|
+
expect(batch.deletes[0].entity).toBe("Comment");
|
|
153
|
+
expect(batch.deletes[0].ids).toEqual(["c2", "c3"]);
|
|
154
|
+
|
|
155
|
+
// Posts second (depth 1)
|
|
156
|
+
expect(batch.deletes[1].entity).toBe("Post");
|
|
157
|
+
expect(batch.deletes[1].ids).toEqual(["p4"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should group creates by entity and order by depth ASC", () => {
|
|
161
|
+
const batch = changes.toBatchOperations();
|
|
162
|
+
|
|
163
|
+
expect(batch.creates).toHaveLength(3); // User, Post, Comment
|
|
164
|
+
|
|
165
|
+
// User first (depth 0)
|
|
166
|
+
expect(batch.creates[0].entity).toBe("User");
|
|
167
|
+
expect(batch.creates[0].items).toHaveLength(1);
|
|
168
|
+
|
|
169
|
+
// Post second (depth 1)
|
|
170
|
+
expect(batch.creates[1].entity).toBe("Post");
|
|
171
|
+
expect(batch.creates[1].items).toHaveLength(2);
|
|
172
|
+
|
|
173
|
+
// Comment last (depth 2)
|
|
174
|
+
expect(batch.creates[2].entity).toBe("Comment");
|
|
175
|
+
expect(batch.creates[2].items).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should group updates by entity", () => {
|
|
179
|
+
const batch = changes.toBatchOperations();
|
|
180
|
+
|
|
181
|
+
expect(batch.updates).toHaveLength(2); // User and Post
|
|
182
|
+
|
|
183
|
+
const userUpdates = batch.updates.find((u) => u.entity === "User");
|
|
184
|
+
expect(userUpdates?.items).toHaveLength(1);
|
|
185
|
+
expect(userUpdates?.items[0].id).toBe("u2");
|
|
186
|
+
|
|
187
|
+
const postUpdates = batch.updates.find((u) => u.entity === "Post");
|
|
188
|
+
expect(postUpdates?.items).toHaveLength(1);
|
|
189
|
+
expect(postUpdates?.items[0].id).toBe("p3");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should include parentId in create items", () => {
|
|
193
|
+
const batch = changes.toBatchOperations();
|
|
194
|
+
|
|
195
|
+
const postCreates = batch.creates.find((c) => c.entity === "Post");
|
|
196
|
+
expect(postCreates?.items[0].parentId).toBe("u1");
|
|
197
|
+
expect(postCreates?.items[1].parentId).toBe("u1");
|
|
198
|
+
|
|
199
|
+
const commentCreates = batch.creates.find((c) => c.entity === "Comment");
|
|
200
|
+
expect(commentCreates?.items[0].parentId).toBe("p1");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("for (filter by entity)", () => {
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
changes.addCreate("Post", { id: "p1", title: "Post 1" }, 1);
|
|
207
|
+
changes.addCreate("Post", { id: "p2", title: "Post 2" }, 1);
|
|
208
|
+
changes.addUpdate("Post", "p3", { id: "p3" }, { title: "Updated" }, 1);
|
|
209
|
+
changes.addDelete("Post", "p4", { id: "p4" }, 1);
|
|
210
|
+
changes.addCreate("Comment", { id: "c1" }, 2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should filter creates by entity", () => {
|
|
214
|
+
const postChanges = changes.for("Post");
|
|
215
|
+
expect(postChanges.creates).toHaveLength(2);
|
|
216
|
+
expect(postChanges.creates[0].id).toBe("p1");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should filter updates by entity", () => {
|
|
220
|
+
const postChanges = changes.for("Post");
|
|
221
|
+
expect(postChanges.updates).toHaveLength(1);
|
|
222
|
+
expect(postChanges.updates[0].entity.id).toBe("p3");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should filter deletes by entity", () => {
|
|
226
|
+
const postChanges = changes.for("Post");
|
|
227
|
+
expect(postChanges.deletes).toHaveLength(1);
|
|
228
|
+
expect(postChanges.deletes[0].id).toBe("p4");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should return empty for non-existent entity", () => {
|
|
232
|
+
const userChanges = changes.for("User");
|
|
233
|
+
expect(userChanges.isEmpty()).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should have helper methods", () => {
|
|
237
|
+
const postChanges = changes.for("Post");
|
|
238
|
+
expect(postChanges.hasCreates()).toBe(true);
|
|
239
|
+
expect(postChanges.hasUpdates()).toBe(true);
|
|
240
|
+
expect(postChanges.hasDeletes()).toBe(true);
|
|
241
|
+
expect(postChanges.hasChanges()).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("getAffectedEntities", () => {
|
|
246
|
+
it("should return list of unique entities", () => {
|
|
247
|
+
changes.addCreate("User", { id: "u1" }, 0);
|
|
248
|
+
changes.addCreate("Post", { id: "p1" }, 1);
|
|
249
|
+
changes.addUpdate("Post", "p2", { id: "p2" }, {}, 1);
|
|
250
|
+
changes.addDelete("Comment", "c1", { id: "c1" }, 2);
|
|
251
|
+
|
|
252
|
+
const entities = changes.getAffectedEntities();
|
|
253
|
+
|
|
254
|
+
expect(entities).toContain("User");
|
|
255
|
+
expect(entities).toContain("Post");
|
|
256
|
+
expect(entities).toContain("Comment");
|
|
257
|
+
expect(entities).toHaveLength(3);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("clone", () => {
|
|
262
|
+
it("should create independent copy", () => {
|
|
263
|
+
changes.addCreate("User", { id: "u1" }, 0);
|
|
264
|
+
|
|
265
|
+
const cloned = changes.clone();
|
|
266
|
+
cloned.addCreate("Post", { id: "p1" }, 1);
|
|
267
|
+
|
|
268
|
+
expect(changes.count).toBe(1);
|
|
269
|
+
expect(cloned.count).toBe(2);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("clear", () => {
|
|
274
|
+
it("should remove all operations", () => {
|
|
275
|
+
changes.addCreate("User", { id: "u1" }, 0);
|
|
276
|
+
changes.addUpdate("Post", "p1", { id: "p1" }, {}, 1);
|
|
277
|
+
|
|
278
|
+
changes.clear();
|
|
279
|
+
|
|
280
|
+
expect(changes.isEmpty()).toBe(true);
|
|
281
|
+
expect(changes.count).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|