Skip to content

Commit 2ae689c

Browse files
Copilothotlong
andcommitted
feat: add notContains operator support to filter DSL
Add $notContains to StringOperatorSchema, FieldOperatorsSchema, Filter<T>, FILTER_OPERATORS, VALID_AST_OPERATORS, and AST_OPERATOR_MAP with both 'notcontains' and 'not_contains' AST aliases. Fixes #34 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ec6a21c commit 2ae689c

2 files changed

Lines changed: 22 additions & 3 deletions

File tree

packages/spec/src/data/filter.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ describe('StringOperatorSchema', () => {
111111
expect(() => StringOperatorSchema.parse(filter)).not.toThrow();
112112
});
113113

114+
it('should accept $notContains operator', () => {
115+
const filter = { $notContains: 'spam' };
116+
expect(() => StringOperatorSchema.parse(filter)).not.toThrow();
117+
});
118+
114119
it('should accept $endsWith operator', () => {
115120
const filter = { $endsWith: '.pdf' };
116121
expect(() => StringOperatorSchema.parse(filter)).not.toThrow();
@@ -826,6 +831,11 @@ describe('parseFilterAST', () => {
826831
expect(parseFilterAST(['name', 'like', 'John'])).toEqual({ name: { $contains: 'John' } });
827832
});
828833

834+
it('should convert notcontains/not_contains operator', () => {
835+
expect(parseFilterAST(['name', 'notcontains', 'spam'])).toEqual({ name: { $notContains: 'spam' } });
836+
expect(parseFilterAST(['name', 'not_contains', 'spam'])).toEqual({ name: { $notContains: 'spam' } });
837+
});
838+
829839
it('should convert startswith operator', () => {
830840
expect(parseFilterAST(['name', 'startswith', 'A'])).toEqual({ name: { $startsWith: 'A' } });
831841
expect(parseFilterAST(['name', 'starts_with', 'A'])).toEqual({ name: { $startsWith: 'A' } });
@@ -948,6 +958,8 @@ describe('isFilterAST', () => {
948958
expect(isFilterAST(['age', '>=', 18])).toBe(true);
949959
expect(isFilterAST(['role', 'in', ['admin', 'editor']])).toBe(true);
950960
expect(isFilterAST(['name', 'contains', 'John'])).toBe(true);
961+
expect(isFilterAST(['name', 'notcontains', 'spam'])).toBe(true);
962+
expect(isFilterAST(['name', 'not_contains', 'spam'])).toBe(true);
951963
expect(isFilterAST(['name', 'like', 'John'])).toBe(true);
952964
expect(isFilterAST(['created_at', 'between', ['2024-01-01', '2024-12-31']])).toBe(true);
953965
expect(isFilterAST(['deleted_at', 'is_null', null])).toBe(true);
@@ -1009,7 +1021,7 @@ describe('isFilterAST', () => {
10091021
describe('VALID_AST_OPERATORS', () => {
10101022
it('should contain all standard comparison operators', () => {
10111023
const expected = ['=', '==', '!=', '<>', '>', '>=', '<', '<=', 'in', 'nin', 'not_in',
1012-
'contains', 'like', 'startswith', 'starts_with', 'endswith', 'ends_with',
1024+
'contains', 'notcontains', 'not_contains', 'like', 'startswith', 'starts_with', 'endswith', 'ends_with',
10131025
'between', 'is_null', 'is_not_null'];
10141026
for (const op of expected) {
10151027
expect(VALID_AST_OPERATORS.has(op)).toBe(true);

packages/spec/src/data/filter.zod.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ export const StringOperatorSchema = z.object({
112112
/** Contains substring - SQL: LIKE %?% | MongoDB: $regex */
113113
$contains: z.string().optional(),
114114

115+
/** Does not contain substring - SQL: NOT LIKE %?% | MongoDB: $not: $regex */
116+
$notContains: z.string().optional(),
117+
115118
/** Starts with prefix - SQL: LIKE ?% | MongoDB: $regex */
116119
$startsWith: z.string().optional(),
117120

@@ -163,6 +166,7 @@ export const FieldOperatorsSchema = z.object({
163166

164167
// String-specific
165168
$contains: z.string().optional(),
169+
$notContains: z.string().optional(),
166170
$startsWith: z.string().optional(),
167171
$endsWith: z.string().optional(),
168172

@@ -272,6 +276,7 @@ export type Filter<T = any> = {
272276
$nin?: T[K][];
273277
$between?: T[K] extends number | Date ? [T[K], T[K]] : never;
274278
$contains?: T[K] extends string ? string : never;
279+
$notContains?: T[K] extends string ? string : never;
275280
$startsWith?: T[K] extends string ? string : never;
276281
$endsWith?: T[K] extends string ? string : never;
277282
$null?: boolean;
@@ -346,7 +351,7 @@ export type NormalizedFilter = z.infer<typeof NormalizedFilterSchema>;
346351
export const VALID_AST_OPERATORS = new Set([
347352
'=', '==', '!=', '<>', '>', '>=', '<', '<=',
348353
'in', 'nin', 'not_in',
349-
'contains', 'like',
354+
'contains', 'notcontains', 'not_contains', 'like',
350355
'startswith', 'starts_with',
351356
'endswith', 'ends_with',
352357
'between',
@@ -418,6 +423,8 @@ const AST_OPERATOR_MAP: Record<string, string> = {
418423
'nin': '$nin',
419424
'not_in': '$nin',
420425
'contains': '$contains',
426+
'notcontains': '$notContains',
427+
'not_contains': '$notContains',
421428
'like': '$contains',
422429
'startswith': '$startsWith',
423430
'starts_with': '$startsWith',
@@ -532,7 +539,7 @@ export const FILTER_OPERATORS = [
532539
// Set & Range
533540
'$in', '$nin', '$between',
534541
// String
535-
'$contains', '$startsWith', '$endsWith',
542+
'$contains', '$notContains', '$startsWith', '$endsWith',
536543
// Special
537544
'$null', '$exists',
538545
] as const;

0 commit comments

Comments
 (0)