@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.
Files changed (178) hide show
  1. package/.github/workflows/ci.yml +40 -0
  2. package/.husky/commit-msg +1 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.versionrc.json +21 -0
  5. package/.vscode/settings.json +3 -0
  6. package/CHANGELOG.md +81 -0
  7. package/LICENSE +21 -0
  8. package/README.md +712 -0
  9. package/commitlint.config.js +23 -0
  10. package/dist/base-entity.d.ts +67 -0
  11. package/dist/base-entity.d.ts.map +1 -0
  12. package/dist/base-entity.js +309 -0
  13. package/dist/base-entity.js.map +1 -0
  14. package/dist/constants.d.ts +3 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +6 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/criteria.d.ts +60 -0
  19. package/dist/criteria.d.ts.map +1 -0
  20. package/dist/criteria.js +214 -0
  21. package/dist/criteria.js.map +1 -0
  22. package/dist/deep-proxy.d.ts +34 -0
  23. package/dist/deep-proxy.d.ts.map +1 -0
  24. package/dist/deep-proxy.js +297 -0
  25. package/dist/deep-proxy.js.map +1 -0
  26. package/dist/domain-event-bus.d.ts +57 -0
  27. package/dist/domain-event-bus.d.ts.map +1 -0
  28. package/dist/domain-event-bus.js +112 -0
  29. package/dist/domain-event-bus.js.map +1 -0
  30. package/dist/domain-event.d.ts +55 -0
  31. package/dist/domain-event.d.ts.map +1 -0
  32. package/dist/domain-event.js +42 -0
  33. package/dist/domain-event.js.map +1 -0
  34. package/dist/entity.d.ts +13 -0
  35. package/dist/entity.d.ts.map +1 -0
  36. package/dist/entity.js +15 -0
  37. package/dist/entity.js.map +1 -0
  38. package/dist/filtering.d.ts +107 -0
  39. package/dist/filtering.d.ts.map +1 -0
  40. package/dist/filtering.js +202 -0
  41. package/dist/filtering.js.map +1 -0
  42. package/dist/id.d.ts +51 -0
  43. package/dist/id.d.ts.map +1 -0
  44. package/dist/id.js +84 -0
  45. package/dist/id.js.map +1 -0
  46. package/dist/index.d.ts +15 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +25 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/ordering.d.ts +93 -0
  51. package/dist/ordering.d.ts.map +1 -0
  52. package/dist/ordering.js +154 -0
  53. package/dist/ordering.js.map +1 -0
  54. package/dist/paginated-result.d.ts +62 -0
  55. package/dist/paginated-result.d.ts.map +1 -0
  56. package/dist/paginated-result.js +201 -0
  57. package/dist/paginated-result.js.map +1 -0
  58. package/dist/pagination.d.ts +218 -0
  59. package/dist/pagination.d.ts.map +1 -0
  60. package/dist/pagination.js +281 -0
  61. package/dist/pagination.js.map +1 -0
  62. package/dist/repository/base-repository.d.ts +77 -0
  63. package/dist/repository/base-repository.d.ts.map +1 -0
  64. package/dist/repository/base-repository.js +80 -0
  65. package/dist/repository/base-repository.js.map +1 -0
  66. package/dist/repository/in-memory-repository.d.ts +46 -0
  67. package/dist/repository/in-memory-repository.d.ts.map +1 -0
  68. package/dist/repository/in-memory-repository.js +85 -0
  69. package/dist/repository/in-memory-repository.js.map +1 -0
  70. package/dist/repository/index.d.ts +42 -0
  71. package/dist/repository/index.d.ts.map +1 -0
  72. package/dist/repository/index.js +47 -0
  73. package/dist/repository/index.js.map +1 -0
  74. package/dist/repository/mapper.d.ts +56 -0
  75. package/dist/repository/mapper.d.ts.map +1 -0
  76. package/dist/repository/mapper.js +15 -0
  77. package/dist/repository/mapper.js.map +1 -0
  78. package/dist/repository/types.d.ts +87 -0
  79. package/dist/repository/types.d.ts.map +1 -0
  80. package/dist/repository/types.js +6 -0
  81. package/dist/repository/types.js.map +1 -0
  82. package/dist/repository/unit-of-work.d.ts +70 -0
  83. package/dist/repository/unit-of-work.d.ts.map +1 -0
  84. package/dist/repository/unit-of-work.js +122 -0
  85. package/dist/repository/unit-of-work.js.map +1 -0
  86. package/dist/repository.d.ts +2 -0
  87. package/dist/repository.d.ts.map +1 -0
  88. package/dist/repository.js +21 -0
  89. package/dist/repository.js.map +1 -0
  90. package/dist/specification.d.ts +102 -0
  91. package/dist/specification.d.ts.map +1 -0
  92. package/dist/specification.js +187 -0
  93. package/dist/specification.js.map +1 -0
  94. package/dist/types/criteria.d.ts +35 -0
  95. package/dist/types/criteria.d.ts.map +1 -0
  96. package/dist/types/criteria.js +17 -0
  97. package/dist/types/criteria.js.map +1 -0
  98. package/dist/types/domain.d.ts +30 -0
  99. package/dist/types/domain.d.ts.map +1 -0
  100. package/dist/types/domain.js +2 -0
  101. package/dist/types/domain.js.map +1 -0
  102. package/dist/types/history-tracker.d.ts +36 -0
  103. package/dist/types/history-tracker.d.ts.map +1 -0
  104. package/dist/types/history-tracker.js +2 -0
  105. package/dist/types/history-tracker.js.map +1 -0
  106. package/dist/types/index.d.ts +8 -0
  107. package/dist/types/index.d.ts.map +1 -0
  108. package/dist/types/index.js +8 -0
  109. package/dist/types/index.js.map +1 -0
  110. package/dist/types/repository.d.ts +43 -0
  111. package/dist/types/repository.d.ts.map +1 -0
  112. package/dist/types/repository.js +2 -0
  113. package/dist/types/repository.js.map +1 -0
  114. package/dist/types/standard-schema.d.ts +15 -0
  115. package/dist/types/standard-schema.d.ts.map +1 -0
  116. package/dist/types/standard-schema.js +2 -0
  117. package/dist/types/standard-schema.js.map +1 -0
  118. package/dist/types/unit-of-work.d.ts +39 -0
  119. package/dist/types/unit-of-work.d.ts.map +1 -0
  120. package/dist/types/unit-of-work.js +2 -0
  121. package/dist/types/unit-of-work.js.map +1 -0
  122. package/dist/types/utils.d.ts +14 -0
  123. package/dist/types/utils.d.ts.map +1 -0
  124. package/dist/types/utils.js +2 -0
  125. package/dist/types/utils.js.map +1 -0
  126. package/dist/types.d.ts +88 -0
  127. package/dist/types.d.ts.map +1 -0
  128. package/dist/types.js +12 -0
  129. package/dist/types.js.map +1 -0
  130. package/dist/validation-error.d.ts +42 -0
  131. package/dist/validation-error.d.ts.map +1 -0
  132. package/dist/validation-error.js +73 -0
  133. package/dist/validation-error.js.map +1 -0
  134. package/dist/value-object.d.ts +47 -0
  135. package/dist/value-object.d.ts.map +1 -0
  136. package/dist/value-object.js +136 -0
  137. package/dist/value-object.js.map +1 -0
  138. package/eslint.config.js +51 -0
  139. package/jest.config.js +21 -0
  140. package/package.json +58 -0
  141. package/src/base-entity.ts +401 -0
  142. package/src/constants.ts +7 -0
  143. package/src/criteria.ts +291 -0
  144. package/src/deep-proxy.ts +339 -0
  145. package/src/domain-event-bus.ts +166 -0
  146. package/src/domain-event.ts +90 -0
  147. package/src/entity.ts +16 -0
  148. package/src/id.ts +94 -0
  149. package/src/index.ts +33 -0
  150. package/src/paginated-result.ts +274 -0
  151. package/src/repository/base-repository.ts +152 -0
  152. package/src/repository/in-memory-repository.ts +104 -0
  153. package/src/repository/index.ts +55 -0
  154. package/src/repository/mapper.ts +74 -0
  155. package/src/repository/unit-of-work.ts +148 -0
  156. package/src/types/criteria.ts +79 -0
  157. package/src/types/domain.ts +37 -0
  158. package/src/types/history-tracker.ts +45 -0
  159. package/src/types/index.ts +7 -0
  160. package/src/types/repository.ts +51 -0
  161. package/src/types/standard-schema.ts +19 -0
  162. package/src/types/unit-of-work.ts +46 -0
  163. package/src/types/utils.ts +29 -0
  164. package/src/validation-error.ts +97 -0
  165. package/src/value-object.ts +187 -0
  166. package/tests/criteria.test.ts +432 -0
  167. package/tests/domain-events.test.ts +445 -0
  168. package/tests/entity-equality.test.ts +487 -0
  169. package/tests/entity-validation.test.ts +339 -0
  170. package/tests/entity.test.ts +33 -0
  171. package/tests/history-tracker.spec.ts +667 -0
  172. package/tests/id.test.ts +341 -0
  173. package/tests/repository.test.ts +641 -0
  174. package/tests/to-json.test.ts +91 -0
  175. package/tests/utils.ts +151 -0
  176. package/tests/value-object-validation.test.ts +228 -0
  177. package/tests/value-objects.test.ts +52 -0
  178. 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
+ }
@@ -0,0 +1,7 @@
1
+ import { ValidationConfig } from ".";
2
+
3
+ export const DEFAULT_VALIDATION_CONFIG: Required<ValidationConfig> = {
4
+ onCreate: true,
5
+ onUpdate: true,
6
+ throwOnError: true,
7
+ };
@@ -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
+ }