@objectstack/driver-memory 3.2.1 → 3.2.2

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.
@@ -589,4 +589,132 @@ describe('InMemoryDriver', () => {
589
589
  expect(results).toHaveLength(3);
590
590
  });
591
591
  });
592
+
593
+ describe('FilterCondition Object Format (from parseFilterAST)', () => {
594
+ beforeEach(async () => {
595
+ await driver.create(testTable, { id: '1', name: 'Alice Johnson', age: 30, email: 'alice@example.com', bio: null });
596
+ await driver.create(testTable, { id: '2', name: 'Bob Smith', age: 25, email: 'bob@test.com', bio: 'Developer' });
597
+ await driver.create(testTable, { id: '3', name: 'Charlie Brown', age: 35, email: 'charlie@example.com', bio: '' });
598
+ await driver.create(testTable, { id: '4', name: 'Evan Davis', age: 28, email: 'evan@test.com', bio: 'Designer' });
599
+ });
600
+
601
+ it('should filter with $contains operator', async () => {
602
+ const results = await driver.find(testTable, {
603
+ object: testTable,
604
+ where: { name: { $contains: 'Evan' } },
605
+ });
606
+ expect(results).toHaveLength(1);
607
+ expect(results[0].name).toBe('Evan Davis');
608
+ });
609
+
610
+ it('should filter with $contains case-insensitively', async () => {
611
+ const results = await driver.find(testTable, {
612
+ object: testTable,
613
+ where: { name: { $contains: 'alice' } },
614
+ });
615
+ expect(results).toHaveLength(1);
616
+ expect(results[0].name).toBe('Alice Johnson');
617
+ });
618
+
619
+ it('should filter with $notContains operator', async () => {
620
+ const results = await driver.find(testTable, {
621
+ object: testTable,
622
+ where: { email: { $notContains: 'example' } },
623
+ });
624
+ expect(results).toHaveLength(2);
625
+ expect(results.map((r: any) => r.name).sort()).toEqual(['Bob Smith', 'Evan Davis']);
626
+ });
627
+
628
+ it('should filter with $startsWith operator', async () => {
629
+ const results = await driver.find(testTable, {
630
+ object: testTable,
631
+ where: { name: { $startsWith: 'Ch' } },
632
+ });
633
+ expect(results).toHaveLength(1);
634
+ expect(results[0].name).toBe('Charlie Brown');
635
+ });
636
+
637
+ it('should filter with $endsWith operator', async () => {
638
+ const results = await driver.find(testTable, {
639
+ object: testTable,
640
+ where: { email: { $endsWith: '.com' } },
641
+ });
642
+ expect(results).toHaveLength(4);
643
+ });
644
+
645
+ it('should filter with $between operator', async () => {
646
+ const results = await driver.find(testTable, {
647
+ object: testTable,
648
+ where: { age: { $between: [26, 32] } },
649
+ });
650
+ expect(results).toHaveLength(2);
651
+ expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Evan Davis']);
652
+ });
653
+
654
+ it('should filter with $null: true', async () => {
655
+ const results = await driver.find(testTable, {
656
+ object: testTable,
657
+ where: { bio: { $null: true } },
658
+ });
659
+ expect(results).toHaveLength(1);
660
+ expect(results[0].name).toBe('Alice Johnson');
661
+ });
662
+
663
+ it('should filter with $null: false', async () => {
664
+ const results = await driver.find(testTable, {
665
+ object: testTable,
666
+ where: { bio: { $null: false } },
667
+ });
668
+ expect(results).toHaveLength(3);
669
+ });
670
+
671
+ it('should handle $contains inside $and', async () => {
672
+ const results = await driver.find(testTable, {
673
+ object: testTable,
674
+ where: { $and: [{ name: { $contains: 'a' } }, { age: { $gte: 30 } }] },
675
+ });
676
+ expect(results).toHaveLength(2);
677
+ expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Charlie Brown']);
678
+ });
679
+
680
+ it('should handle $startsWith inside $or', async () => {
681
+ const results = await driver.find(testTable, {
682
+ object: testTable,
683
+ where: { $or: [{ name: { $startsWith: 'Al' } }, { name: { $startsWith: 'Ev' } }] },
684
+ });
685
+ expect(results).toHaveLength(2);
686
+ expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Evan Davis']);
687
+ });
688
+
689
+ it('should handle AST-converted notcontains via convertConditionToMongo', async () => {
690
+ const results = await driver.find(testTable, {
691
+ object: testTable,
692
+ where: { type: 'comparison', field: 'name', operator: 'notcontains', value: 'Bob' },
693
+ });
694
+ expect(results).toHaveLength(3);
695
+ });
696
+
697
+ it('should handle combined $startsWith + $endsWith on same field', async () => {
698
+ const results = await driver.find(testTable, {
699
+ object: testTable,
700
+ where: { name: { $startsWith: 'A', $endsWith: 'son' } },
701
+ });
702
+ expect(results).toHaveLength(1);
703
+ expect(results[0].name).toBe('Alice Johnson');
704
+ });
705
+
706
+ it('should filter with $null: true on missing fields', async () => {
707
+ // bio is null for Alice Johnson — should match
708
+ // Records without bio field at all should also match $null: true
709
+ await driver.create(testTable, { id: '5', name: 'Frank', age: 40, email: 'frank@test.com' });
710
+ const results = await driver.find(testTable, {
711
+ object: testTable,
712
+ where: { bio: { $null: true } },
713
+ });
714
+ // Alice (bio: null), Frank (bio: missing)
715
+ expect(results.length).toBeGreaterThanOrEqual(2);
716
+ expect(results.map((r: any) => r.name)).toContain('Alice Johnson');
717
+ expect(results.map((r: any) => r.name)).toContain('Frank');
718
+ });
719
+ });
592
720
  });
