@woltz/rich-domain 0.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/.github/workflows/ci.yml +40 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.versionrc.json +21 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +712 -0
- package/commitlint.config.js +23 -0
- package/dist/base-entity.d.ts +67 -0
- package/dist/base-entity.d.ts.map +1 -0
- package/dist/base-entity.js +309 -0
- package/dist/base-entity.js.map +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/criteria.d.ts +60 -0
- package/dist/criteria.d.ts.map +1 -0
- package/dist/criteria.js +214 -0
- package/dist/criteria.js.map +1 -0
- package/dist/deep-proxy.d.ts +34 -0
- package/dist/deep-proxy.d.ts.map +1 -0
- package/dist/deep-proxy.js +297 -0
- package/dist/deep-proxy.js.map +1 -0
- package/dist/domain-event-bus.d.ts +57 -0
- package/dist/domain-event-bus.d.ts.map +1 -0
- package/dist/domain-event-bus.js +112 -0
- package/dist/domain-event-bus.js.map +1 -0
- package/dist/domain-event.d.ts +55 -0
- package/dist/domain-event.d.ts.map +1 -0
- package/dist/domain-event.js +42 -0
- package/dist/domain-event.js.map +1 -0
- package/dist/entity.d.ts +13 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +15 -0
- package/dist/entity.js.map +1 -0
- package/dist/filtering.d.ts +107 -0
- package/dist/filtering.d.ts.map +1 -0
- package/dist/filtering.js +202 -0
- package/dist/filtering.js.map +1 -0
- package/dist/id.d.ts +51 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +84 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/ordering.d.ts +93 -0
- package/dist/ordering.d.ts.map +1 -0
- package/dist/ordering.js +154 -0
- package/dist/ordering.js.map +1 -0
- package/dist/paginated-result.d.ts +62 -0
- package/dist/paginated-result.d.ts.map +1 -0
- package/dist/paginated-result.js +201 -0
- package/dist/paginated-result.js.map +1 -0
- package/dist/pagination.d.ts +218 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +281 -0
- package/dist/pagination.js.map +1 -0
- package/dist/repository/base-repository.d.ts +77 -0
- package/dist/repository/base-repository.d.ts.map +1 -0
- package/dist/repository/base-repository.js +80 -0
- package/dist/repository/base-repository.js.map +1 -0
- package/dist/repository/in-memory-repository.d.ts +46 -0
- package/dist/repository/in-memory-repository.d.ts.map +1 -0
- package/dist/repository/in-memory-repository.js +85 -0
- package/dist/repository/in-memory-repository.js.map +1 -0
- package/dist/repository/index.d.ts +42 -0
- package/dist/repository/index.d.ts.map +1 -0
- package/dist/repository/index.js +47 -0
- package/dist/repository/index.js.map +1 -0
- package/dist/repository/mapper.d.ts +56 -0
- package/dist/repository/mapper.d.ts.map +1 -0
- package/dist/repository/mapper.js +15 -0
- package/dist/repository/mapper.js.map +1 -0
- package/dist/repository/types.d.ts +87 -0
- package/dist/repository/types.d.ts.map +1 -0
- package/dist/repository/types.js +6 -0
- package/dist/repository/types.js.map +1 -0
- package/dist/repository/unit-of-work.d.ts +70 -0
- package/dist/repository/unit-of-work.d.ts.map +1 -0
- package/dist/repository/unit-of-work.js +122 -0
- package/dist/repository/unit-of-work.js.map +1 -0
- package/dist/repository.d.ts +2 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +21 -0
- package/dist/repository.js.map +1 -0
- package/dist/specification.d.ts +102 -0
- package/dist/specification.d.ts.map +1 -0
- package/dist/specification.js +187 -0
- package/dist/specification.js.map +1 -0
- package/dist/types/criteria.d.ts +35 -0
- package/dist/types/criteria.d.ts.map +1 -0
- package/dist/types/criteria.js +17 -0
- package/dist/types/criteria.js.map +1 -0
- package/dist/types/domain.d.ts +30 -0
- package/dist/types/domain.d.ts.map +1 -0
- package/dist/types/domain.js +2 -0
- package/dist/types/domain.js.map +1 -0
- package/dist/types/history-tracker.d.ts +36 -0
- package/dist/types/history-tracker.d.ts.map +1 -0
- package/dist/types/history-tracker.js +2 -0
- package/dist/types/history-tracker.js.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/repository.d.ts +43 -0
- package/dist/types/repository.d.ts.map +1 -0
- package/dist/types/repository.js +2 -0
- package/dist/types/repository.js.map +1 -0
- package/dist/types/standard-schema.d.ts +15 -0
- package/dist/types/standard-schema.d.ts.map +1 -0
- package/dist/types/standard-schema.js +2 -0
- package/dist/types/standard-schema.js.map +1 -0
- package/dist/types/unit-of-work.d.ts +39 -0
- package/dist/types/unit-of-work.d.ts.map +1 -0
- package/dist/types/unit-of-work.js +2 -0
- package/dist/types/unit-of-work.js.map +1 -0
- package/dist/types/utils.d.ts +14 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/dist/types/utils.js +2 -0
- package/dist/types/utils.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/validation-error.d.ts +42 -0
- package/dist/validation-error.d.ts.map +1 -0
- package/dist/validation-error.js +73 -0
- package/dist/validation-error.js.map +1 -0
- package/dist/value-object.d.ts +47 -0
- package/dist/value-object.d.ts.map +1 -0
- package/dist/value-object.js +136 -0
- package/dist/value-object.js.map +1 -0
- package/eslint.config.js +51 -0
- package/jest.config.js +21 -0
- package/package.json +58 -0
- package/src/base-entity.ts +401 -0
- package/src/constants.ts +7 -0
- package/src/criteria.ts +291 -0
- package/src/deep-proxy.ts +339 -0
- package/src/domain-event-bus.ts +166 -0
- package/src/domain-event.ts +90 -0
- package/src/entity.ts +16 -0
- package/src/id.ts +94 -0
- package/src/index.ts +33 -0
- package/src/paginated-result.ts +274 -0
- package/src/repository/base-repository.ts +152 -0
- package/src/repository/in-memory-repository.ts +104 -0
- package/src/repository/index.ts +55 -0
- package/src/repository/mapper.ts +74 -0
- package/src/repository/unit-of-work.ts +148 -0
- package/src/types/criteria.ts +79 -0
- package/src/types/domain.ts +37 -0
- package/src/types/history-tracker.ts +45 -0
- package/src/types/index.ts +7 -0
- package/src/types/repository.ts +51 -0
- package/src/types/standard-schema.ts +19 -0
- package/src/types/unit-of-work.ts +46 -0
- package/src/types/utils.ts +29 -0
- package/src/validation-error.ts +97 -0
- package/src/value-object.ts +187 -0
- package/tests/criteria.test.ts +432 -0
- package/tests/domain-events.test.ts +445 -0
- package/tests/entity-equality.test.ts +487 -0
- package/tests/entity-validation.test.ts +339 -0
- package/tests/entity.test.ts +33 -0
- package/tests/history-tracker.spec.ts +667 -0
- package/tests/id.test.ts +341 -0
- package/tests/repository.test.ts +641 -0
- package/tests/to-json.test.ts +91 -0
- package/tests/utils.ts +151 -0
- package/tests/value-object-validation.test.ts +228 -0
- package/tests/value-objects.test.ts +52 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Base Entity Class with Standard Schema Validation
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { Id } from "./id";
|
|
6
|
+
import { DeepProxy } from "./deep-proxy";
|
|
7
|
+
import { ValidationError } from "./validation-error";
|
|
8
|
+
import { IDomainEvent } from "./domain-event";
|
|
9
|
+
import {
|
|
10
|
+
BaseProps,
|
|
11
|
+
SubscriptionConfig,
|
|
12
|
+
HistoryEntry,
|
|
13
|
+
DeepJsonResult,
|
|
14
|
+
EntityHooks,
|
|
15
|
+
ValidationConfig,
|
|
16
|
+
StandardSchema,
|
|
17
|
+
EntityValidation,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import { DomainEventBus } from "./domain-event-bus";
|
|
20
|
+
import { DEFAULT_VALIDATION_CONFIG } from "./constants";
|
|
21
|
+
|
|
22
|
+
// Helper to get static properties from constructor
|
|
23
|
+
function getStaticProperty<T>(
|
|
24
|
+
instance: any,
|
|
25
|
+
propertyName: string
|
|
26
|
+
): T | undefined {
|
|
27
|
+
return instance.constructor[propertyName];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export abstract class BaseEntity<T extends BaseProps> {
|
|
31
|
+
private _props: T;
|
|
32
|
+
private proxy: DeepProxy;
|
|
33
|
+
private proxiedProps: T;
|
|
34
|
+
private snapshot: T | null = null;
|
|
35
|
+
private validationConfig: Required<ValidationConfig>;
|
|
36
|
+
private entityHooks?: EntityHooks<T, any>;
|
|
37
|
+
private entitySchema?: StandardSchema<T>;
|
|
38
|
+
private domainEvents: IDomainEvent[] = [];
|
|
39
|
+
|
|
40
|
+
// Static properties that subclasses can override
|
|
41
|
+
protected static validation?: EntityValidation<any>;
|
|
42
|
+
protected static hooks?: EntityHooks<any, any>;
|
|
43
|
+
|
|
44
|
+
constructor(props: Omit<T, "id"> & { id?: Id }) {
|
|
45
|
+
// Get static configuration from subclass
|
|
46
|
+
const validation = getStaticProperty<EntityValidation<T>>(
|
|
47
|
+
this,
|
|
48
|
+
"validation"
|
|
49
|
+
);
|
|
50
|
+
const hooks = getStaticProperty<EntityHooks<T, any>>(this, "hooks");
|
|
51
|
+
|
|
52
|
+
this.entityHooks = hooks;
|
|
53
|
+
|
|
54
|
+
if (validation?.schema) {
|
|
55
|
+
this.entitySchema = validation.schema;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.validationConfig = {
|
|
59
|
+
...DEFAULT_VALIDATION_CONFIG,
|
|
60
|
+
...validation?.config,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Apply defaultValues
|
|
64
|
+
let finalProps = { ...props } as T;
|
|
65
|
+
|
|
66
|
+
// Generate ID if not provided
|
|
67
|
+
if (!finalProps.id) {
|
|
68
|
+
finalProps.id = new Id();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate schema on creation
|
|
72
|
+
if (this.entitySchema && this.validationConfig.onCreate) {
|
|
73
|
+
this.validateProps(finalProps);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this._props = finalProps;
|
|
77
|
+
this.proxy = new DeepProxy(this._props);
|
|
78
|
+
this.proxiedProps = this.proxy.createProxy();
|
|
79
|
+
|
|
80
|
+
// Execute rules (custom validations)
|
|
81
|
+
if (hooks?.rules) {
|
|
82
|
+
hooks.rules(this as any);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hook onCreate
|
|
86
|
+
if (hooks?.onCreate) {
|
|
87
|
+
hooks.onCreate(this as any);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Setup update validation
|
|
91
|
+
if (this.entitySchema && this.validationConfig.onUpdate) {
|
|
92
|
+
this.setupUpdateValidation();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Take initial snapshot for onBeforeUpdate
|
|
96
|
+
this.takeSnapshot();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private validateProps(props: T): void {
|
|
100
|
+
if (!this.entitySchema) return;
|
|
101
|
+
|
|
102
|
+
const result = this.entitySchema["~standard"].validate(props);
|
|
103
|
+
|
|
104
|
+
if (result instanceof Promise) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Async validation not supported in constructor. Use sync validation schema."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (result.issues && result.issues.length > 0) {
|
|
111
|
+
const validationError = new ValidationError(
|
|
112
|
+
result.issues.map((issue) => ({
|
|
113
|
+
path: issue.path?.map((p) => this.extractPathKey(p)) || [],
|
|
114
|
+
message: issue.message,
|
|
115
|
+
}))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (this.validationConfig.throwOnError) {
|
|
119
|
+
throw validationError;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If not throwing, store error for later retrieval
|
|
123
|
+
(this as any)._validationError = validationError;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private extractPathKey(pathSegment: unknown): string {
|
|
128
|
+
if (pathSegment === null || pathSegment === undefined) {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
// Handle PropertyKey (string | number | symbol)
|
|
132
|
+
if (typeof pathSegment === "string" || typeof pathSegment === "number") {
|
|
133
|
+
return String(pathSegment);
|
|
134
|
+
}
|
|
135
|
+
if (typeof pathSegment === "symbol") {
|
|
136
|
+
return pathSegment.toString();
|
|
137
|
+
}
|
|
138
|
+
// Handle object with 'key' property (Zod's PathSegment)
|
|
139
|
+
if (typeof pathSegment === "object" && "key" in pathSegment) {
|
|
140
|
+
return String((pathSegment as { key: unknown }).key);
|
|
141
|
+
}
|
|
142
|
+
// Fallback
|
|
143
|
+
return String(pathSegment);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private setupUpdateValidation(): void {
|
|
147
|
+
const self = this;
|
|
148
|
+
|
|
149
|
+
const validateOnChange = () => {
|
|
150
|
+
// Check onBeforeUpdate hook
|
|
151
|
+
if (self.entityHooks?.onBeforeUpdate && self.snapshot) {
|
|
152
|
+
const shouldContinue = self.entityHooks.onBeforeUpdate(
|
|
153
|
+
self as any,
|
|
154
|
+
self.snapshot
|
|
155
|
+
);
|
|
156
|
+
if (!shouldContinue) {
|
|
157
|
+
// Revert changes
|
|
158
|
+
Object.assign(self._props, self.snapshot);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Validate with schema
|
|
164
|
+
if (self.entitySchema) {
|
|
165
|
+
const result = self.entitySchema["~standard"].validate(self._props);
|
|
166
|
+
|
|
167
|
+
if (result instanceof Promise) {
|
|
168
|
+
console.warn(
|
|
169
|
+
"Async validation on update not supported. Consider using sync validation."
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (result.issues && result.issues.length > 0) {
|
|
175
|
+
const validationError = new ValidationError(
|
|
176
|
+
result.issues.map((issue) => ({
|
|
177
|
+
path: issue.path?.map((p) => self.extractPathKey(p)) || [],
|
|
178
|
+
message: issue.message,
|
|
179
|
+
}))
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (self.validationConfig.throwOnError) {
|
|
183
|
+
// Revert changes before throwing
|
|
184
|
+
if (self.snapshot) {
|
|
185
|
+
Object.assign(self._props, self.snapshot);
|
|
186
|
+
}
|
|
187
|
+
throw validationError;
|
|
188
|
+
}
|
|
189
|
+
console.error("Validation failed on update:", validationError);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Execute rules after schema validation
|
|
194
|
+
if (self.entityHooks?.rules) {
|
|
195
|
+
try {
|
|
196
|
+
self.entityHooks.rules(self as any);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (self.validationConfig.throwOnError) {
|
|
199
|
+
// Revert changes before throwing
|
|
200
|
+
if (self.snapshot) {
|
|
201
|
+
Object.assign(self._props, self.snapshot);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
console.error("Rules validation failed on update:", error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Update snapshot after successful validation
|
|
210
|
+
self.takeSnapshot();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Subscribe to all changes
|
|
214
|
+
this.proxy.subscribe("*", validateOnChange);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private takeSnapshot(): void {
|
|
218
|
+
this.snapshot = this.deepCloneProps(this._props);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private deepCloneProps(obj: any, seen: WeakSet<object> = new WeakSet()): any {
|
|
222
|
+
if (obj === null || obj === undefined) return obj;
|
|
223
|
+
|
|
224
|
+
// Primitives
|
|
225
|
+
if (typeof obj !== "object") return obj;
|
|
226
|
+
|
|
227
|
+
// Special cases - don't clone these, just return the reference
|
|
228
|
+
if (obj instanceof Id) return obj;
|
|
229
|
+
if (obj instanceof Date) return new Date(obj.getTime());
|
|
230
|
+
|
|
231
|
+
// Check for circular references
|
|
232
|
+
if (seen.has(obj)) {
|
|
233
|
+
return obj; // Return reference to avoid infinite loop
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle BaseEntity instances - just keep the reference
|
|
237
|
+
if (obj instanceof BaseEntity) {
|
|
238
|
+
return obj;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Handle ValueObject instances - just keep the reference (they're immutable)
|
|
242
|
+
if (
|
|
243
|
+
obj.constructor &&
|
|
244
|
+
obj.constructor.name !== "Object" &&
|
|
245
|
+
obj.constructor.name !== "Array"
|
|
246
|
+
) {
|
|
247
|
+
// Check if it has toJson method (likely a ValueObject or similar)
|
|
248
|
+
if (
|
|
249
|
+
typeof obj.toJson === "function" &&
|
|
250
|
+
typeof obj.equals === "function"
|
|
251
|
+
) {
|
|
252
|
+
return obj; // Keep reference for ValueObjects
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
seen.add(obj);
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(obj)) {
|
|
259
|
+
return obj.map((item) => this.deepCloneProps(item, seen));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Plain objects only
|
|
263
|
+
if (obj.constructor === Object) {
|
|
264
|
+
const cloned: any = {};
|
|
265
|
+
for (const key in obj) {
|
|
266
|
+
if (obj.hasOwnProperty(key)) {
|
|
267
|
+
cloned[key] = this.deepCloneProps(obj[key], seen);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return cloned;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// For other object types (custom classes), just keep the reference
|
|
274
|
+
return obj;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get id(): Id {
|
|
278
|
+
return this._props.id;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
get isNew(): boolean {
|
|
282
|
+
return this._props.id.isNew;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check equality with another entity by comparing IDs
|
|
287
|
+
* Entities are equal if they have the same ID, regardless of other properties
|
|
288
|
+
*
|
|
289
|
+
* @param other - Another entity or an ID to compare with
|
|
290
|
+
* @returns true if entities have the same ID
|
|
291
|
+
*/
|
|
292
|
+
equals(other: BaseEntity<T> | Id | string): boolean {
|
|
293
|
+
if (!other) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Compare with another entity
|
|
298
|
+
if (other instanceof BaseEntity) {
|
|
299
|
+
return this.id.equals(other.id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Compare with an ID
|
|
303
|
+
if (other instanceof Id) {
|
|
304
|
+
return this.id.equals(other);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Compare with a string ID
|
|
308
|
+
if (typeof other === "string") {
|
|
309
|
+
return this.id.equals(other);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
public get props(): T {
|
|
316
|
+
return this.proxiedProps;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check if entity has validation errors (when throwOnError is false)
|
|
321
|
+
*/
|
|
322
|
+
get hasValidationErrors(): boolean {
|
|
323
|
+
return !!(this as any)._validationError;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get validation errors (when throwOnError is false)
|
|
328
|
+
*/
|
|
329
|
+
get validationErrors(): ValidationError | undefined {
|
|
330
|
+
return (this as any)._validationError;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
subscribe(config: SubscriptionConfig<T>): void {
|
|
334
|
+
Object.keys(config).forEach((key) => {
|
|
335
|
+
const sub = config[key as keyof T];
|
|
336
|
+
if (sub && "onChange" in sub) {
|
|
337
|
+
this.proxy.subscribe(key, sub.onChange);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getHistory(): HistoryEntry[] {
|
|
343
|
+
return this.proxy.getHistory();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
clearHistory(): void {
|
|
347
|
+
this.proxy.clearHistory();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Add a domain event to this entity
|
|
352
|
+
*/
|
|
353
|
+
protected addDomainEvent(event: IDomainEvent): void {
|
|
354
|
+
this.domainEvents.push(event);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
public async dispatchAll(bus: DomainEventBus) {
|
|
358
|
+
await bus.publishAll(this.getUncommittedEvents());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get all uncommitted domain events
|
|
363
|
+
*/
|
|
364
|
+
getUncommittedEvents(): IDomainEvent[] {
|
|
365
|
+
return [...this.domainEvents];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Clear all domain events (call after publishing)
|
|
370
|
+
*/
|
|
371
|
+
clearEvents(): void {
|
|
372
|
+
this.domainEvents = [];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Check if entity has uncommitted events
|
|
377
|
+
*/
|
|
378
|
+
hasUncommittedEvents(): boolean {
|
|
379
|
+
return this.domainEvents.length > 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
toJson(): DeepJsonResult<T> {
|
|
383
|
+
return this.deepToJson(this._props) as DeepJsonResult<T>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private deepToJson(obj: any): any {
|
|
387
|
+
if (obj === null || obj === undefined) return obj;
|
|
388
|
+
if (obj instanceof Id) return obj.value;
|
|
389
|
+
if (Array.isArray(obj)) return obj.map((item) => this.deepToJson(item));
|
|
390
|
+
if (obj instanceof BaseEntity) return obj.toJson();
|
|
391
|
+
if (obj && typeof obj.toJson === "function") return obj.toJson();
|
|
392
|
+
if (typeof obj === "object") {
|
|
393
|
+
const result: any = {};
|
|
394
|
+
for (const key in obj) {
|
|
395
|
+
if (obj.hasOwnProperty(key)) result[key] = this.deepToJson(obj[key]);
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
return obj;
|
|
400
|
+
}
|
|
401
|
+
}
|
package/src/constants.ts
ADDED
package/src/criteria.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FieldPath,
|
|
3
|
+
Filter,
|
|
4
|
+
FilterOperator,
|
|
5
|
+
FilterValueFor,
|
|
6
|
+
Order,
|
|
7
|
+
OrderDirection,
|
|
8
|
+
Pagination,
|
|
9
|
+
PathValue,
|
|
10
|
+
TypedFilter,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Filter Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export class Criteria<T = unknown> {
|
|
18
|
+
private _filters: Filter<FieldPath<T>, any>[] = [];
|
|
19
|
+
private _orders: Order[] = [];
|
|
20
|
+
private _pagination: Pagination = { page: 1, limit: 20, offset: 0 };
|
|
21
|
+
private _search?: {
|
|
22
|
+
fields: FieldPath<T>[];
|
|
23
|
+
value: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
private constructor() {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a new Criteria instance
|
|
30
|
+
*/
|
|
31
|
+
static create<T = unknown>(): Criteria<T> {
|
|
32
|
+
return new Criteria<T>();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add a filter condition
|
|
37
|
+
*/
|
|
38
|
+
where<K extends FieldPath<T>>(
|
|
39
|
+
field: K,
|
|
40
|
+
operator: FilterOperator,
|
|
41
|
+
value?: FilterValueFor<PathValue<T, K>>
|
|
42
|
+
): this {
|
|
43
|
+
this._filters.push({
|
|
44
|
+
field,
|
|
45
|
+
operator,
|
|
46
|
+
value,
|
|
47
|
+
});
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// === Shorthand methods (tipados) ===
|
|
52
|
+
|
|
53
|
+
whereEquals<K extends FieldPath<T>>(field: K, value: PathValue<T, K>): this {
|
|
54
|
+
return this.where(field, "equals", value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
whereContains<K extends FieldPath<T>>(
|
|
58
|
+
field: K,
|
|
59
|
+
value: PathValue<T, K>
|
|
60
|
+
): this {
|
|
61
|
+
return this.where(field, "contains", value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
whereIn<K extends FieldPath<T>>(field: K, values: PathValue<T, K>[]): this {
|
|
65
|
+
return this.where(field, "in", values);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
whereBetween<K extends FieldPath<T>>(
|
|
69
|
+
field: K,
|
|
70
|
+
min: PathValue<T, K>,
|
|
71
|
+
max: PathValue<T, K>
|
|
72
|
+
): this {
|
|
73
|
+
return this.where(field, "between", [min, max] as [
|
|
74
|
+
PathValue<T, K>,
|
|
75
|
+
PathValue<T, K>
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
whereNull<K extends FieldPath<T>>(field: K): this {
|
|
80
|
+
return this.where(field, "isNull");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
whereNotNull<K extends FieldPath<T>>(field: K): this {
|
|
84
|
+
return this.where(field, "isNotNull");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// === OrderBy ===
|
|
88
|
+
|
|
89
|
+
orderBy<K extends FieldPath<T>>(
|
|
90
|
+
field: K,
|
|
91
|
+
direction: OrderDirection = "asc"
|
|
92
|
+
): this {
|
|
93
|
+
this._orders.push({
|
|
94
|
+
field: String(field),
|
|
95
|
+
direction,
|
|
96
|
+
});
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
orderByAsc<K extends FieldPath<T>>(field: K): this {
|
|
101
|
+
return this.orderBy(field, "asc");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
orderByDesc<K extends FieldPath<T>>(field: K): this {
|
|
105
|
+
return this.orderBy(field, "desc");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --------------------------------------------------------------------------
|
|
109
|
+
// Search (tipado)
|
|
110
|
+
// --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
search<K extends FieldPath<T>>(fields: K[], value: string): this {
|
|
113
|
+
this._search = {
|
|
114
|
+
fields,
|
|
115
|
+
value,
|
|
116
|
+
};
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
hasSearch(): boolean {
|
|
121
|
+
return !!this._search;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getSearch() {
|
|
125
|
+
return this._search;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// === Pagination ===
|
|
129
|
+
|
|
130
|
+
paginate(page: number, limit: number): this {
|
|
131
|
+
if (page < 1) page = 1;
|
|
132
|
+
if (limit < 1) limit = 10;
|
|
133
|
+
|
|
134
|
+
this._pagination = {
|
|
135
|
+
page,
|
|
136
|
+
limit,
|
|
137
|
+
offset: (page - 1) * limit,
|
|
138
|
+
};
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
limit(limit: number): this {
|
|
143
|
+
return this.paginate(1, limit);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// === Getters ===
|
|
147
|
+
|
|
148
|
+
getFilters(): Filter[] {
|
|
149
|
+
return this._filters;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getOrders(): Order[] {
|
|
153
|
+
return this._orders;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getPagination(): Pagination {
|
|
157
|
+
return this._pagination;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
hasFilters(): boolean {
|
|
161
|
+
return this._filters.length > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
hasOrders(): boolean {
|
|
165
|
+
return this._orders.length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
hasPagination(): boolean {
|
|
169
|
+
return this._pagination !== undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// === Utilities ===
|
|
173
|
+
|
|
174
|
+
clone(): Criteria<T> {
|
|
175
|
+
const cloned = Criteria.create<T>();
|
|
176
|
+
cloned._filters = [...this._filters];
|
|
177
|
+
cloned._orders = [...this._orders];
|
|
178
|
+
cloned._pagination = { ...this._pagination };
|
|
179
|
+
return cloned;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
toJSON() {
|
|
183
|
+
return {
|
|
184
|
+
filters: this._filters,
|
|
185
|
+
orders: this._orders,
|
|
186
|
+
pagination: this._pagination,
|
|
187
|
+
search: this._search,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static fromObject<T>(obj: {
|
|
192
|
+
filters?: TypedFilter<T>[];
|
|
193
|
+
orders?: Order[];
|
|
194
|
+
pagination?: Pagination;
|
|
195
|
+
search?: { fields: FieldPath<T>[]; value: string };
|
|
196
|
+
}): Criteria<T> {
|
|
197
|
+
const criteria = Criteria.create<T>();
|
|
198
|
+
if (obj.filters) criteria._filters = [...obj.filters];
|
|
199
|
+
if (obj.orders) criteria._orders = [...obj.orders];
|
|
200
|
+
if (obj.pagination) criteria._pagination = { ...obj.pagination };
|
|
201
|
+
if (obj.search) criteria._search = { ...obj.search };
|
|
202
|
+
|
|
203
|
+
return criteria;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
static fromQueryParams<T>(query: Record<string, any>): Criteria<T> {
|
|
207
|
+
const criteria = Criteria.create<T>();
|
|
208
|
+
|
|
209
|
+
for (const [key, value] of Object.entries(query)) {
|
|
210
|
+
// Pagination
|
|
211
|
+
if (key === "page") {
|
|
212
|
+
continue; // We'll handle pagination after
|
|
213
|
+
}
|
|
214
|
+
if (key === "limit") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (key === "sort") {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const [field, operatorRaw] = key.split(":");
|
|
222
|
+
|
|
223
|
+
if (!operatorRaw || !field) continue;
|
|
224
|
+
|
|
225
|
+
const operator = isOperator(operatorRaw) ? operatorRaw : null;
|
|
226
|
+
if (!operator) throw new Error(`Invalid filter operator: ${operatorRaw}`);
|
|
227
|
+
|
|
228
|
+
let parsedValue: any = value;
|
|
229
|
+
|
|
230
|
+
if (operator === "between") {
|
|
231
|
+
parsedValue = value
|
|
232
|
+
.split(",")
|
|
233
|
+
.map((v: any) => parseQueryValue(v.trim()));
|
|
234
|
+
if (parsedValue.length === 2) {
|
|
235
|
+
criteria.whereBetween(field as any, parsedValue[0], parsedValue[1]);
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (operator === "in" || operator === "notIn") {
|
|
241
|
+
parsedValue = value.split(",").map(parseQueryValue);
|
|
242
|
+
criteria.where(field as any, operator, parsedValue);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
criteria.where(field as any, operator, parseQueryValue(value));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Pagination
|
|
250
|
+
const page = query.page ? parseInt(query.page) : undefined;
|
|
251
|
+
const limit = query.limit ? parseInt(query.limit) : undefined;
|
|
252
|
+
|
|
253
|
+
if (page && limit) {
|
|
254
|
+
criteria.paginate(page, limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Sorting
|
|
258
|
+
if (query.orderBy) {
|
|
259
|
+
const sortParts = query.orderBy.split(",");
|
|
260
|
+
sortParts.forEach((part: string) => {
|
|
261
|
+
const [field, direction] = part.split(":");
|
|
262
|
+
criteria.orderBy(field as any, (direction as OrderDirection) || "asc");
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (query.search && query.searchFields) {
|
|
267
|
+
const fields = (query.searchFields as string)
|
|
268
|
+
.split(",")
|
|
269
|
+
.filter(Boolean) as FieldPath<T>[];
|
|
270
|
+
|
|
271
|
+
criteria.search(fields, query.search as string);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return criteria;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ============================================================================
|
|
279
|
+
// Helper Functions
|
|
280
|
+
// ============================================================================
|
|
281
|
+
|
|
282
|
+
function parseQueryValue(value: string): any {
|
|
283
|
+
if (!isNaN(Number(value))) return Number(value); // number
|
|
284
|
+
if (value === "true" || value === "false") return value === "true"; // boolean
|
|
285
|
+
if (!isNaN(Date.parse(value))) return new Date(value); // Date
|
|
286
|
+
return value; // string
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isOperator(value: string): value is FilterOperator {
|
|
290
|
+
return FilterOperator.includes(value as FilterOperator);
|
|
291
|
+
}
|