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
6 changes: 5 additions & 1 deletion apps/docs/content/docs/en/tools/table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,11 @@ Filters use MongoDB-style operators for flexible querying:
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` |
| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` |
| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` |
| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` |
| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` |

### Combining Filters

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@/components/emcn'
import { ChevronDown, Plus } from '@/components/emcn/icons'
import type { Filter, FilterRule } from '@/lib/table'
import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants'
import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'

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

const handleApply = useCallback(() => {
const validRules = rulesRef.current.filter((r) => r.column && r.value)
const validRules = rulesRef.current.filter(
(r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator))
)
onApply(filterRulesToFilter(validRules))
}, [onApply])

Expand Down Expand Up @@ -197,16 +199,20 @@ const FilterRuleRow = memo(function FilterRuleRow({
</DropdownMenuContent>
</DropdownMenu>

<input
type='text'
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onApply()
}}
placeholder='Enter a value'
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)]'
/>
{VALUELESS_OPERATORS.has(rule.operator) ? (
<div className='h-[28px] flex-1' />
) : (
<input
type='text'
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onApply()
}}
placeholder='Enter a value'
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)]'
/>
)}

<button
onClick={() => onRemove(rule.id)}
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/blocks/blocks/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ IMPORTANT: Reference the table schema to know which columns exist and their type
- **$in**: In array - {"column": {"$in": ["value1", "value2"]}}
- **$nin**: Not in array - {"column": {"$nin": ["value1", "value2"]}}
- **$contains**: String contains - {"column": {"$contains": "text"}}
- **$ncontains**: Does not contain (matches empty cells) - {"column": {"$ncontains": "text"}}
- **$startsWith**: Starts with - {"column": {"$startsWith": "text"}}
- **$endsWith**: Ends with - {"column": {"$endsWith": "text"}}
- **$empty**: Is empty (true) or non-empty (false) - {"column": {"$empty": true}}

### EXAMPLES

Expand Down Expand Up @@ -467,6 +471,10 @@ IMPORTANT: Reference the table schema to know which columns exist and their type
- **$in**: In array - {"column": {"$in": ["value1", "value2"]}}
- **$nin**: Not in array - {"column": {"$nin": ["value1", "value2"]}}
- **$contains**: String contains - {"column": {"$contains": "text"}}
- **$ncontains**: Does not contain (matches empty cells) - {"column": {"$ncontains": "text"}}
- **$startsWith**: Starts with - {"column": {"$startsWith": "text"}}
- **$endsWith**: Ends with - {"column": {"$endsWith": "text"}}
- **$empty**: Is empty (true) or non-empty (false) - {"column": {"$empty": true}}

### EXAMPLES

Expand Down
66 changes: 66 additions & 0 deletions apps/sim/lib/table/__tests__/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,72 @@ describe('SQL Builder', () => {
const out = render(buildFilterClause({ name: { $contains: 'john' } }, TABLE, NO_COLUMNS))
expect(out).toContain(`${TABLE}.data->>'name'`)
expect(out).toContain('ILIKE')
expect(out).toContain('%john%')
})

it('handles $ncontains as negated ILIKE that surfaces null cells', () => {
const out = render(buildFilterClause({ name: { $ncontains: 'john' } }, TABLE, NO_COLUMNS))
expect(out).toContain('IS NULL')
expect(out).toContain('NOT ILIKE')
expect(out).toContain('%john%')
})

it('handles $startsWith with a trailing wildcard only', () => {
const out = render(buildFilterClause({ name: { $startsWith: 'jo' } }, TABLE, NO_COLUMNS))
expect(out).toContain('ILIKE')
expect(out).toContain('jo%')
expect(out).not.toContain('%jo%')
})

it('handles $endsWith with a leading wildcard only', () => {
const out = render(buildFilterClause({ file: { $endsWith: '.pdf' } }, TABLE, NO_COLUMNS))
expect(out).toContain('ILIKE')
expect(out).toContain('%.pdf')
})

it('escapes ILIKE wildcards in pattern values', () => {
const out = render(buildFilterClause({ name: { $contains: '50%_off' } }, TABLE, NO_COLUMNS))
expect(out).toContain('50\\%\\_off')
})

it('rejects an empty pattern value rather than matching every row', () => {
for (const op of ['$contains', '$ncontains', '$startsWith', '$endsWith'] as const) {
expect(() =>
buildFilterClause({ name: { [op]: '' } } as Filter, TABLE, NO_COLUMNS)
).toThrow(/requires a non-empty value/)
}
})

it('handles $empty: true as null-or-empty-string check', () => {
const out = render(buildFilterClause({ phone: { $empty: true } }, TABLE, NO_COLUMNS))
expect(out).toContain(`${TABLE}.data->>'phone'`)
expect(out).toContain('IS NULL')
expect(out).toContain("= ''")
expect(out).toContain(' OR ')
})

it('handles $empty: false as present-and-non-empty check', () => {
const out = render(buildFilterClause({ phone: { $empty: false } }, TABLE, NO_COLUMNS))
expect(out).toContain('IS NOT NULL')
expect(out).toContain("<> ''")
expect(out).toContain(' AND ')
})

it('coerces string "true"/"false" $empty operands (lenient raw-API input)', () => {
const truthy = render(
buildFilterClause({ phone: { $empty: 'true' } } as Filter, TABLE, NO_COLUMNS)
)
expect(truthy).toContain('IS NULL')
const falsy = render(
buildFilterClause({ phone: { $empty: 'false' } } as Filter, TABLE, NO_COLUMNS)
)
expect(falsy).toContain('IS NOT NULL')
})

it('throws on a non-boolean $empty operand rather than silently inverting', () => {
expect(() =>
buildFilterClause({ phone: { $empty: 1 } } as unknown as Filter, TABLE, NO_COLUMNS)
).toThrow(/\$empty on column "phone" requires a boolean/)
})

it('joins multiple top-level conditions with AND', () => {
Expand Down
119 changes: 119 additions & 0 deletions apps/sim/lib/table/query-builder/__tests__/converters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @vitest-environment node
*
* Converter unit tests for the table query builder. Cover the operator
* round-trips — UI rule → Filter object → UI rule — with attention to the
* valueless `$empty` operator that maps to two distinct UI operators.
*/
import { describe, expect, it } from 'vitest'
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
import type { FilterRule } from '@/lib/table/types'

function rule(overrides: Partial<FilterRule>): FilterRule {
return {
id: 'rule-1',
logicalOperator: 'and',
column: 'name',
operator: 'eq',
value: '',
...overrides,
}
}

describe('filterRulesToFilter', () => {
it('emits a bare value for eq (containment shorthand)', () => {
expect(filterRulesToFilter([rule({ operator: 'eq', value: 'John' })])).toEqual({ name: 'John' })
})

it('wraps non-eq operators in a $-prefixed operator object', () => {
expect(
filterRulesToFilter([rule({ column: 'email', operator: 'startsWith', value: 'a' })])
).toEqual({ email: { $startsWith: 'a' } })
expect(
filterRulesToFilter([rule({ column: 'email', operator: 'ncontains', value: 'x' })])
).toEqual({ email: { $ncontains: 'x' } })
})

it('parses comma-separated values into arrays for in / nin', () => {
expect(
filterRulesToFilter([rule({ column: 'status', operator: 'nin', value: 'a, b' })])
).toEqual({ status: { $nin: ['a', 'b'] } })
})

it('serializes isEmpty / isNotEmpty to $empty without a value', () => {
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isEmpty' })])).toEqual({
phone: { $empty: true },
})
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isNotEmpty' })])).toEqual({
phone: { $empty: false },
})
})

it('merges two AND rules on the same column into one operator object', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
rule({ id: 'b', column: 'age', operator: 'lt', value: '65' }),
])
expect(filter).toEqual({ age: { $gt: 18, $lt: 65 } })
})

it('normalizes a bare-equality shorthand when merging with an operator', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'name', operator: 'eq', value: 'John' }),
rule({ id: 'b', column: 'name', operator: 'contains', value: 'oh' }),
])
expect(filter).toEqual({ name: { $eq: 'John', $contains: 'oh' } })
})

it('keeps same-column rules across an OR boundary in separate groups', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
rule({ id: 'b', logicalOperator: 'or', column: 'age', operator: 'lt', value: '5' }),
])
expect(filter).toEqual({ $or: [{ age: { $gt: 18 } }, { age: { $lt: 5 } }] })
})
})

describe('filterToRules', () => {
it('maps $empty: true back to isEmpty and $empty: false back to isNotEmpty', () => {
const empty = filterToRules({ phone: { $empty: true } })
expect(empty).toHaveLength(1)
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })

const notEmpty = filterToRules({ phone: { $empty: false } })
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
})

it("treats the string '$empty' operand the same as the boolean (no predicate flip)", () => {
const empty = filterToRules({ phone: { $empty: 'true' } } as unknown as Parameters<
typeof filterToRules
>[0])
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })

const notEmpty = filterToRules({ phone: { $empty: 'false' } } as unknown as Parameters<
typeof filterToRules
>[0])
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
})

it('round-trips string-pattern operators', () => {
for (const operator of ['contains', 'ncontains', 'startsWith', 'endsWith'] as const) {
const filter = filterRulesToFilter([rule({ column: 'name', operator, value: 'abc' })])
const back = filterToRules(filter)
expect(back[0]).toMatchObject({ column: 'name', operator, value: 'abc' })
}
})

it('round-trips isEmpty through filterRulesToFilter', () => {
const filter = filterRulesToFilter([rule({ column: 'name', operator: 'isEmpty' })])
const back = filterToRules(filter)
expect(back[0]).toMatchObject({ column: 'name', operator: 'isEmpty', value: '' })
})

it('round-trips a multi-operator column (Filter → rules → Filter) without loss', () => {
const original = { age: { $gte: 18, $lte: 65 } }
const rules = filterToRules(original)
expect(rules).toHaveLength(2)
expect(filterRulesToFilter(rules)).toEqual(original)
})
})
15 changes: 14 additions & 1 deletion apps/sim/lib/table/query-builder/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,27 @@ export type { FilterRule, SortRule } from '../types'
export const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
{ value: 'ne', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'ncontains', label: 'does not contain' },
{ value: 'startsWith', label: 'starts with' },
{ value: 'endsWith', label: 'ends with' },
{ value: 'gt', label: 'greater than' },
{ value: 'gte', label: 'greater or equal' },
{ value: 'lt', label: 'less than' },
{ value: 'lte', label: 'less or equal' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in array' },
{ value: 'nin', label: 'not in array' },
{ value: 'isEmpty', label: 'is empty' },
{ value: 'isNotEmpty', label: 'is not empty' },
] as const

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

export const LOGICAL_OPERATORS = [
{ value: 'and', label: 'and' },
{ value: 'or', label: 'or' },
Expand Down
Loading
Loading