@@ -629,8 +629,9 @@ export class InMemoryDriver implements DriverInterface {
629
629
  const op = filters.operator === 'or' ? '$or' : '$and';
630
630
  return { [op]: conditions };
631
631
  }
632
- // MongoDB format passthrough: { field: value } or { field: { $eq: value } }
633
- return filters;
632
+ // MongoDB/FilterCondition format: { field: value } or { field: { $op: value } }
633
+ // Translate non-standard operators ($contains, $notContains, etc.) to Mingo-compatible format
634
+ return this.normalizeFilterCondition(filters);
634
635
  }
635
636
 
636
637
  // Legacy array format
@@ -694,6 +695,8 @@ export class InMemoryDriver implements DriverInterface {
694
695
  return { [field]: { $nin: value } };
695
696
  case 'contains': case 'like':
696
697
  return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
698
+ case 'notcontains': case 'not_contains':
699
+ return { [field]: { $not: { $regex: new RegExp(this.escapeRegex(value), 'i') } } };
697
700
  case 'startswith': case 'starts_with':
698
701
  return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
699
702
  case 'endswith': case 'ends_with':
@@ -708,6 +711,128 @@ export class InMemoryDriver implements DriverInterface {
708
711
  }
709
712
  }
710
713
 
714
+ /**
715
+ * Normalize a FilterCondition object by converting non-standard $-prefixed
716
+ * operators ($contains, $notContains, $startsWith, $endsWith, $between, $null)
717
+ * to Mingo-compatible equivalents ($regex, $gte/$lte, null checks).
718
+ */
719
+ private normalizeFilterCondition(filter: Record<string, any>): Record<string, any> {
720
+ const result: Record<string, any> = {};
721
+ const extraAndConditions: Record<string, any>[] = [];
722
+
723
+ for (const key of Object.keys(filter)) {
724
+ const value = filter[key];
725
+ // Recurse into logical operators
726
+ if (key === '$and' || key === '$or') {
727
+ result[key] = Array.isArray(value)
728
+ ? value.map((child: any) => this.normalizeFilterCondition(child))
729
+ : value;
730
+ continue;
731
+ }
732
+ if (key === '$not') {
733
+ result[key] = value && typeof value === 'object'
734
+ ? this.normalizeFilterCondition(value)
735
+ : value;
736
+ continue;
737
+ }
738
+ // Skip $-prefixed keys that aren't field names (already handled or unknown)
739
+ if (key.startsWith('$')) {
740
+ result[key] = value;
741
+ continue;
742
+ }
743
+ // Field-level: value may be primitive (implicit eq) or operator object
744
+ if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp)) {
745
+ const normalized = this.normalizeFieldOperators(value);
746
+ // Handle multiple regex conditions on the same field (e.g. $startsWith + $endsWith)
747
+ if (normalized._multiRegex) {
748
+ const regexConditions: Record<string, any>[] = normalized._multiRegex;
749
+ delete normalized._multiRegex;
750
+ // Each regex becomes its own { field: { $regex: ... } } inside $and
751
+ for (const rc of regexConditions) {
752
+ extraAndConditions.push({ [key]: { ...normalized, ...rc } });
753
+ }
754
+ } else {
755
+ result[key] = normalized;
756
+ }
757
+ } else {
758
+ result[key] = value;
759
+ }
760
+ }
761
+
762
+ // Merge extra $and conditions from multi-regex fields
763
+ if (extraAndConditions.length > 0) {
764
+ const existing = result.$and;
765
+ const andArray = Array.isArray(existing) ? existing : [];
766
+ // Include the rest of result as a condition too
767
+ if (Object.keys(result).filter(k => k !== '$and').length > 0) {
768
+ const rest = { ...result };
769
+ delete rest.$and;
770
+ andArray.push(rest);
771
+ }
772
+ andArray.push(...extraAndConditions);
773
+ return { $and: andArray };
774
+ }
775
+
776
+ return result;
777
+ }
778
+
779
+ /**
780
+ * Convert non-standard field operators to Mingo-compatible format.
781
+ * When multiple regex-producing operators appear on the same field
782
+ * (e.g. $startsWith + $endsWith), they are combined via $and.
783
+ */
784
+ private normalizeFieldOperators(ops: Record<string, any>): Record<string, any> {
785
+ const result: Record<string, any> = {};
786
+ const regexConditions: Record<string, any>[] = [];
787
+
788
+ for (const op of Object.keys(ops)) {
789
+ const val = ops[op];
790
+ switch (op) {
791
+ case '$contains':
792
+ regexConditions.push({ $regex: new RegExp(this.escapeRegex(val), 'i') });
793
+ break;
794
+ case '$notContains':
795
+ result.$not = { $regex: new RegExp(this.escapeRegex(val), 'i') };
796
+ break;
797
+ case '$startsWith':
798
+ regexConditions.push({ $regex: new RegExp(`^${this.escapeRegex(val)}`, 'i') });
799
+ break;
800
+ case '$endsWith':
801
+ regexConditions.push({ $regex: new RegExp(`${this.escapeRegex(val)}$`, 'i') });
802
+ break;
803
+ case '$between':
804
+ if (Array.isArray(val) && val.length === 2) {
805
+ result.$gte = val[0];
806
+ result.$lte = val[1];
807
+ }
808
+ break;
809
+ case '$null':
810
+ // $null: true → field is null, $null: false → field is not null
811
+ // Use $eq/$ne null for Mingo compatibility
812
+ if (val === true) {
813
+ result.$eq = null;
814
+ } else {
815
+ result.$ne = null;
816
+ }
817
+ break;
818
+ default:
819
+ result[op] = val;
820
+ break;
821
+ }
822
+ }
823
+
824
+ // Merge regex conditions: single → inline, multiple → wrap with $and
825
+ if (regexConditions.length === 1) {
826
+ Object.assign(result, regexConditions[0]);
827
+ } else if (regexConditions.length > 1) {
828
+ // Cannot have multiple $regex on one object; promote to top-level $and.
829
+ // _multiRegex is an internal sentinel consumed by normalizeFilterCondition().
830
+ result._multiRegex = regexConditions;
831
+ }
832
+
833
+ return result;
834
+ }
835
+
711
836
  /**
712
837
  * Escape special regex characters for safe literal matching.
713
838
  */
