@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,187 @@
1
+ // ============================================================================
2
+ // Value Object - Immutable Domain Objects
3
+ // ============================================================================
4
+
5
+ import { ValidationError } from "./validation-error";
6
+ import { IDomainEvent } from "./domain-event";
7
+ import {
8
+ VOHooks,
9
+ ValidationConfig,
10
+ StandardSchema,
11
+ EntityValidation,
12
+ } from "./types";
13
+ import { DEFAULT_VALIDATION_CONFIG } from "./constants";
14
+
15
+ // Helper to get static properties from constructor
16
+ function getStaticProperty<T>(
17
+ instance: any,
18
+ propertyName: string
19
+ ): T | undefined {
20
+ return instance.constructor[propertyName];
21
+ }
22
+
23
+ export abstract class ValueObject<T> {
24
+ protected readonly props!: T;
25
+ private validationConfig: Required<ValidationConfig>;
26
+ private domainHooks?: VOHooks<T, any>;
27
+ private domainSchema?: StandardSchema<T>;
28
+ private domainEvents: IDomainEvent[] = [];
29
+
30
+ // Static properties that subclasses can override
31
+ protected static validation?: EntityValidation<any>;
32
+ protected static hooks?: VOHooks<any, any>;
33
+
34
+ constructor(props: T) {
35
+ // Get static configuration from subclass
36
+ const validation = getStaticProperty<EntityValidation<T>>(
37
+ this,
38
+ "validation"
39
+ );
40
+ const hooks = getStaticProperty<VOHooks<T, any>>(this, "hooks");
41
+
42
+ this.domainHooks = hooks;
43
+
44
+ if (validation?.schema) {
45
+ this.domainSchema = validation.schema;
46
+ }
47
+
48
+ this.validationConfig = {
49
+ ...DEFAULT_VALIDATION_CONFIG,
50
+ ...validation?.config,
51
+ };
52
+
53
+ // Apply defaultValues
54
+ let finalProps = { ...props } as T;
55
+ if (hooks?.defaultValues) {
56
+ finalProps = { ...hooks.defaultValues, ...props } as T;
57
+ }
58
+
59
+ // Validate schema on creation
60
+ if (this.domainSchema && this.validationConfig.onCreate) {
61
+ this.validateProps(finalProps);
62
+ }
63
+
64
+ // Set props (not frozen yet) so rules can access them
65
+ (this as any).props = finalProps;
66
+
67
+ // Execute rules (custom validations) - after props is set but before freezing
68
+ if (hooks?.rules) {
69
+ hooks.rules(this as any);
70
+ }
71
+
72
+ // Now freeze the props for immutability
73
+ Object.freeze(this.props);
74
+
75
+ // Hook onCreate
76
+ if (hooks?.onCreate) {
77
+ hooks.onCreate(this as any);
78
+ }
79
+ }
80
+
81
+ private validateProps(props: T): void {
82
+ if (!this.domainSchema) return;
83
+
84
+ const result = this.domainSchema["~standard"].validate(props);
85
+
86
+ if (result instanceof Promise) {
87
+ throw new Error(
88
+ "Async validation not supported in constructor. Use sync validation schema."
89
+ );
90
+ }
91
+
92
+ if (result.issues && result.issues.length > 0) {
93
+ const validationError = new ValidationError(
94
+ result.issues.map((issue) => ({
95
+ path: issue.path?.map((p) => this.extractPathKey(p)) || [],
96
+ message: issue.message,
97
+ }))
98
+ );
99
+
100
+ if (this.validationConfig.throwOnError) {
101
+ throw validationError;
102
+ }
103
+
104
+ // If not throwing, store error for later retrieval
105
+ (this as any)._validationError = validationError;
106
+ }
107
+ }
108
+
109
+ private extractPathKey(pathSegment: unknown): string {
110
+ if (pathSegment === null || pathSegment === undefined) {
111
+ return "";
112
+ }
113
+ // Handle PropertyKey (string | number | symbol)
114
+ if (typeof pathSegment === "string" || typeof pathSegment === "number") {
115
+ return String(pathSegment);
116
+ }
117
+ if (typeof pathSegment === "symbol") {
118
+ return pathSegment.toString();
119
+ }
120
+ // Handle object with 'key' property (Zod's PathSegment)
121
+ if (typeof pathSegment === "object" && "key" in pathSegment) {
122
+ return String((pathSegment as { key: unknown }).key);
123
+ }
124
+ // Fallback
125
+ return String(pathSegment);
126
+ }
127
+
128
+ /**
129
+ * Check if value object has validation errors (when throwOnError is false)
130
+ */
131
+ get hasValidationErrors(): boolean {
132
+ return !!(this as any)._validationError;
133
+ }
134
+
135
+ /**
136
+ * Get validation errors (when throwOnError is false)
137
+ */
138
+ get validationErrors(): ValidationError | undefined {
139
+ return (this as any)._validationError;
140
+ }
141
+
142
+ equals(other: ValueObject<T>): boolean {
143
+ if (!other || !(other instanceof ValueObject)) return false;
144
+ return JSON.stringify(this.props) === JSON.stringify(other.props);
145
+ }
146
+
147
+ /**
148
+ * Add a domain event to this value object
149
+ */
150
+ protected addDomainEvent(event: IDomainEvent): void {
151
+ this.domainEvents.push(event);
152
+ }
153
+
154
+ /**
155
+ * Get all uncommitted domain events
156
+ */
157
+ getUncommittedEvents(): IDomainEvent[] {
158
+ return [...this.domainEvents];
159
+ }
160
+
161
+ /**
162
+ * Clear all domain events (call after publishing)
163
+ */
164
+ clearEvents(): void {
165
+ this.domainEvents = [];
166
+ }
167
+
168
+ /**
169
+ * Check if value object has uncommitted events
170
+ */
171
+ hasUncommittedEvents(): boolean {
172
+ return this.domainEvents.length > 0;
173
+ }
174
+
175
+ toJson(): T {
176
+ return { ...this.props };
177
+ }
178
+
179
+ /**
180
+ * Create a new ValueObject with updated properties
181
+ * Since ValueObjects are immutable, this returns a new instance
182
+ */
183
+ protected clone(updates: Partial<T>): this {
184
+ const Constructor = this.constructor as new (props: T) => this;
185
+ return new Constructor({ ...this.props, ...updates });
186
+ }
187
+ }
@@ -0,0 +1,432 @@
1
+ import { Pagination } from "../src";
2
+ import { Criteria } from "../src/criteria";
3
+ import { PaginatedResult } from "../src/paginated-result";
4
+ import { Post } from "./utils";
5
+
6
+ interface TestUser {
7
+ id: string;
8
+ name: string;
9
+ email: string;
10
+ age: number;
11
+ status: "active" | "inactive";
12
+ createdAt: Date;
13
+ }
14
+
15
+ const testUsers: TestUser[] = [
16
+ {
17
+ id: "1",
18
+ name: "Alice",
19
+ email: "alice@example.com",
20
+ age: 25,
21
+ status: "active",
22
+ createdAt: new Date("2024-01-01"),
23
+ },
24
+ {
25
+ id: "2",
26
+ name: "Bob",
27
+ email: "bob@example.com",
28
+ age: 30,
29
+ status: "active",
30
+ createdAt: new Date("2024-02-01"),
31
+ },
32
+ {
33
+ id: "3",
34
+ name: "Charlie",
35
+ email: "charlie@test.com",
36
+ age: 35,
37
+ status: "inactive",
38
+ createdAt: new Date("2024-03-01"),
39
+ },
40
+ {
41
+ id: "4",
42
+ name: "Diana",
43
+ email: "diana@example.com",
44
+ age: 28,
45
+ status: "active",
46
+ createdAt: new Date("2024-04-01"),
47
+ },
48
+ {
49
+ id: "5",
50
+ name: "Eve",
51
+ email: "eve@test.com",
52
+ age: 22,
53
+ status: "inactive",
54
+ createdAt: new Date("2024-05-01"),
55
+ },
56
+ ];
57
+
58
+ describe("Criteria", () => {
59
+ describe("Fluent API", () => {
60
+ it("should create empty criteria", () => {
61
+ const criteria = Criteria.create<TestUser>();
62
+ expect(criteria.hasFilters()).toBe(false);
63
+ expect(criteria.hasOrders()).toBe(false);
64
+ expect(criteria.hasPagination()).toBe(true); // Default pagination is set
65
+ });
66
+
67
+ it("should chain methods fluently", () => {
68
+ const criteria = Criteria.create<TestUser>()
69
+ .where("status", "equals", "active")
70
+ .where("age", "greaterThan", 18)
71
+ .orderBy("name", "asc")
72
+ .paginate(1, 10);
73
+
74
+ expect(criteria.hasFilters()).toBe(true);
75
+ expect(criteria.hasOrders()).toBe(true);
76
+ expect(criteria.hasPagination()).toBe(true);
77
+ });
78
+
79
+ it("should use shorthand methods", () => {
80
+ const criteria = Criteria.create<TestUser>()
81
+ .whereEquals("status", "active")
82
+ .whereContains("name", "ali")
83
+ .whereIn("age", [25, 30, 35])
84
+ .orderByDesc("createdAt");
85
+
86
+ const filters = criteria.getFilters();
87
+ expect(filters).toHaveLength(3);
88
+ expect(filters[0].operator).toBe("equals");
89
+ expect(filters[1].operator).toBe("contains");
90
+ expect(filters[2].operator).toBe("in");
91
+ });
92
+ });
93
+
94
+ describe("Filtering", () => {
95
+ it("should filter by equals", () => {
96
+ const criteria = Criteria.create<TestUser>().whereEquals(
97
+ "status",
98
+ "active"
99
+ );
100
+ const result = PaginatedResult.fromArray(testUsers, criteria);
101
+ expect(result.data).toHaveLength(3);
102
+ expect(result.data.every((u) => u.status === "active")).toBe(true);
103
+ });
104
+
105
+ it("should filter by notEquals", () => {
106
+ const criteria = Criteria.create<TestUser>().where(
107
+ "status",
108
+ "notEquals",
109
+ "active"
110
+ );
111
+ const result = PaginatedResult.fromArray(testUsers, criteria);
112
+ expect(result.data).toHaveLength(2);
113
+ expect(result.data.every((u) => u.status === "inactive")).toBe(true);
114
+ });
115
+
116
+ it("should filter by greaterThan", () => {
117
+ const criteria = Criteria.create<TestUser>().where(
118
+ "age",
119
+ "greaterThan",
120
+ 28
121
+ );
122
+ const result = PaginatedResult.fromArray(testUsers, criteria);
123
+ expect(result.data).toHaveLength(2);
124
+ expect(result.data.map((u) => u.name)).toEqual(["Bob", "Charlie"]);
125
+ });
126
+
127
+ it("should filter by lessThan", () => {
128
+ const criteria = Criteria.create<TestUser>().where("age", "lessThan", 26);
129
+ const result = PaginatedResult.fromArray(testUsers, criteria);
130
+ expect(result.data).toHaveLength(2);
131
+ expect(result.data.map((u) => u.name)).toEqual(["Alice", "Eve"]);
132
+ });
133
+
134
+ it("should filter by contains", () => {
135
+ const criteria = Criteria.create<TestUser>().whereContains(
136
+ "email",
137
+ "example"
138
+ );
139
+ const result = PaginatedResult.fromArray(testUsers, criteria);
140
+ expect(result.data).toHaveLength(3);
141
+ });
142
+
143
+ it("should filter by startsWith", () => {
144
+ const criteria = Criteria.create<TestUser>().where(
145
+ "name",
146
+ "startsWith",
147
+ "A"
148
+ );
149
+ const result = PaginatedResult.fromArray(testUsers, criteria);
150
+ expect(result.data).toHaveLength(1);
151
+ expect(result.data[0].name).toBe("Alice");
152
+ });
153
+
154
+ it("should filter by endsWith", () => {
155
+ const criteria = Criteria.create<TestUser>().where(
156
+ "email",
157
+ "endsWith",
158
+ ".com"
159
+ );
160
+ const result = PaginatedResult.fromArray(testUsers, criteria);
161
+ expect(result.data).toHaveLength(5);
162
+ });
163
+
164
+ it("should filter by in", () => {
165
+ const criteria = Criteria.create<TestUser>().whereIn("age", [25, 35]);
166
+ const result = PaginatedResult.fromArray(testUsers, criteria);
167
+ expect(result.data).toHaveLength(2);
168
+ expect(result.data.map((u) => u.name)).toEqual(["Alice", "Charlie"]);
169
+ });
170
+
171
+ it("should filter by notIn", () => {
172
+ const criteria = Criteria.create<TestUser>().where(
173
+ "age",
174
+ "notIn",
175
+ [25, 35]
176
+ );
177
+ const result = PaginatedResult.fromArray(testUsers, criteria);
178
+ expect(result.data).toHaveLength(3);
179
+ });
180
+
181
+ it("should filter by between", () => {
182
+ const criteria = Criteria.create<TestUser>().whereBetween("age", 25, 30);
183
+ const result = PaginatedResult.fromArray(testUsers, criteria);
184
+ expect(result.data).toHaveLength(3);
185
+ expect(result.data.map((u) => u.name)).toEqual(["Alice", "Bob", "Diana"]);
186
+ });
187
+
188
+ it("should combine multiple filters", () => {
189
+ const criteria = Criteria.create<TestUser>()
190
+ .whereEquals("status", "active")
191
+ .where("age", "greaterThan", 25);
192
+
193
+ const result = PaginatedResult.fromArray(testUsers, criteria);
194
+ expect(result.data).toHaveLength(2);
195
+ expect(result.data.map((u) => u.name)).toEqual(["Bob", "Diana"]);
196
+ });
197
+ });
198
+
199
+ describe("Ordering", () => {
200
+ it("should order by ascending", () => {
201
+ const criteria = Criteria.create<TestUser>().orderByAsc("age");
202
+ const result = PaginatedResult.fromArray(testUsers, criteria);
203
+ const ages = result.data.map((u) => u.age);
204
+ expect(ages).toEqual([22, 25, 28, 30, 35]);
205
+ });
206
+
207
+ it("should order by descending", () => {
208
+ const criteria = Criteria.create<TestUser>().orderByDesc("age");
209
+ const result = PaginatedResult.fromArray(testUsers, criteria);
210
+ const ages = result.data.map((u) => u.age);
211
+ expect(ages).toEqual([35, 30, 28, 25, 22]);
212
+ });
213
+
214
+ it("should order by string field", () => {
215
+ const criteria = Criteria.create<TestUser>().orderByAsc("name");
216
+ const result = PaginatedResult.fromArray(testUsers, criteria);
217
+ const names = result.data.map((u) => u.name);
218
+ expect(names).toEqual(["Alice", "Bob", "Charlie", "Diana", "Eve"]);
219
+ });
220
+
221
+ it("should order by date field", () => {
222
+ const criteria = Criteria.create<TestUser>().orderByDesc("createdAt");
223
+ const result = PaginatedResult.fromArray(testUsers, criteria);
224
+ const names = result.data.map((u) => u.name);
225
+ expect(names).toEqual(["Eve", "Diana", "Charlie", "Bob", "Alice"]);
226
+ });
227
+ });
228
+
229
+ describe("Pagination", () => {
230
+ it("should paginate results", () => {
231
+ const criteria = Criteria.create<TestUser>().paginate(1, 2);
232
+ const result = PaginatedResult.fromArray(testUsers, criteria);
233
+
234
+ expect(result.data).toHaveLength(2);
235
+ expect(result.meta.page).toBe(1);
236
+ expect(result.meta.limit).toBe(2);
237
+ expect(result.meta.total).toBe(5);
238
+ expect(result.meta.totalPages).toBe(3);
239
+ expect(result.meta.hasNext).toBe(true);
240
+ expect(result.meta.hasPrevious).toBe(false);
241
+ });
242
+
243
+ it("should get second page", () => {
244
+ const criteria = Criteria.create<TestUser>().paginate(2, 2);
245
+ const result = PaginatedResult.fromArray(testUsers, criteria);
246
+
247
+ expect(result.data).toHaveLength(2);
248
+ expect(result.data.map((u) => u.name)).toEqual(["Charlie", "Diana"]);
249
+ expect(result.meta.page).toBe(2);
250
+ expect(result.meta.hasNext).toBe(true);
251
+ expect(result.meta.hasPrevious).toBe(true);
252
+ });
253
+
254
+ it("should get last page", () => {
255
+ const criteria = Criteria.create<TestUser>().paginate(3, 2);
256
+ const result = PaginatedResult.fromArray(testUsers, criteria);
257
+
258
+ expect(result.data).toHaveLength(1);
259
+ expect(result.data[0].name).toBe("Eve");
260
+ expect(result.meta.hasNext).toBe(false);
261
+ expect(result.meta.hasPrevious).toBe(true);
262
+ });
263
+
264
+ it("should handle empty page", () => {
265
+ const criteria = Criteria.create<TestUser>().paginate(10, 2);
266
+ const result = PaginatedResult.fromArray(testUsers, criteria);
267
+
268
+ expect(result.data).toHaveLength(0);
269
+ expect(result.meta.total).toBe(5);
270
+ });
271
+
272
+ it("should apply limit shorthand", () => {
273
+ const criteria = Criteria.create<TestUser>().limit(3);
274
+ const result = PaginatedResult.fromArray(testUsers, criteria);
275
+
276
+ expect(result.data).toHaveLength(3);
277
+ expect(result.meta.page).toBe(1);
278
+ });
279
+ });
280
+
281
+ describe("Combined Operations", () => {
282
+ it("should filter, order, and paginate", () => {
283
+ const criteria = Criteria.create<TestUser>()
284
+ .whereEquals("status", "active")
285
+ .orderByDesc("age")
286
+ .paginate(1, 2);
287
+
288
+ const result = PaginatedResult.fromArray(testUsers, criteria);
289
+
290
+ expect(result.data).toHaveLength(2);
291
+ expect(result.data.map((u) => u.name)).toEqual(["Bob", "Diana"]);
292
+ expect(result.meta.total).toBe(3); // Total active users
293
+ expect(result.meta.totalPages).toBe(2);
294
+ });
295
+ });
296
+
297
+ describe("Serialization", () => {
298
+ it("should convert to object", () => {
299
+ const criteria = Criteria.create<TestUser>()
300
+ .whereEquals("status", "active")
301
+ .orderByDesc("age")
302
+ .paginate(1, 10);
303
+
304
+ const obj = criteria.toJSON();
305
+
306
+ expect(obj.filters).toHaveLength(1);
307
+ expect(obj.orders).toHaveLength(1);
308
+ expect(obj.pagination).toBeDefined();
309
+ expect(obj.pagination?.page).toBe(1);
310
+ });
311
+
312
+ it("should create from object", () => {
313
+ const criteria = Criteria.fromObject<TestUser>({
314
+ filters: [{ field: "status", operator: "equals", value: "active" }],
315
+ orders: [{ field: "age", direction: "desc" }],
316
+ pagination: { page: 1, limit: 10, offset: 0 },
317
+ });
318
+
319
+ expect(criteria.getFilters()).toHaveLength(1);
320
+ expect(criteria.getOrders()).toHaveLength(1);
321
+ expect(criteria.getPagination()?.page).toBe(1);
322
+ });
323
+
324
+ it("should clone criteria", () => {
325
+ const original = Criteria.create<TestUser>()
326
+ .whereEquals("status", "active")
327
+ .orderByDesc("age");
328
+
329
+ const cloned = original.clone();
330
+ cloned.whereEquals("age", 30);
331
+
332
+ expect(original.getFilters()).toHaveLength(1);
333
+ expect(cloned.getFilters()).toHaveLength(2);
334
+ });
335
+
336
+ it("should deserialize pagination result with entities", () => {
337
+ const pagination: Pagination = { page: 1, limit: 10, offset: 0 };
338
+
339
+ const data = [
340
+ new Post({
341
+ title: "Post 1",
342
+ content: "Content 1",
343
+ likes: 1,
344
+ }),
345
+ new Post({
346
+ title: "Post 2",
347
+ content: "Content 2",
348
+ likes: 2,
349
+ }),
350
+ ];
351
+
352
+ const total = data.length;
353
+
354
+ const paginationResult = PaginatedResult.create(data, pagination, total);
355
+
356
+ const result = paginationResult.toJSON();
357
+
358
+ expect(result.data).toHaveLength(2);
359
+ expect(result.data[0].title).toBe("Post 1");
360
+ expect(result.data[1].title).toBe("Post 2");
361
+ expect(result.meta.total).toBe(total);
362
+ expect(result.meta.totalPages).toBe(1);
363
+ expect(result.meta.page).toBe(1);
364
+ expect(result.meta.limit).toBe(10);
365
+ });
366
+
367
+ it("should deserialize pagination result with plain objects", () => {
368
+ const pagination: Pagination = { page: 1, limit: 10, offset: 0 };
369
+ const total = testUsers.length;
370
+
371
+ const paginationResult = PaginatedResult.create(
372
+ testUsers,
373
+ pagination,
374
+ total
375
+ );
376
+
377
+ const result = paginationResult.toJSON();
378
+
379
+ expect(result.data).toHaveLength(testUsers.length);
380
+ expect(result.data.map((u) => u.name)).toEqual(
381
+ testUsers.map((u) => u.name)
382
+ );
383
+ expect(result.meta.total).toBe(total);
384
+ expect(result.meta.totalPages).toBe(1);
385
+ expect(result.meta.page).toBe(1);
386
+ expect(result.meta.limit).toBe(10);
387
+ });
388
+ });
389
+
390
+ describe("Helper Functions", () => {
391
+ it("should create pagination meta", () => {
392
+ const pagination = { page: 2, limit: 10, offset: 10 };
393
+ const meta = PaginatedResult.createMeta(pagination, 45);
394
+
395
+ expect(meta.page).toBe(2);
396
+ expect(meta.limit).toBe(10);
397
+ expect(meta.total).toBe(45);
398
+ expect(meta.totalPages).toBe(5);
399
+ expect(meta.hasNext).toBe(true);
400
+ expect(meta.hasPrevious).toBe(true);
401
+ });
402
+
403
+ it("should create paginated result", () => {
404
+ const data = [{ id: "1" }, { id: "2" }];
405
+ const pagination = { page: 1, limit: 2, offset: 0 };
406
+ const result = PaginatedResult.create(data, pagination, data.length);
407
+
408
+ expect(result.data).toEqual(data);
409
+ expect(result.meta.total).toBe(data.length);
410
+ expect(result.meta.totalPages).toBe(1);
411
+ });
412
+ });
413
+
414
+ describe("Criteria from Query Params", () => {
415
+ it("should create criteria from query params", () => {
416
+ const queryParams = {
417
+ "status:equals": "active",
418
+ "age:greaterThan": "25",
419
+ orderBy: "age",
420
+ orderDirection: "desc",
421
+ page: "1",
422
+ limit: "2",
423
+ };
424
+
425
+ const criteria = Criteria.fromQueryParams<TestUser>(queryParams);
426
+ expect(criteria.getFilters()).toHaveLength(2);
427
+ expect(criteria.getOrders()).toHaveLength(1);
428
+ expect(criteria.getPagination()?.page).toBe(1);
429
+ expect(criteria.getPagination()?.limit).toBe(2);
430
+ });
431
+ });
432
+ });