diff --git a/packages/plugins/driver-memory/src/memory-driver.test.ts b/packages/plugins/driver-memory/src/memory-driver.test.ts index 28d406e2d3..ef410f34b5 100644 --- a/packages/plugins/driver-memory/src/memory-driver.test.ts +++ b/packages/plugins/driver-memory/src/memory-driver.test.ts @@ -589,4 +589,132 @@ describe('InMemoryDriver', () => { expect(results).toHaveLength(3); }); }); + + describe('FilterCondition Object Format (from parseFilterAST)', () => { + beforeEach(async () => { + await driver.create(testTable, { id: '1', name: 'Alice Johnson', age: 30, email: 'alice@example.com', bio: null }); + await driver.create(testTable, { id: '2', name: 'Bob Smith', age: 25, email: 'bob@test.com', bio: 'Developer' }); + await driver.create(testTable, { id: '3', name: 'Charlie Brown', age: 35, email: 'charlie@example.com', bio: '' }); + await driver.create(testTable, { id: '4', name: 'Evan Davis', age: 28, email: 'evan@test.com', bio: 'Designer' }); + }); + + it('should filter with $contains operator', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { name: { $contains: 'Evan' } }, + }); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Evan Davis'); + }); + + it('should filter with $contains case-insensitively', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { name: { $contains: 'alice' } }, + }); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Alice Johnson'); + }); + + it('should filter with $notContains operator', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { email: { $notContains: 'example' } }, + }); + expect(results).toHaveLength(2); + expect(results.map((r: any) => r.name).sort()).toEqual(['Bob Smith', 'Evan Davis']); + }); + + it('should filter with $startsWith operator', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { name: { $startsWith: 'Ch' } }, + }); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Charlie Brown'); + }); + + it('should filter with $endsWith operator', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { email: { $endsWith: '.com' } }, + }); + expect(results).toHaveLength(4); + }); + + it('should filter with $between operator', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { age: { $between: [26, 32] } }, + }); + expect(results).toHaveLength(2); + expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Evan Davis']); + }); + + it('should filter with $null: true', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { bio: { $null: true } }, + }); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Alice Johnson'); + }); + + it('should filter with $null: false', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { bio: { $null: false } }, + }); + expect(results).toHaveLength(3); + }); + + it('should handle $contains inside $and', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { $and: [{ name: { $contains: 'a' } }, { age: { $gte: 30 } }] }, + }); + expect(results).toHaveLength(2); + expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Charlie Brown']); + }); + + it('should handle $startsWith inside $or', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { $or: [{ name: { $startsWith: 'Al' } }, { name: { $startsWith: 'Ev' } }] }, + }); + expect(results).toHaveLength(2); + expect(results.map((r: any) => r.name).sort()).toEqual(['Alice Johnson', 'Evan Davis']); + }); + + it('should handle AST-converted notcontains via convertConditionToMongo', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { type: 'comparison', field: 'name', operator: 'notcontains', value: 'Bob' }, + }); + expect(results).toHaveLength(3); + }); + + it('should handle combined $startsWith + $endsWith on same field', async () => { + const results = await driver.find(testTable, { + object: testTable, + where: { name: { $startsWith: 'A', $endsWith: 'son' } }, + }); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Alice Johnson'); + }); + + it('should filter with $null: true on missing fields', async () => { + // bio is null for Alice Johnson — should match + // Records without bio field at all should also match $null: true + await driver.create(testTable, { id: '5', name: 'Frank', age: 40, email: 'frank@test.com' }); + const results = await driver.find(testTable, { + object: testTable, + where: { bio: { $null: true } }, + }); + // Alice (bio: null), Frank (bio: missing) + expect(results.length).toBeGreaterThanOrEqual(2); + expect(results.map((r: any) => r.name)).toContain('Alice Johnson'); + expect(results.map((r: any) => r.name)).toContain('Frank'); + }); + }); }); diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index dadfddcd22..daafbf0883 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -629,8 +629,9 @@ export class InMemoryDriver implements DriverInterface { const op = filters.operator === 'or' ? '$or' : '$and'; return { [op]: conditions }; } - // MongoDB format passthrough: { field: value } or { field: { $eq: value } } - return filters; + // MongoDB/FilterCondition format: { field: value } or { field: { $op: value } } + // Translate non-standard operators ($contains, $notContains, etc.) to Mingo-compatible format + return this.normalizeFilterCondition(filters); } // Legacy array format @@ -694,6 +695,8 @@ export class InMemoryDriver implements DriverInterface { return { [field]: { $nin: value } }; case 'contains': case 'like': return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } }; + case 'notcontains': case 'not_contains': + return { [field]: { $not: { $regex: new RegExp(this.escapeRegex(value), 'i') } } }; case 'startswith': case 'starts_with': return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } }; case 'endswith': case 'ends_with': @@ -708,6 +711,128 @@ export class InMemoryDriver implements DriverInterface { } } + /** + * Normalize a FilterCondition object by converting non-standard $-prefixed + * operators ($contains, $notContains, $startsWith, $endsWith, $between, $null) + * to Mingo-compatible equivalents ($regex, $gte/$lte, null checks). + */ + private normalizeFilterCondition(filter: Record): Record { + const result: Record = {}; + const extraAndConditions: Record[] = []; + + for (const key of Object.keys(filter)) { + const value = filter[key]; + // Recurse into logical operators + if (key === '$and' || key === '$or') { + result[key] = Array.isArray(value) + ? value.map((child: any) => this.normalizeFilterCondition(child)) + : value; + continue; + } + if (key === '$not') { + result[key] = value && typeof value === 'object' + ? this.normalizeFilterCondition(value) + : value; + continue; + } + // Skip $-prefixed keys that aren't field names (already handled or unknown) + if (key.startsWith('$')) { + result[key] = value; + continue; + } + // Field-level: value may be primitive (implicit eq) or operator object + if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp)) { + const normalized = this.normalizeFieldOperators(value); + // Handle multiple regex conditions on the same field (e.g. $startsWith + $endsWith) + if (normalized._multiRegex) { + const regexConditions: Record[] = normalized._multiRegex; + delete normalized._multiRegex; + // Each regex becomes its own { field: { $regex: ... } } inside $and + for (const rc of regexConditions) { + extraAndConditions.push({ [key]: { ...normalized, ...rc } }); + } + } else { + result[key] = normalized; + } + } else { + result[key] = value; + } + } + + // Merge extra $and conditions from multi-regex fields + if (extraAndConditions.length > 0) { + const existing = result.$and; + const andArray = Array.isArray(existing) ? existing : []; + // Include the rest of result as a condition too + if (Object.keys(result).filter(k => k !== '$and').length > 0) { + const rest = { ...result }; + delete rest.$and; + andArray.push(rest); + } + andArray.push(...extraAndConditions); + return { $and: andArray }; + } + + return result; + } + + /** + * Convert non-standard field operators to Mingo-compatible format. + * When multiple regex-producing operators appear on the same field + * (e.g. $startsWith + $endsWith), they are combined via $and. + */ + private normalizeFieldOperators(ops: Record): Record { + const result: Record = {}; + const regexConditions: Record[] = []; + + for (const op of Object.keys(ops)) { + const val = ops[op]; + switch (op) { + case '$contains': + regexConditions.push({ $regex: new RegExp(this.escapeRegex(val), 'i') }); + break; + case '$notContains': + result.$not = { $regex: new RegExp(this.escapeRegex(val), 'i') }; + break; + case '$startsWith': + regexConditions.push({ $regex: new RegExp(`^${this.escapeRegex(val)}`, 'i') }); + break; + case '$endsWith': + regexConditions.push({ $regex: new RegExp(`${this.escapeRegex(val)}$`, 'i') }); + break; + case '$between': + if (Array.isArray(val) && val.length === 2) { + result.$gte = val[0]; + result.$lte = val[1]; + } + break; + case '$null': + // $null: true → field is null, $null: false → field is not null + // Use $eq/$ne null for Mingo compatibility + if (val === true) { + result.$eq = null; + } else { + result.$ne = null; + } + break; + default: + result[op] = val; + break; + } + } + + // Merge regex conditions: single → inline, multiple → wrap with $and + if (regexConditions.length === 1) { + Object.assign(result, regexConditions[0]); + } else if (regexConditions.length > 1) { + // Cannot have multiple $regex on one object; promote to top-level $and. + // _multiRegex is an internal sentinel consumed by normalizeFilterCondition(). + result._multiRegex = regexConditions; + } + + return result; + } + /** * Escape special regex characters for safe literal matching. */ diff --git a/packages/plugins/driver-memory/src/memory-matcher.ts b/packages/plugins/driver-memory/src/memory-matcher.ts index 4267f03f70..f7a7d1acfb 100644 --- a/packages/plugins/driver-memory/src/memory-matcher.ts +++ b/packages/plugins/driver-memory/src/memory-matcher.ts @@ -103,7 +103,7 @@ function checkCondition(value: any, condition: any): boolean { const target = condition[op]; // Handle undefined values - if (value === undefined && op !== '$exists' && op !== '$ne') { + if (value === undefined && op !== '$exists' && op !== '$ne' && op !== '$null') { return false; } @@ -151,12 +151,20 @@ function checkCondition(value: any, condition: any): boolean { case '$contains': if (typeof value !== 'string' || !value.includes(target)) return false; break; + case '$notContains': + if (typeof value !== 'string' || value.includes(target)) return false; + break; case '$startsWith': if (typeof value !== 'string' || !value.startsWith(target)) return false; break; case '$endsWith': if (typeof value !== 'string' || !value.endsWith(target)) return false; break; + case '$null': + // $null: true → value must be null/undefined; $null: false → value must not be null/undefined + if (target === true && value != null) return false; + if (target === false && value == null) return false; + break; case '$regex': try { const re = new RegExp(target, condition.$options || ''); diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 85173a8126..6a2b8cf4cd 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -111,6 +111,11 @@ describe('StringOperatorSchema', () => { expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); }); + it('should accept $notContains operator', () => { + const filter = { $notContains: 'spam' }; + expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); + }); + it('should accept $endsWith operator', () => { const filter = { $endsWith: '.pdf' }; expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); @@ -826,6 +831,11 @@ describe('parseFilterAST', () => { expect(parseFilterAST(['name', 'like', 'John'])).toEqual({ name: { $contains: 'John' } }); }); + it('should convert notcontains/not_contains operator', () => { + expect(parseFilterAST(['name', 'notcontains', 'spam'])).toEqual({ name: { $notContains: 'spam' } }); + expect(parseFilterAST(['name', 'not_contains', 'spam'])).toEqual({ name: { $notContains: 'spam' } }); + }); + it('should convert startswith operator', () => { expect(parseFilterAST(['name', 'startswith', 'A'])).toEqual({ name: { $startsWith: 'A' } }); expect(parseFilterAST(['name', 'starts_with', 'A'])).toEqual({ name: { $startsWith: 'A' } }); @@ -948,6 +958,8 @@ describe('isFilterAST', () => { expect(isFilterAST(['age', '>=', 18])).toBe(true); expect(isFilterAST(['role', 'in', ['admin', 'editor']])).toBe(true); expect(isFilterAST(['name', 'contains', 'John'])).toBe(true); + expect(isFilterAST(['name', 'notcontains', 'spam'])).toBe(true); + expect(isFilterAST(['name', 'not_contains', 'spam'])).toBe(true); expect(isFilterAST(['name', 'like', 'John'])).toBe(true); expect(isFilterAST(['created_at', 'between', ['2024-01-01', '2024-12-31']])).toBe(true); expect(isFilterAST(['deleted_at', 'is_null', null])).toBe(true); @@ -1009,7 +1021,7 @@ describe('isFilterAST', () => { describe('VALID_AST_OPERATORS', () => { it('should contain all standard comparison operators', () => { const expected = ['=', '==', '!=', '<>', '>', '>=', '<', '<=', 'in', 'nin', 'not_in', - 'contains', 'like', 'startswith', 'starts_with', 'endswith', 'ends_with', + 'contains', 'notcontains', 'not_contains', 'like', 'startswith', 'starts_with', 'endswith', 'ends_with', 'between', 'is_null', 'is_not_null']; for (const op of expected) { expect(VALID_AST_OPERATORS.has(op)).toBe(true); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index e81139ac89..6ea23d72b2 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -112,6 +112,9 @@ export const StringOperatorSchema = z.object({ /** Contains substring - SQL: LIKE %?% | MongoDB: $regex */ $contains: z.string().optional(), + /** Does not contain substring - SQL: NOT LIKE %?% | MongoDB: $not: $regex */ + $notContains: z.string().optional(), + /** Starts with prefix - SQL: LIKE ?% | MongoDB: $regex */ $startsWith: z.string().optional(), @@ -163,6 +166,7 @@ export const FieldOperatorsSchema = z.object({ // String-specific $contains: z.string().optional(), + $notContains: z.string().optional(), $startsWith: z.string().optional(), $endsWith: z.string().optional(), @@ -272,6 +276,7 @@ export type Filter = { $nin?: T[K][]; $between?: T[K] extends number | Date ? [T[K], T[K]] : never; $contains?: T[K] extends string ? string : never; + $notContains?: T[K] extends string ? string : never; $startsWith?: T[K] extends string ? string : never; $endsWith?: T[K] extends string ? string : never; $null?: boolean; @@ -346,7 +351,7 @@ export type NormalizedFilter = z.infer; export const VALID_AST_OPERATORS = new Set([ '=', '==', '!=', '<>', '>', '>=', '<', '<=', 'in', 'nin', 'not_in', - 'contains', 'like', + 'contains', 'notcontains', 'not_contains', 'like', 'startswith', 'starts_with', 'endswith', 'ends_with', 'between', @@ -418,6 +423,8 @@ const AST_OPERATOR_MAP: Record = { 'nin': '$nin', 'not_in': '$nin', 'contains': '$contains', + 'notcontains': '$notContains', + 'not_contains': '$notContains', 'like': '$contains', 'startswith': '$startsWith', 'starts_with': '$startsWith', @@ -532,7 +539,7 @@ export const FILTER_OPERATORS = [ // Set & Range '$in', '$nin', '$between', // String - '$contains', '$startsWith', '$endsWith', + '$contains', '$notContains', '$startsWith', '$endsWith', // Special '$null', '$exists', ] as const;