Skip to content

Commit 5d81d54

Browse files
committed
feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty)
Add does-not-contain ($ncontains), starts-with ($startsWith), ends-with ($endsWith), not-in-array ($nin, previously executed server-side but unexposed in the UI), and is-empty/is-not-empty ($empty) filter operators end-to-end — SQL builder, condition types, query-builder converters/constants, the filter UI, the Table tools/block descriptions, and docs. Also fix correctness bugs in the filter builder surfaced by the wider operator set: - Same-column AND rules (e.g. age > 18 AND age < 65, or name startsWith 'A' AND name endsWith 'Z') silently overwrote each other because the AND group was keyed by column name. They now merge into one operator object, which also makes Filter -> rules -> Filter round-trip losslessly for multi-operator columns. - $nin values were not split into an array like $in, and textual-match values like "123" were numeric-coerced (breaking the ILIKE path). - A non-boolean $empty operand from the raw API silently inverted the check; it now coerces 'true'/'false' strings and otherwise returns a 400.
1 parent b399ee0 commit 5d81d54

12 files changed

Lines changed: 370 additions & 28 deletions

File tree

apps/docs/content/docs/en/tools/table.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ Filters use MongoDB-style operators for flexible querying:
275275
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
276276
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
277277
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
278-
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
278+
| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` |
279+
| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` |
280+
| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` |
281+
| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` |
282+
| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` |
279283

280284
### Combining Filters
281285

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '@/components/emcn'
1313
import { ChevronDown, Plus } from '@/components/emcn/icons'
1414
import type { Filter, FilterRule } from '@/lib/table'
15-
import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants'
15+
import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants'
1616
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
1717

1818
const OPERATOR_LABELS = Object.fromEntries(
@@ -71,7 +71,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
7171
}, [])
7272

7373
const handleApply = useCallback(() => {
74-
const validRules = rulesRef.current.filter((r) => r.column && r.value)
74+
const validRules = rulesRef.current.filter(
75+
(r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator))
76+
)
7577
onApply(filterRulesToFilter(validRules))
7678
}, [onApply])
7779

@@ -197,16 +199,20 @@ const FilterRuleRow = memo(function FilterRuleRow({
197199
</DropdownMenuContent>
198200
</DropdownMenu>
199201

200-
<input
201-
type='text'
202-
value={rule.value}
203-
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
204-
onKeyDown={(e) => {
205-
if (e.key === 'Enter') onApply()
206-
}}
207-
placeholder='Enter a value'
208-
className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
209-
/>
202+
{VALUELESS_OPERATORS.has(rule.operator) ? (
203+
<div className='h-[28px] flex-1' />
204+
) : (
205+
<input
206+
type='text'
207+
value={rule.value}
208+
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
209+
onKeyDown={(e) => {
210+
if (e.key === 'Enter') onApply()
211+
}}
212+
placeholder='Enter a value'
213+
className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
214+
/>
215+
)}
210216

211217
<button
212218
onClick={() => onRemove(rule.id)}

apps/sim/blocks/blocks/table.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ IMPORTANT: Reference the table schema to know which columns exist and their type
379379
- **$in**: In array - {"column": {"$in": ["value1", "value2"]}}
380380
- **$nin**: Not in array - {"column": {"$nin": ["value1", "value2"]}}
381381
- **$contains**: String contains - {"column": {"$contains": "text"}}
382+
- **$ncontains**: Does not contain (matches empty cells) - {"column": {"$ncontains": "text"}}
383+
- **$startsWith**: Starts with - {"column": {"$startsWith": "text"}}
384+
- **$endsWith**: Ends with - {"column": {"$endsWith": "text"}}
385+
- **$empty**: Is empty (true) or non-empty (false) - {"column": {"$empty": true}}
382386
383387
### EXAMPLES
384388
@@ -467,6 +471,10 @@ IMPORTANT: Reference the table schema to know which columns exist and their type
467471
- **$in**: In array - {"column": {"$in": ["value1", "value2"]}}
468472
- **$nin**: Not in array - {"column": {"$nin": ["value1", "value2"]}}
469473
- **$contains**: String contains - {"column": {"$contains": "text"}}
474+
- **$ncontains**: Does not contain (matches empty cells) - {"column": {"$ncontains": "text"}}
475+
- **$startsWith**: Starts with - {"column": {"$startsWith": "text"}}
476+
- **$endsWith**: Ends with - {"column": {"$endsWith": "text"}}
477+
- **$empty**: Is empty (true) or non-empty (false) - {"column": {"$empty": true}}
470478
471479
### EXAMPLES
472480

apps/sim/lib/table/__tests__/sql.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,64 @@ describe('SQL Builder', () => {
135135
const out = render(buildFilterClause({ name: { $contains: 'john' } }, TABLE, NO_COLUMNS))
136136
expect(out).toContain(`${TABLE}.data->>'name'`)
137137
expect(out).toContain('ILIKE')
138+
expect(out).toContain('%john%')
139+
})
140+
141+
it('handles $ncontains as negated ILIKE that surfaces null cells', () => {
142+
const out = render(buildFilterClause({ name: { $ncontains: 'john' } }, TABLE, NO_COLUMNS))
143+
expect(out).toContain('IS NULL')
144+
expect(out).toContain('NOT ILIKE')
145+
expect(out).toContain('%john%')
146+
})
147+
148+
it('handles $startsWith with a trailing wildcard only', () => {
149+
const out = render(buildFilterClause({ name: { $startsWith: 'jo' } }, TABLE, NO_COLUMNS))
150+
expect(out).toContain('ILIKE')
151+
expect(out).toContain('jo%')
152+
expect(out).not.toContain('%jo%')
153+
})
154+
155+
it('handles $endsWith with a leading wildcard only', () => {
156+
const out = render(buildFilterClause({ file: { $endsWith: '.pdf' } }, TABLE, NO_COLUMNS))
157+
expect(out).toContain('ILIKE')
158+
expect(out).toContain('%.pdf')
159+
})
160+
161+
it('escapes ILIKE wildcards in pattern values', () => {
162+
const out = render(buildFilterClause({ name: { $contains: '50%_off' } }, TABLE, NO_COLUMNS))
163+
expect(out).toContain('50\\%\\_off')
164+
})
165+
166+
it('handles $empty: true as null-or-empty-string check', () => {
167+
const out = render(buildFilterClause({ phone: { $empty: true } }, TABLE, NO_COLUMNS))
168+
expect(out).toContain(`${TABLE}.data->>'phone'`)
169+
expect(out).toContain('IS NULL')
170+
expect(out).toContain("= ''")
171+
expect(out).toContain(' OR ')
172+
})
173+
174+
it('handles $empty: false as present-and-non-empty check', () => {
175+
const out = render(buildFilterClause({ phone: { $empty: false } }, TABLE, NO_COLUMNS))
176+
expect(out).toContain('IS NOT NULL')
177+
expect(out).toContain("<> ''")
178+
expect(out).toContain(' AND ')
179+
})
180+
181+
it('coerces string "true"/"false" $empty operands (lenient raw-API input)', () => {
182+
const truthy = render(
183+
buildFilterClause({ phone: { $empty: 'true' } } as Filter, TABLE, NO_COLUMNS)
184+
)
185+
expect(truthy).toContain('IS NULL')
186+
const falsy = render(
187+
buildFilterClause({ phone: { $empty: 'false' } } as Filter, TABLE, NO_COLUMNS)
188+
)
189+
expect(falsy).toContain('IS NOT NULL')
190+
})
191+
192+
it('throws on a non-boolean $empty operand rather than silently inverting', () => {
193+
expect(() =>
194+
buildFilterClause({ phone: { $empty: 1 } } as unknown as Filter, TABLE, NO_COLUMNS)
195+
).toThrow(/\$empty on column "phone" requires a boolean/)
138196
})
139197

140198
it('joins multiple top-level conditions with AND', () => {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Converter unit tests for the table query builder. Cover the operator
5+
* round-trips — UI rule → Filter object → UI rule — with attention to the
6+
* valueless `$empty` operator that maps to two distinct UI operators.
7+
*/
8+
import { describe, expect, it } from 'vitest'
9+
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
10+
import type { FilterRule } from '@/lib/table/types'
11+
12+
function rule(overrides: Partial<FilterRule>): FilterRule {
13+
return {
14+
id: 'rule-1',
15+
logicalOperator: 'and',
16+
column: 'name',
17+
operator: 'eq',
18+
value: '',
19+
...overrides,
20+
}
21+
}
22+
23+
describe('filterRulesToFilter', () => {
24+
it('emits a bare value for eq (containment shorthand)', () => {
25+
expect(filterRulesToFilter([rule({ operator: 'eq', value: 'John' })])).toEqual({ name: 'John' })
26+
})
27+
28+
it('wraps non-eq operators in a $-prefixed operator object', () => {
29+
expect(
30+
filterRulesToFilter([rule({ column: 'email', operator: 'startsWith', value: 'a' })])
31+
).toEqual({ email: { $startsWith: 'a' } })
32+
expect(
33+
filterRulesToFilter([rule({ column: 'email', operator: 'ncontains', value: 'x' })])
34+
).toEqual({ email: { $ncontains: 'x' } })
35+
})
36+
37+
it('parses comma-separated values into arrays for in / nin', () => {
38+
expect(
39+
filterRulesToFilter([rule({ column: 'status', operator: 'nin', value: 'a, b' })])
40+
).toEqual({ status: { $nin: ['a', 'b'] } })
41+
})
42+
43+
it('serializes isEmpty / isNotEmpty to $empty without a value', () => {
44+
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isEmpty' })])).toEqual({
45+
phone: { $empty: true },
46+
})
47+
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isNotEmpty' })])).toEqual({
48+
phone: { $empty: false },
49+
})
50+
})
51+
52+
it('merges two AND rules on the same column into one operator object', () => {
53+
const filter = filterRulesToFilter([
54+
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
55+
rule({ id: 'b', column: 'age', operator: 'lt', value: '65' }),
56+
])
57+
expect(filter).toEqual({ age: { $gt: 18, $lt: 65 } })
58+
})
59+
60+
it('normalizes a bare-equality shorthand when merging with an operator', () => {
61+
const filter = filterRulesToFilter([
62+
rule({ id: 'a', column: 'name', operator: 'eq', value: 'John' }),
63+
rule({ id: 'b', column: 'name', operator: 'contains', value: 'oh' }),
64+
])
65+
expect(filter).toEqual({ name: { $eq: 'John', $contains: 'oh' } })
66+
})
67+
68+
it('keeps same-column rules across an OR boundary in separate groups', () => {
69+
const filter = filterRulesToFilter([
70+
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
71+
rule({ id: 'b', logicalOperator: 'or', column: 'age', operator: 'lt', value: '5' }),
72+
])
73+
expect(filter).toEqual({ $or: [{ age: { $gt: 18 } }, { age: { $lt: 5 } }] })
74+
})
75+
})
76+
77+
describe('filterToRules', () => {
78+
it('maps $empty: true back to isEmpty and $empty: false back to isNotEmpty', () => {
79+
const empty = filterToRules({ phone: { $empty: true } })
80+
expect(empty).toHaveLength(1)
81+
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
82+
83+
const notEmpty = filterToRules({ phone: { $empty: false } })
84+
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
85+
})
86+
87+
it("treats the string '$empty' operand the same as the boolean (no predicate flip)", () => {
88+
const empty = filterToRules({ phone: { $empty: 'true' } } as unknown as Parameters<
89+
typeof filterToRules
90+
>[0])
91+
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
92+
93+
const notEmpty = filterToRules({ phone: { $empty: 'false' } } as unknown as Parameters<
94+
typeof filterToRules
95+
>[0])
96+
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
97+
})
98+
99+
it('round-trips string-pattern operators', () => {
100+
for (const operator of ['contains', 'ncontains', 'startsWith', 'endsWith'] as const) {
101+
const filter = filterRulesToFilter([rule({ column: 'name', operator, value: 'abc' })])
102+
const back = filterToRules(filter)
103+
expect(back[0]).toMatchObject({ column: 'name', operator, value: 'abc' })
104+
}
105+
})
106+
107+
it('round-trips isEmpty through filterRulesToFilter', () => {
108+
const filter = filterRulesToFilter([rule({ column: 'name', operator: 'isEmpty' })])
109+
const back = filterToRules(filter)
110+
expect(back[0]).toMatchObject({ column: 'name', operator: 'isEmpty', value: '' })
111+
})
112+
113+
it('round-trips a multi-operator column (Filter → rules → Filter) without loss', () => {
114+
const original = { age: { $gte: 18, $lte: 65 } }
115+
const rules = filterToRules(original)
116+
expect(rules).toHaveLength(2)
117+
expect(filterRulesToFilter(rules)).toEqual(original)
118+
})
119+
})

apps/sim/lib/table/query-builder/constants.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,27 @@ export type { FilterRule, SortRule } from '../types'
77
export const COMPARISON_OPERATORS = [
88
{ value: 'eq', label: 'equals' },
99
{ value: 'ne', label: 'not equals' },
10+
{ value: 'contains', label: 'contains' },
11+
{ value: 'ncontains', label: 'does not contain' },
12+
{ value: 'startsWith', label: 'starts with' },
13+
{ value: 'endsWith', label: 'ends with' },
1014
{ value: 'gt', label: 'greater than' },
1115
{ value: 'gte', label: 'greater or equal' },
1216
{ value: 'lt', label: 'less than' },
1317
{ value: 'lte', label: 'less or equal' },
14-
{ value: 'contains', label: 'contains' },
1518
{ value: 'in', label: 'in array' },
19+
{ value: 'nin', label: 'not in array' },
20+
{ value: 'isEmpty', label: 'is empty' },
21+
{ value: 'isNotEmpty', label: 'is not empty' },
1622
] as const
1723

24+
/**
25+
* Operators that take no value — the filter is fully specified by column +
26+
* operator alone. The UI hides the value input and skips the value-required
27+
* check for these, and the converter serializes them to `{ $empty: bool }`.
28+
*/
29+
export const VALUELESS_OPERATORS = new Set<string>(['isEmpty', 'isNotEmpty'])
30+
1831
export const LOGICAL_OPERATORS = [
1932
{ value: 'and', label: 'and' },
2033
{ value: 'or', label: 'or' },

0 commit comments

Comments
 (0)