Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/plugins/driver-memory/src/memory-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
129 changes: 127 additions & 2 deletions packages/plugins/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand All @@ -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<string, any>): Record<string, any> {
const result: Record<string, any> = {};
const extraAndConditions: Record<string, any>[] = [];

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<string, any>[] = 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<string, any>): Record<string, any> {
const result: Record<string, any> = {};
const regexConditions: Record<string, any>[] = [];

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;
Comment on lines +791 to +802

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In normalizeFieldOperators, the cases for $contains, $startsWith, and $endsWith all write to result.$regex. If a filter has more than one of these operators on the same field (e.g., { name: { $startsWith: 'A', $endsWith: 'n' } }), the second operator silently overwrites the $regex set by the first. Only the last processed operator's regex pattern is applied, and the earlier constraints are lost. To fix this, each of these operators should be combined into a single regex (e.g., concatenating both ^ and $ anchors into one expression), or this limitation should be documented.

Copilot uses AI. Check for mistakes.
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.
*/
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/driver-memory/src/memory-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Comment on lines +163 to +167

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In checkCondition, there is an early-return guard at line 106: if (value === undefined && op !== '$exists' && op !== '$ne') { return false; }. Because $null is not excluded from this guard, when a field is missing (value === undefined) and the operator is $null: true, the guard fires and returns false — meaning $null: true fails to match fields that don't exist on a record. Since $null: true is semantically "field is null or missing", the guard should also exclude $null (i.e., the condition should be op !== '$exists' && op !== '$ne' && op !== '$null') so that the $null case in the switch can evaluate correctly for undefined values.

Copilot uses AI. Check for mistakes.
case '$regex':
try {
const re = new RegExp(target, condition.$options || '');
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/src/data/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' } });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading