@woltz/rich-domain 1.1.0 → 1.2.0

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.
@@ -17,12 +17,65 @@ export const FILTER_OPERATORS = [
17
17
  "isNotNull",
18
18
  ] as const;
19
19
 
20
+ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
21
+
22
+ export type StringOperators =
23
+ | "equals"
24
+ | "notEquals"
25
+ | "contains"
26
+ | "startsWith"
27
+ | "endsWith"
28
+ | "in"
29
+ | "notIn"
30
+ | "isNull"
31
+ | "isNotNull";
32
+
33
+ export type NumberOperators =
34
+ | "equals"
35
+ | "notEquals"
36
+ | "greaterThan"
37
+ | "greaterThanOrEqual"
38
+ | "lessThan"
39
+ | "lessThanOrEqual"
40
+ | "in"
41
+ | "notIn"
42
+ | "between"
43
+ | "isNull"
44
+ | "isNotNull";
45
+
46
+ export type DateOperators =
47
+ | "equals"
48
+ | "notEquals"
49
+ | "greaterThan"
50
+ | "greaterThanOrEqual"
51
+ | "lessThan"
52
+ | "lessThanOrEqual"
53
+ | "in"
54
+ | "notIn"
55
+ | "between"
56
+ | "isNull"
57
+ | "isNotNull";
58
+
59
+ export type BooleanOperators = "equals" | "notEquals" | "isNull" | "isNotNull";
60
+
61
+ export type ArrayOperators = "in" | "notIn" | "isNull" | "isNotNull";
62
+
63
+ export type OperatorsForType<T> = T extends string
64
+ ? StringOperators
65
+ : T extends number
66
+ ? NumberOperators
67
+ : T extends Date
68
+ ? DateOperators
69
+ : T extends boolean
70
+ ? BooleanOperators
71
+ : T extends Array<any>
72
+ ? ArrayOperators
73
+ : FilterOperator;
74
+
20
75
  export type FilterValueFor<T> =
21
- | T // equals, notEquals
22
- | (T extends number | Date
23
- ? [T, T] // between
24
- : never)
25
- | T[] // in, notIn
76
+ | T
77
+ | (T extends number | Date ? [T, T] : never)
78
+ | T[]
26
79
  | null;
27
80
 
28
81
  export type PathValue<
@@ -30,24 +83,34 @@ export type PathValue<
30
83
  P extends string
31
84
  > = P extends `${infer K}.${infer Rest}`
32
85
  ? K extends keyof T
33
- ? PathValue<T[K], Rest>
86
+ ? T[K] extends Array<infer U>
87
+ ? PathValue<U, Rest>
88
+ : PathValue<T[K], Rest>
34
89
  : never
35
90
  : P extends keyof T
36
91
  ? T[P]
37
92
  : never;
38
93
 
39
- export type FilterOperator = (typeof FILTER_OPERATORS)[number];
40
-
41
94
  export interface Filter<TField = string, TValue = unknown> {
42
95
  field: TField;
43
- operator: FilterOperator;
96
+ operator: unknown extends TValue ? FilterOperator : OperatorsForType<TValue>;
44
97
  value: TValue;
98
+ options?: CriteriaOptions;
45
99
  }
46
100
 
47
101
  export type TypedFilter<T> = {
48
- [K in FieldPath<T>]: Filter<K, FilterValueFor<PathValue<T, K>>>;
102
+ [K in FieldPath<T>]: {
103
+ field: K;
104
+ operator: OperatorsForType<NonNullable<PathValue<T, K>>>;
105
+ value: FilterValueFor<NonNullable<PathValue<T, K>>>;
106
+ options?: CriteriaOptions;
107
+ };
49
108
  }[FieldPath<T>];
50
109
 
110
+ export type CriteriaAdapter<Input, Output> = {
111
+ [K in FieldPath<Input>]?: FieldPath<Output>;
112
+ };
113
+
51
114
  export type OrderDirection = "asc" | "desc";
52
115
 
