@mastra/mongodb 0.0.2-alpha.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.
@@ -0,0 +1,415 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ import { MongoDBFilterTranslator } from './filter';
4
+
5
+ describe('MongoDBFilterTranslator', () => {
6
+ let translator: MongoDBFilterTranslator;
7
+
8
+ beforeEach(() => {
9
+ translator = new MongoDBFilterTranslator();
10
+ });
11
+
12
+ // Basic Filter Operations
13
+ describe('basic operations', () => {
14
+ it('handles simple equality', () => {
15
+ const filter = { field: 'value' };
16
+ expect(translator.translate(filter)).toEqual(filter);
17
+ });
18
+
19
+ it('handles comparison operators', () => {
20
+ const filter = {
21
+ age: { $gt: 25 },
22
+ score: { $lte: 100 },
23
+ };
24
+ expect(translator.translate(filter)).toEqual(filter);
25
+ });
26
+
27
+ it('handles valid multiple operators on same field', () => {
28
+ const filter = {
29
+ price: { $gt: 100, $lt: 200 },
30
+ quantity: { $gte: 10, $lte: 20 },
31
+ };
32
+ expect(translator.translate(filter)).toEqual(filter);
33
+ });
34
+
35
+ it('handles null values correctly', () => {
36
+ const filter = {
37
+ field: null,
38
+ other: { $eq: null },
39
+ };
40
+ expect(translator.translate(filter)).toEqual(filter);
41
+ });
42
+
43
+ it('handles boolean values correctly', () => {
44
+ const filter = {
45
+ active: true,
46
+ deleted: false,
47
+ status: { $eq: true }
48
+ };
49
+ expect(translator.translate(filter)).toEqual(filter);
50
+ });
51
+ });
52
+
53
+ // Array Operations
54
+ describe('array operations', () => {
55
+ it('handles array operators', () => {
56
+ const filter = {
57
+ tags: { $all: ['tag1', 'tag2'] },
58
+ categories: { $in: ['A', 'B'] },
59
+ items: { $nin: ['item1', 'item2'] },
60
+ scores: { $elemMatch: { $gt: 90 } }
61
+ };
62
+ expect(translator.translate(filter)).toEqual(filter);
63
+ });
64
+
65
+ it('handles empty array values', () => {
66
+ const filter = {
67
+ tags: { $in: [] },
68
+ categories: { $all: [] },
69
+ };
70
+ expect(translator.translate(filter)).toEqual(filter);
71
+ });
72
+
73
+ it('handles nested array operators', () => {
74
+ const filter = {
75
+ $and: [{ tags: { $all: ['tag1', 'tag2'] } }, { 'nested.array': { $in: [1, 2, 3] } }],
76
+ };
77
+ expect(translator.translate(filter)).toEqual(filter);
78
+ });
79
+
80
+ it('handles $size operator', () => {
81
+ const filter = {
82
+ tags: { $size: 3 }
83
+ };
84
+ expect(translator.translate(filter)).toEqual(filter);
85
+ });
86
+ });
87
+
88
+ // Logical Operators
89
+ describe('logical operators', () => {
90
+ it('handles logical operators', () => {
91
+ const filter = {
92
+ $or: [{ status: 'active' }, { age: { $gt: 25 } }],
93
+ };
94
+ expect(translator.translate(filter)).toEqual(filter);
95
+ });
96
+
97
+ it('handles $not operator', () => {
98
+ const filter = {
99
+ field: { $not: { $eq: 'value' } },
100
+ $not: { field: 'value' }
101
+ };
102
+ expect(translator.translate(filter)).toEqual(filter);
103
+ });
104
+
105
+ it('handles $nor operator', () => {
106
+ const filter = {
107
+ $nor: [{ status: 'deleted' }, { active: false }]
108
+ };
109
+ expect(translator.translate(filter)).toEqual(filter);
110
+ });
111
+
112
+ it('handles nested logical operators', () => {
113
+ const filter = {
114
+ $and: [
115
+ { status: 'active' },
116
+ { $or: [{ category: { $in: ['A', 'B'] } }, { $and: [{ price: { $gt: 100 } }, { stock: { $lt: 50 } }] }] },
117
+ ],
118
+ };
119
+ expect(translator.translate(filter)).toEqual(filter);
120
+ });
121
+
122
+ it('handles empty conditions in logical operators', () => {
123
+ const filter = {
124
+ $and: [],
125
+ $or: [{}],
126
+ field: 'value',
127
+ };
128
+ expect(translator.translate(filter)).toEqual(filter);
129
+ });
130
+
131
+ it('allows multiple logical operators at root level with complex conditions', () => {
132
+ expect(() =>
133
+ translator.translate({
134
+ $and: [{ field1: { $gt: 10 } }],
135
+ $or: [{ field2: { $lt: 20 } }],
136
+ field4: { $not: { $eq: 'value' } },
137
+ }),
138
+ ).not.toThrow();
139
+ });
140
+
141
+ it('allows logical operators at root level', () => {
142
+ expect(() =>
143
+ translator.translate({
144
+ $and: [{ field1: 'value1' }, { field2: 'value2' }],
145
+ $or: [{ field3: 'value3' }, { field4: 'value4' }],
146
+ }),
147
+ ).not.toThrow();
148
+ });
149
+
150
+ it('allows logical operators nested within other logical operators', () => {
151
+ expect(() =>
152
+ translator.translate({
153
+ $and: [
154
+ {
155
+ $or: [{ field1: 'value1' }, { field2: 'value2' }],
156
+ },
157
+ {
158
+ $and: [{ field3: 'value3' }, { field4: 'value4' }],
159
+ },
160
+ ],
161
+ }),
162
+ ).not.toThrow();
163
+ });
164
+ });
165
+
166
+ // Logical Operator Validation
167
+ describe('logical operator validation', () => {
168
+ it('throws error for direct operators in logical operator arrays', () => {
169
+ expect(() =>
170
+ translator.translate({
171
+ $and: [{ $eq: 'value' }, { $gt: 100 }],
172
+ }),
173
+ ).toThrow(/Logical operators must contain field conditions/);
174
+
175
+ expect(() =>
176
+ translator.translate({
177
+ $or: [{ $in: ['value1', 'value2'] }],
178
+ }),
179
+ ).toThrow(/Logical operators must contain field conditions/);
180
+ });
181
+
182
+ it('throws error for deeply nested logical operators in non-logical contexts', () => {
183
+ expect(() =>
184
+ translator.translate({
185
+ field: {
186
+ $gt: {
187
+ $or: [{ subfield: 'value1' }, { subfield: 'value2' }],
188
+ },
189
+ },
190
+ }),
191
+ ).toThrow();
192
+
193
+ expect(() =>
194
+ translator.translate({
195
+ field: {
196
+ $in: [
197
+ {
198
+ $and: [{ subfield: 'value1' }, { subfield: 'value2' }],
199
+ },
200
+ ],
201
+ },
202
+ }),
203
+ ).toThrow();
204
+ });
205
+
206
+ it('throws error for logical operators nested in non-logical contexts', () => {
207
+ expect(() =>
208
+ translator.translate({
209
+ field: {
210
+ $gt: {
211
+ $or: [{ subfield: 'value1' }, { subfield: 'value2' }],
212
+ },
213
+ },
214
+ }),
215
+ ).toThrow();
216
+
217
+ expect(() =>
218
+ translator.translate({
219
+ field: {
220
+ $not: {
221
+ $and: [{ subfield: 'value1' }, { subfield: 'value2' }],
222
+ },
223
+ },
224
+ }),
225
+ ).not.toThrow(); // $not is allowed to contain logical operators
226
+ });
227
+
228
+ it('throws error for $not if not an object', () => {
229
+ expect(() => translator.translate({ $not: 'value' })).toThrow();
230
+ expect(() => translator.translate({ $not: [{ field: 'value' }] })).toThrow();
231
+ });
232
+
233
+ it('throws error for $not if empty', () => {
234
+ expect(() => translator.translate({ $not: {} })).toThrow();
235
+ });
236
+ });
237
+
238
+ // Nested Objects and Fields
239
+ describe('nested objects and fields', () => {
240
+ it('handles nested objects', () => {
241
+ const filter = {
242
+ 'user.profile.age': { $gt: 25 },
243
+ 'user.status': 'active',
244
+ };
245
+ expect(translator.translate(filter)).toEqual(filter);
246
+ });
247
+
248
+ it('handles deeply nested field paths', () => {
249
+ const filter = {
250
+ 'user.profile.address.city': { $eq: 'New York' },
251
+ 'deep.nested.field': { $gt: 100 },
252
+ };
253
+ expect(translator.translate(filter)).toEqual(filter);
254
+ });
255
+
256
+ it('preserves nested empty objects', () => {
257
+ const filter = {
258
+ status: 'active',
259
+ metadata: {},
260
+ user: {
261
+ profile: {},
262
+ settings: { theme: null },
263
+ },
264
+ };
265
+ expect(translator.translate(filter)).toEqual(filter);
266
+ });
267
+
268
+ it('handles mix of operators and empty objects', () => {
269
+ const filter = {
270
+ tags: { $in: ['a', 'b'] },
271
+ metadata: {},
272
+ nested: {
273
+ field: { $eq: 'value' },
274
+ empty: {},
275
+ },
276
+ };
277
+ expect(translator.translate(filter)).toEqual(filter);
278
+ });
279
+
280
+ it('handles deeply nested operators', () => {
281
+ const filter = {
282
+ user: {
283
+ profile: {
284
+ preferences: {
285
+ theme: { $in: ['dark', 'light'] },
286
+ },
287
+ },
288
+ },
289
+ };
290
+ expect(translator.translate(filter)).toEqual(filter);
291
+ });
292
+ });
293
+
294
+ // Special Cases
295
+ describe('special cases', () => {
296
+ it('handles empty filters', () => {
297
+ expect(translator.translate({})).toEqual({});
298
+ expect(translator.translate(null as any)).toEqual(null);
299
+ expect(translator.translate(undefined as any)).toEqual(undefined);
300
+ });
301
+
302
+ it('normalizes dates', () => {
303
+ const date = new Date('2024-01-01');
304
+ const filter = { timestamp: { $gt: date } };
305
+ expect(translator.translate(filter)).toEqual({
306
+ timestamp: { $gt: date.toISOString() },
307
+ });
308
+ });
309
+
310
+ it('allows $not in field-level conditions', () => {
311
+ expect(() =>
312
+ translator.translate({
313
+ field1: { $not: { $eq: 'value1' } },
314
+ field2: { $not: { $in: ['value2', 'value3'] } },
315
+ field3: { $not: { $regex: 'pattern' } },
316
+ }),
317
+ ).not.toThrow();
318
+ });
319
+ });
320
+
321
+ // Regex Support
322
+ describe('regex support', () => {
323
+ it('handles $regex operator', () => {
324
+ const filter = {
325
+ name: { $regex: '^test' }
326
+ };
327
+ expect(translator.translate(filter)).toEqual(filter);
328
+ });
329
+
330
+ it('handles RegExp objects', () => {
331
+ const filter = {
332
+ name: /^test/i
333
+ };
334
+ // RegExp objects should be preserved
335
+ expect(translator.translate(filter)).toEqual(filter);
336
+ });
337
+ });
338
+
339
+ describe('operator validation', () => {
340
+ it('ensures all supported operator filters are accepted', () => {
341
+ const supportedFilters = [
342
+ // Basic comparison operators
343
+ { field: { $eq: 'value' } },
344
+ { field: { $ne: 'value' } },
345
+ { field: { $gt: 'value' } },
346
+ { field: { $gte: 'value' } },
347
+ { field: { $lt: 'value' } },
348
+ { field: { $lte: 'value' } },
349
+
350
+ // Array operators
351
+ { field: { $in: ['value'] } },
352
+ { field: { $nin: ['value'] } },
353
+ { field: { $all: ['value'] } },
354
+ { field: { $elemMatch: { $gt: 5 } } },
355
+
356
+ // Existence
357
+ { field: { $exists: true } },
358
+
359
+ // Logical operators
360
+ { $and: [{ field1: 'value1' }, { field2: 'value2' }] },
361
+ { $or: [{ field1: 'value1' }, { field2: 'value2' }] },
362
+ { $nor: [{ field1: 'value1' }, { field2: 'value2' }] },
363
+ { $not: { field: 'value' } },
364
+
365
+ // Nested logical operators
366
+ { $or: [{ $and: [{ field1: 'value1' }] }, { $not: { field2: 'value2' } }] },
367
+
368
+ // Field-level $not
369
+ { field: { $not: { $eq: 'value' } } },
370
+ { field: { $not: { $in: ['value1', 'value2'] } } },
371
+ { field: { $not: { $gt: 100 } } },
372
+ { field: { $not: { $lt: 50 } } },
373
+
374
+ // Custom operators
375
+ { field: { $size: 1 } },
376
+ { field: { $regex: 'pattern' } },
377
+ ];
378
+
379
+ supportedFilters.forEach(filter => {
380
+ expect(() => translator.translate(filter)).not.toThrow();
381
+ });
382
+ });
383
+
384
+ it('throws on unsupported operators', () => {
385
+ expect(() => translator.translate({ field: { $unknown: 'value' } })).toThrow('Unsupported operator: $unknown');
386
+ expect(() => translator.translate({ $unknown: [{ field: 'value' }] })).toThrow('Unsupported operator: $unknown');
387
+ });
388
+
389
+ it('throws error for non-logical operators at top level', () => {
390
+ const invalidFilters = [
391
+ { $gt: 100 },
392
+ { $in: ['value1', 'value2'] },
393
+ { $exists: true },
394
+ { $regex: 'pattern' }
395
+ ];
396
+
397
+ invalidFilters.forEach(filter => {
398
+ expect(() => translator.translate(filter)).toThrow(/Invalid top-level operator/);
399
+ });
400
+ });
401
+
402
+ it('allows logical operators at top level', () => {
403
+ const validFilters = [
404
+ { $and: [{ field: 'value' }] },
405
+ { $or: [{ field: 'value' }] },
406
+ { $nor: [{ field: 'value' }] },
407
+ { $not: { field: 'value' } },
408
+ ];
409
+
410
+ validFilters.forEach(filter => {
411
+ expect(() => translator.translate(filter)).not.toThrow();
412
+ });
413
+ });
414
+ });
415
+ });
@@ -0,0 +1,124 @@
1
+ import { BaseFilterTranslator } from '@mastra/core/vector/filter';
2
+ import type { FieldCondition, VectorFilter, OperatorSupport, QueryOperator } from '@mastra/core/vector/filter';
3
+
4
+ /**
5
+ * Translator for MongoDB filter queries.
6
+ * Maintains MongoDB-compatible syntax while ensuring proper validation
7
+ * and normalization of values.
8
+ */
9
+ export class MongoDBFilterTranslator extends BaseFilterTranslator {
10
+ protected override getSupportedOperators(): OperatorSupport {
11
+ return {
12
+ ...BaseFilterTranslator.DEFAULT_OPERATORS,
13
+ array: ['$all', '$in', '$nin'],
14
+ logical: ['$and', '$or', '$not', '$nor'],
15
+ regex: ['$regex'],
16
+ custom: ['$size', '$elemMatch'],
17
+ };
18
+ }
19
+
20
+ translate(filter?: VectorFilter): any {
21
+ if (this.isEmpty(filter)) return filter;
22
+ this.validateFilter(filter);
23
+
24
+ return this.translateNode(filter);
25
+ }
26
+
27
+ private translateNode(node: VectorFilter | FieldCondition): any {
28
+ // Handle primitive values and arrays
29
+ if (this.isRegex(node)) {
30
+ return node; // Return regex values as-is
31
+ }
32
+ if (this.isPrimitive(node)) return node;
33
+ if (Array.isArray(node)) return node;
34
+
35
+ const entries = Object.entries(node as Record<string, any>);
36
+ const translatedEntries = entries.map(([key, value]) => {
37
+ // Handle operators
38
+ if (this.isOperator(key)) {
39
+ return [key, this.translateOperatorValue(key, value)];
40
+ }
41
+
42
+ // Handle nested paths and objects
43
+ return [key, this.translateNode(value)];
44
+ });
45
+
46
+ return Object.fromEntries(translatedEntries);
47
+ }
48
+
49
+ private translateOperatorValue(operator: QueryOperator, value: any): any {
50
+ // Handle logical operators
51
+ if (this.isLogicalOperator(operator)) {
52
+ if (operator === '$not') {
53
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
54
+ throw new Error('$not operator requires an object');
55
+ }
56
+ if (this.isEmpty(value)) {
57
+ throw new Error('$not operator cannot be empty');
58
+ }
59
+ return this.translateNode(value);
60
+ } else {
61
+ if (!Array.isArray(value)) {
62
+ throw new Error(`Value for logical operator ${operator} must be an array`);
63
+ }
64
+ return value.map(item => this.translateNode(item));
65
+ }
66
+ }
67
+
68
+ // Handle basic and numeric operators
69
+ if (this.isBasicOperator(operator) || this.isNumericOperator(operator)) {
70
+ // Convert Date to ISO string
71
+ if (value instanceof Date) {
72
+ return value.toISOString();
73
+ }
74
+ return this.normalizeComparisonValue(value);
75
+ }
76
+
77
+ // Handle $elemMatch operator - place this before array operators check
78
+ if (operator === '$elemMatch') {
79
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
80
+ throw new Error(`Value for $elemMatch operator must be an object`);
81
+ }
82
+ return this.translateNode(value);
83
+ }
84
+
85
+ // Handle array operators
86
+ if (this.isArrayOperator(operator)) {
87
+ if (!Array.isArray(value)) {
88
+ throw new Error(`Value for array operator ${operator} must be an array`);
89
+ }
90
+ return this.normalizeArrayValues(value);
91
+ }
92
+
93
+ // Handle element operators
94
+ if (this.isElementOperator(operator)) {
95
+ if (operator === '$exists' && typeof value !== 'boolean') {
96
+ throw new Error(`Value for $exists operator must be a boolean`);
97
+ }
98
+ return value;
99
+ }
100
+
101
+ // Handle regex operators
102
+ if (this.isRegexOperator(operator)) {
103
+ if (!(value instanceof RegExp) && typeof value !== 'string') {
104
+ throw new Error(`Value for ${operator} operator must be a RegExp or string`);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ // Handle $size operator
110
+ if (operator === '$size') {
111
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
112
+ throw new Error(`Value for $size operator must be a non-negative integer`);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ // If we get here, the operator is not supported
118
+ throw new Error(`Unsupported operator: ${operator}`);
119
+ }
120
+
121
+ isEmpty(filter: any): boolean {
122
+ return filter === undefined || filter === null || (typeof filter === 'object' && Object.keys(filter).length === 0);
123
+ }
124
+ }