@objectstack/driver-memory 3.2.0 → 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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +24 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +102 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +102 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/memory-driver.test.ts +128 -0
- package/src/memory-driver.ts +127 -2
- package/src/memory-matcher.ts +9 -6
|
@@ -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
|
});
|
package/src/memory-driver.ts
CHANGED
|
@@ -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
|
|
633
|
-
|
|
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
|
*/
|
package/src/memory-matcher.ts
CHANGED
|
@@ -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 || '');
|