53
116
  export interface Order {
@@ -75,10 +138,20 @@ export interface PaginationMeta {
75
138
  hasPrevious: boolean;
76
139
  }
77
140
 
78
- export type FieldPath<T> = {
79
- [K in keyof T & string]: T[K] extends Primitive
80
- ? K
81
- : T[K] extends Array<infer U>
82
- ? K | `${K}.${FieldPath<U>}`
83
- : K | `${K}.${FieldPath<T[K]>}`;
84
- }[keyof T & string];
141
+ export interface CriteriaOptions {
142
+ quantifier?: "some" | "every" | "none";
143
+ }
144
+
145
+ type ExcludeBuiltInKeys<T> = Exclude<keyof T, keyof any[] | number | symbol>;
146
+
147
+ export type FieldPath<T> = T extends Primitive
148
+ ? never
149
+ : {
150
+ [K in ExcludeBuiltInKeys<T> & string]: NonNullable<T[K]> extends Primitive
151
+ ? K
152
+ : NonNullable<T[K]> extends Array<infer U>
153
+ ? U extends Primitive
154
+ ? K
155
+ : K | `${K}.${FieldPath<U>}`
156
+ : K | `${K}.${FieldPath<NonNullable<T[K]>>}`;
157
+ }[ExcludeBuiltInKeys<T> & string];
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { Pagination } from "../src";
2
2
  import { Criteria } from "../src/criteria";
3
3
  import { PaginatedResult } from "../src/paginated-result";
4
+ import { CriteriaAdapter } from "../src/types";
4
5
  import { Post } from "./utils";
5
6
 
6
7
  interface TestUser {
@@ -12,6 +13,17 @@ interface TestUser {
12
13
  createdAt: Date;
13
14
  }
14
15
 
16
+ interface UserWithPostsDto {
17
+ id: string;
18
+ name: string;
19
+ posts: { title: string; content: string }[];
20
+ leads: {
21
+ contact: {
22
+ name: string;
23
+ };
24
+ }[];
25
+ }
26
+
15
27
  const testUsers: TestUser[] = [
16
28
  {
17
29
  id: "1",
@@ -56,6 +68,20 @@ const testUsers: TestUser[] = [
56
68
  ];
57
69
 
58
70
  describe("Criteria", () => {
71
+ describe("Search", () => {
72
+ it("should search by all items ignoring pagination and limit", () => {
73
+ const criteria = Criteria.create<TestUser>()
74
+ .search(["name"], "Eve")
75
+ .paginate(3, 1);
76
+ const result = PaginatedResult.fromArray(testUsers, criteria);
77
+ expect(result.data).toHaveLength(1);
78
+ expect(result.data[0].name).toBe("Eve");
79
+ expect(result.meta.total).toBe(1);
80
+ expect(result.meta.totalPages).toBe(1);
81
+ expect(result.meta.page).toBe(1);
82
+ expect(result.meta.limit).toBe(1);
83
+ });
84
+ });
59
85
  describe("Fluent API", () => {
60
86
  it("should create empty criteria", () => {
61
87
  const criteria = Criteria.create<TestUser>();
@@ -297,7 +323,7 @@ describe("Criteria", () => {
297
323
 
298
324
  expect(result.data).toHaveLength(2);
299
325
  expect(result.data.map((u) => u.name)).toEqual(["Bob", "Diana"]);
300
- expect(result.meta.total).toBe(3); // Total active users
326
+ expect(result.meta.total).toBe(3);
301
327
  expect(result.meta.totalPages).toBe(2);
302
328
  });
303
329
  });
@@ -437,4 +463,293 @@ describe("Criteria", () => {
437
463
  expect(criteria.getPagination()?.limit).toBe(2);
438
464
  });
439
465
  });
466
+
467
+ describe("Quantifiers", () => {
468
+ describe("Fluent API methods", () => {
469
+ it("should create filter with whereSome", () => {
470
+ const criteria = Criteria.create<UserWithPostsDto>().whereSome(
471
+ "posts.title",
472
+ "contains",
473
+ "test"
474
+ );
475
+
476
+ const filters = criteria.getFilters();
477
+
478
+ expect(filters).toHaveLength(1);
479
+ expect(filters[0].field).toBe("posts.title");
480
+ expect(filters[0].operator).toBe("contains");
481
+ expect(filters[0].value).toBe("test");
482
+ expect(filters[0].options?.quantifier).toBe("some");
483
+ });
484
+
485
+ it("should create filter with whereEvery", () => {
486
+ const criteria = Criteria.create<UserWithPostsDto>().whereEvery(
487
+ "posts.title",
488
+ "contains",
489
+ "test"
490
+ );
491
+
492
+ const filters = criteria.getFilters();
493
+ expect(filters).toHaveLength(1);
494
+ expect(filters[0].options?.quantifier).toBe("every");
495
+ });
496
+
497
+ it("should create filter with whereNone", () => {
498
+ const criteria = Criteria.create<UserWithPostsDto>().whereNone(
499
+ "posts.title",
500
+ "contains",
501
+ "test"
502
+ );
503
+
504
+ const filters = criteria.getFilters();
505
+ expect(filters).toHaveLength(1);
506
+ expect(filters[0].options?.quantifier).toBe("none");
507
+ });
508
+
509
+ it("should create filter without quantifier using where", () => {
510
+ const criteria = Criteria.create<UserWithPostsDto>().where(
511
+ "posts.title",
512
+ "contains",
513
+ "test"
514
+ );
515
+
516
+ const filters = criteria.getFilters();
517
+ expect(filters).toHaveLength(1);
518
+ expect(filters[0].options).toBeUndefined();
519
+ });
520
+ });
521
+
522
+ describe("fromQueryParams with quantifier", () => {
523
+ it("should parse quantifier from query params with @some", () => {
524
+ const queryParams = {
525
+ "posts.title:contains@some": "test",
526
+ };
527
+
528
+ const criteria =
529
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
530
+ const filters = criteria.getFilters();
531
+
532
+ expect(filters).toHaveLength(1);
533
+ expect(filters[0].field).toBe("posts.title");
534
+ expect(filters[0].operator).toBe("contains");
535
+ expect(filters[0].value).toBe("test");
536
+ expect(filters[0].options?.quantifier).toBe("some");
537
+ });
538
+
539
+ it("should parse quantifier from query params with @every", () => {
540
+ const queryParams = {
541
+ "posts.title:equals@every": "test",
542
+ };
543
+
544
+ const criteria =
545
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
546
+ const filters = criteria.getFilters();
547
+
548
+ expect(filters).toHaveLength(1);
549
+ expect(filters[0].options?.quantifier).toBe("every");
550
+ });
551
+
552
+ it("should parse quantifier from query params with @none", () => {
553
+ const queryParams = {
554
+ "posts.title:contains@none": "spam",
555
+ };
556
+
557
+ const criteria =
558
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
559
+ const filters = criteria.getFilters();
560
+
561
+ expect(filters).toHaveLength(1);
562
+ expect(filters[0].options?.quantifier).toBe("none");
563
+ });
564
+
565
+ it("should work with in operator and quantifier", () => {
566
+ const queryParams = {
567
+ "posts.title:in@some": "test1,test2,test3",
568
+ };
569
+
570
+ const criteria =
571
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
572
+ const filters = criteria.getFilters();
573
+
574
+ expect(filters).toHaveLength(1);
575
+ expect(filters[0].operator).toBe("in");
576
+ expect(filters[0].value).toEqual(["test1", "test2", "test3"]);
577
+ expect(filters[0].options?.quantifier).toBe("some");
578
+ });
579
+
580
+ it("should work with between operator and quantifier", () => {
581
+ const queryParams = {
582
+ "posts.likes:between@some": "10,100",
583
+ };
584
+
585
+ const criteria =
586
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
587
+ const filters = criteria.getFilters();
588
+
589
+ expect(filters).toHaveLength(1);
590
+ expect(filters[0].operator).toBe("between");
591
+ expect(filters[0].value).toEqual([10, 100]);
592
+ expect(filters[0].options?.quantifier).toBe("some");
593
+ });
594
+
595
+ it("should maintain backward compatibility without quantifier", () => {
596
+ const queryParams = {
597
+ "posts.title:contains": "test",
598
+ };
599
+
600
+ const criteria =
601
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
602
+ const filters = criteria.getFilters();
603
+
604
+ expect(filters).toHaveLength(1);
605
+ expect(filters[0].field).toBe("posts.title");
606
+ expect(filters[0].operator).toBe("contains");
607
+ expect(filters[0].value).toBe("test");
608
+ expect(filters[0].options).toBeUndefined();
609
+ });
610
+
611
+ it("should throw error for invalid quantifier", () => {
612
+ const queryParams = {
613
+ "posts.title:contains@invalid": "test",
614
+ };
615
+
616
+ expect(() => {
617
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
618
+ }).toThrow("Invalid quantifier");
619
+ });
620
+
621
+ it("should handle multiple filters with mixed quantifiers", () => {
622
+ const queryParams = {
623
+ "posts.title:contains@some": "test",
624
+ "posts.content:equals@every": "content",
625
+ "name:contains": "John",
626
+ };
627
+
628
+ const criteria =
629
+ Criteria.fromQueryParams<UserWithPostsDto>(queryParams);
630
+ const filters = criteria.getFilters();
631
+
632
+ expect(filters).toHaveLength(3);
633
+ expect(filters[0].options?.quantifier).toBe("some");
634
+ expect(filters[1].options?.quantifier).toBe("every");
635
+ expect(filters[2].options).toBeUndefined();
636
+ });
637
+ });
638
+
639
+ describe("fromObject with quantifier", () => {
640
+ it("should create criteria from object with quantifier", () => {
641
+ const criteria = Criteria.fromObject<UserWithPostsDto>({
642
+ filters: [
643
+ {
644
+ field: "posts.title",
645
+ operator: "contains",
646
+ value: "test",
647
+ options: { quantifier: "some" },
648
+ },
649
+ ],
650
+ });
651
+
652
+ const filters = criteria.getFilters();
653
+ expect(filters).toHaveLength(1);
654
+ expect(filters[0].options?.quantifier).toBe("some");
655
+ });
656
+
657
+ it("should preserve quantifier when cloning", () => {
658
+ const original = Criteria.create<UserWithPostsDto>().whereSome(
659
+ "posts.title",
660
+ "contains",
661
+ "test"
662
+ );
663
+
664
+ const cloned = original.clone();
665
+ const filters = cloned.getFilters();
666
+
667
+ expect(filters).toHaveLength(1);
668
+ expect(filters[0].options?.quantifier).toBe("some");
669
+ });
670
+ });
671
+
672
+ describe("toJSON with quantifier", () => {
673
+ it("should serialize quantifier to JSON", () => {
674
+ const criteria = Criteria.create<UserWithPostsDto>()
675
+ .whereSome("posts.title", "contains", "test")
676
+ .whereEvery("posts.content", "equals", "content");
677
+
678
+ const json = criteria.toJSON();
679
+
680
+ expect(json.filters).toHaveLength(2);
681
+ expect(json.filters[0].options?.quantifier).toBe("some");
682
+ expect(json.filters[1].options?.quantifier).toBe("every");
683
+ });
684
+ });
685
+ });
686
+
687
+ describe("Adapter", () => {
688
+ type UserInDatabase = {
689
+ id: string;
690
+ name: string;
691
+ user_posts: { title: string; content: string }[];
692
+ leads: {
693
+ contact: {
694
+ fullName: string;
695
+ };
696
+ };
697
+ };
698
+ const UserWithPostsAdapter: CriteriaAdapter<
699
+ UserWithPostsDto,
700
+ UserInDatabase
701
+ > = {
702
+ posts: "user_posts",
703
+ "leads.contact.name": "leads.contact.fullName",
704
+ };
705
+
706
+ let criteria: Criteria<UserWithPostsDto>;
707
+ beforeEach(() => {
708
+ criteria =
709
+ Criteria.create<UserWithPostsDto>().useAdapter(UserWithPostsAdapter);
710
+ });
711
+
712
+ it("should resolve field path", () => {
713
+ const filters = criteria
714
+ .where("leads.contact.name", "contains", "John")
715
+ .getFilters();
716
+
717
+ expect(filters[0].field).toBe("leads.contact.fullName");
718
+ });
719
+
720
+ it("should resolve field path from query params", () => {
721
+ const queryParams = {
722
+ "posts:contains": "test",
723
+ };
724
+
725
+ const result = Criteria.fromQueryParams<UserWithPostsDto>(
726
+ queryParams,
727
+ UserWithPostsAdapter
728
+ );
729
+ const filters = result.getFilters();
730
+
731
+ expect(filters).toHaveLength(1);
732
+ expect(filters[0].field).toBe("user_posts");
733
+ expect(filters[0].operator).toBe("contains");
734
+ expect(filters[0].value).toBe("test");
735
+ });
736
+
737
+ it("should resolve field path from object", () => {
738
+ const criteria = Criteria.fromObject<UserWithPostsDto>(
739
+ {
740
+ filters: [
741
+ {
742
+ field: "posts",
743
+ operator: "in",
744
+ value: [{ title: "test", content: "test" }],
745
+ },
746
+ ],
747
+ },
748
+ UserWithPostsAdapter
749
+ );
750
+ const filters = criteria.getFilters();
751
+ expect(filters).toHaveLength(1);
752
+ expect(filters[0].field).toBe("user_posts");
753
+ });
754
+ });
440
755
  });