@@ -62,11 +62,6 @@ export function match(record: RecordType, filter: any): boolean {
62
62
  * Access nested properties via dot-notation (e.g. "user.name")
63
63
  */
64
64
  export function getValueByPath(obj: any, path: string): any {
65
- // Compatibility: Map _id to id if _id is missing
66
- if (path === '_id' && obj._id === undefined && obj.id !== undefined) {
67
- return obj.id;
68
- }
69
-
70
65
  if (!path.includes('.')) return obj[path];
71
66
  return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj);
72
67
  }
@@ -103,7 +98,7 @@ function checkCondition(value: any, condition: any): boolean {
103
98
  const target = condition[op];
104
99
 
105
100
  // Handle undefined values
106
- if (value === undefined && op !== '$exists' && op !== '$ne') {
101
+ if (value === undefined && op !== '$exists' && op !== '$ne' && op !== '$null') {
107
102
  return false;
108
103
  }
109
104
 
@@ -151,12 +146,20 @@ function checkCondition(value: any, condition: any): boolean {
151
146
  case '$contains':
152
147
  if (typeof value !== 'string' || !value.includes(target)) return false;
153
148
  break;
149
+ case '$notContains':
150
+ if (typeof value !== 'string' || value.includes(target)) return false;
151
+ break;
154
152
  case '$startsWith':
155
153
  if (typeof value !== 'string' || !value.startsWith(target)) return false;
156
154
  break;
157
155
  case '$endsWith':
158
156
  if (typeof value !== 'string' || !value.endsWith(target)) return false;
159
157
  break;
158
+ case '$null':
159
+ // $null: true → value must be null/undefined; $null: false → value must not be null/undefined
160
+ if (target === true && value != null) return false;
161
+ if (target === false && value == null) return false;
162
+ break;
160
163
  case '$regex':
161
164
  try {
162
165
  const re = new RegExp(target, condition.$options || '');