mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) (#4827)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+18
-12
@@ -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(
|
||||
@@ -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])
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -21,7 +21,11 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
|
||||
currentGroup = {}
|
||||
}
|
||||
|
||||
currentGroup[rule.column] = ruleValue as Filter[string]
|
||||
const existing = currentGroup[rule.column]
|
||||
currentGroup[rule.column] =
|
||||
existing === undefined
|
||||
? (ruleValue as Filter[string])
|
||||
: (mergeConditions(existing, ruleValue) as Filter[string])
|
||||
}
|
||||
|
||||
if (Object.keys(currentGroup).length > 0) {
|
||||
@@ -77,10 +81,31 @@ export function sortToRules(sort: Sort | null): SortRule[] {
|
||||
}
|
||||
|
||||
function toRuleValue(operator: string, value: string): JsonValue {
|
||||
if (operator === 'isEmpty') return { $empty: true }
|
||||
if (operator === 'isNotEmpty') return { $empty: false }
|
||||
const parsedValue = parseValue(value, operator)
|
||||
return operator === 'eq' ? parsedValue : { [`$${operator}`]: parsedValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two conditions targeting the same column within one AND group into a
|
||||
* single operator object, so `age > 18 AND age < 65` becomes
|
||||
* `{ age: { $gt: 18, $lt: 65 } }` instead of the second rule clobbering the
|
||||
* first. Bare-equality shorthands are normalized to `{ $eq: value }` so they
|
||||
* can coexist with operators. On a same-operator collision (e.g. two
|
||||
* `$contains`) the later rule wins.
|
||||
*/
|
||||
function mergeConditions(existing: unknown, incoming: unknown): Record<string, JsonValue> {
|
||||
return { ...toOperatorObject(existing), ...toOperatorObject(incoming) }
|
||||
}
|
||||
|
||||
function toOperatorObject(value: unknown): Record<string, JsonValue> {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return { ...(value as Record<string, JsonValue>) }
|
||||
}
|
||||
return { $eq: value as JsonValue }
|
||||
}
|
||||
|
||||
function applyLogicalOperators(groups: FilterRule[][]): FilterRule[] {
|
||||
const rules: FilterRule[] = []
|
||||
|
||||
@@ -101,14 +126,23 @@ function applyLogicalOperators(groups: FilterRule[][]): FilterRule[] {
|
||||
return rules
|
||||
}
|
||||
|
||||
const ARRAY_OPERATORS = new Set(['in', 'nin'])
|
||||
const TEXT_MATCH_OPERATORS = new Set(['contains', 'ncontains', 'startsWith', 'endsWith'])
|
||||
|
||||
function parseValue(value: string, operator: string): JsonValue {
|
||||
if (operator === 'in') {
|
||||
if (ARRAY_OPERATORS.has(operator)) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => parseScalar(part))
|
||||
}
|
||||
|
||||
// Substring/prefix/suffix matches are textual — keep the raw string so a value
|
||||
// like "123" isn't coerced to a number the SQL builder's ILIKE path can't use.
|
||||
if (TEXT_MATCH_OPERATORS.has(operator)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return parseScalar(value)
|
||||
}
|
||||
|
||||
@@ -130,15 +164,29 @@ function parseFilterGroup(group: Filter): FilterRule[] {
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
for (const [op, opValue] of Object.entries(value)) {
|
||||
if (op.startsWith('$')) {
|
||||
if (!op.startsWith('$')) continue
|
||||
// `$empty` is a valueless boolean operator — map it back to the two
|
||||
// distinct UI operators rather than exposing a raw `empty` operator.
|
||||
// Accept the string forms `'true'`/`'false'` too, matching the lenient
|
||||
// coercion in the SQL builder's `coerceEmptyFlag` so a filter authored
|
||||
// via the raw API doesn't flip its predicate when re-opened in the UI.
|
||||
if (op === '$empty') {
|
||||
rules.push({
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: op.substring(1),
|
||||
value: formatValueForBuilder(opValue as JsonValue),
|
||||
operator: opValue === true || opValue === 'true' ? 'isEmpty' : 'isNotEmpty',
|
||||
value: '',
|
||||
})
|
||||
continue
|
||||
}
|
||||
rules.push({
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: op.substring(1),
|
||||
value: formatValueForBuilder(opValue as JsonValue),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ const ALLOWED_OPERATORS = new Set([
|
||||
'$in',
|
||||
'$nin',
|
||||
'$contains',
|
||||
'$ncontains',
|
||||
'$startsWith',
|
||||
'$endsWith',
|
||||
'$empty',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -67,8 +71,9 @@ const ALLOWED_OPERATORS = new Set([
|
||||
*
|
||||
* Index behavior: equality ($eq, $in) uses the JSONB containment operator (@>) and
|
||||
* can leverage the GIN index on `user_table_rows.data` (jsonb_path_ops). Range
|
||||
* operators ($gt, $gte, $lt, $lte) and pattern match ($contains) fall back to
|
||||
* text extraction via `data->>'field'`, which defeats the GIN index and produces
|
||||
* operators ($gt, $gte, $lt, $lte), pattern matches ($contains, $ncontains,
|
||||
* $startsWith, $endsWith), and emptiness checks ($empty) fall back to text
|
||||
* extraction via `data->>'field'`, which defeats the GIN index and produces
|
||||
* a sequential scan over the table's rows (bounded by a btree prefix on
|
||||
* `table_id`). Prefer equality filters on hot paths; assume range filters are
|
||||
* O(rows per table) until a per-column expression index is added.
|
||||
@@ -357,7 +362,25 @@ function buildFieldCondition(
|
||||
break
|
||||
|
||||
case '$contains':
|
||||
conditions.push(buildContainsClause(tableName, field, value as string))
|
||||
conditions.push(buildLikeClause(tableName, field, value as string, 'contains'))
|
||||
break
|
||||
|
||||
case '$ncontains':
|
||||
conditions.push(
|
||||
buildLikeClause(tableName, field, value as string, 'contains', { negate: true })
|
||||
)
|
||||
break
|
||||
|
||||
case '$startsWith':
|
||||
conditions.push(buildLikeClause(tableName, field, value as string, 'startsWith'))
|
||||
break
|
||||
|
||||
case '$endsWith':
|
||||
conditions.push(buildLikeClause(tableName, field, value as string, 'endsWith'))
|
||||
break
|
||||
|
||||
case '$empty':
|
||||
conditions.push(buildEmptyClause(tableName, field, coerceEmptyFlag(field, value)))
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -460,10 +483,73 @@ function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */
|
||||
function buildContainsClause(tableName: string, field: string, value: string): SQL {
|
||||
/**
|
||||
* Builds a case-insensitive pattern match against a JSONB cell using ILIKE.
|
||||
* `position` controls wildcard placement: `contains` → `%value%`, `startsWith`
|
||||
* → `value%`, `endsWith` → `%value`. When `negate` is set the match is inverted
|
||||
* and null cells are included — "does not contain X" should keep empty rows,
|
||||
* mirroring `$ne` (which also surfaces nulls). Cannot use the GIN index; falls
|
||||
* back to a sequential scan bounded by the `table_id` btree prefix.
|
||||
*/
|
||||
function buildLikeClause(
|
||||
tableName: string,
|
||||
field: string,
|
||||
value: string,
|
||||
position: 'contains' | 'startsWith' | 'endsWith',
|
||||
options?: { negate?: boolean }
|
||||
): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${escapeLikePattern(value)}%`}`
|
||||
// Coerce defensively: filters arriving via the raw v1 API / tools may carry a
|
||||
// non-string value (e.g. `{ $contains: 123 }`), and ILIKE compares text anyway.
|
||||
const text = String(value)
|
||||
// An empty pattern collapses to `%`/`%%`, which matches every non-null row —
|
||||
// a silent footgun for raw-API callers (the UI gates empty values out). Reject
|
||||
// it, consistent with the range/`$empty` operand validation.
|
||||
if (text.length === 0) {
|
||||
const opName = position === 'contains' && options?.negate ? 'ncontains' : position
|
||||
throw new TableQueryValidationError(
|
||||
`$${opName} on column "${field}" requires a non-empty value`
|
||||
)
|
||||
}
|
||||
const escaped = escapeLikePattern(text)
|
||||
const pattern =
|
||||
position === 'startsWith'
|
||||
? `${escaped}%`
|
||||
: position === 'endsWith'
|
||||
? `%${escaped}`
|
||||
: `%${escaped}%`
|
||||
const cell = sql.raw(`${tableName}.data->>'${escapedField}'`)
|
||||
return options?.negate
|
||||
? sql`(${cell} IS NULL OR ${cell} NOT ILIKE ${pattern})`
|
||||
: sql`${cell} ILIKE ${pattern}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a `$empty` operand to a boolean. Accepts a real boolean (the UI path)
|
||||
* and the string forms `'true'` / `'false'` (lenient raw-API input). Anything
|
||||
* else throws rather than silently inverting the check — a 400 with a clear
|
||||
* message beats returning the opposite row set.
|
||||
*/
|
||||
function coerceEmptyFlag(field: string, value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
throw new TableQueryValidationError(
|
||||
`$empty on column "${field}" requires a boolean, got ${typeof value}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an emptiness check against a JSONB cell. `isEmpty` matches null cells
|
||||
* (absent key or JSON null, both surfaced as SQL NULL by `->>`) and empty
|
||||
* strings; the negation requires the cell to be present and non-empty.
|
||||
*/
|
||||
function buildEmptyClause(tableName: string, field: string, isEmpty: boolean): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
const cell = sql.raw(`${tableName}.data->>'${escapedField}'`)
|
||||
return isEmpty
|
||||
? sql`(${cell} IS NULL OR ${cell} = '')`
|
||||
: sql`(${cell} IS NOT NULL AND ${cell} <> '')`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -204,6 +204,14 @@ export interface ConditionOperators {
|
||||
$in?: ColumnValue[]
|
||||
$nin?: ColumnValue[]
|
||||
$contains?: string
|
||||
/** Case-insensitive negated substring match. Null/empty cells match. */
|
||||
$ncontains?: string
|
||||
/** Case-insensitive prefix match. */
|
||||
$startsWith?: string
|
||||
/** Case-insensitive suffix match. */
|
||||
$endsWith?: string
|
||||
/** `true` → cell is null or empty string; `false` → cell is present and non-empty. */
|
||||
$empty?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,8 @@ export const tableDeleteRowsByFilterTool: ToolConfig<
|
||||
filter: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'Filter criteria using operators like $eq, $ne, $gt, $lt, $contains, $in, etc.',
|
||||
description:
|
||||
'Filter criteria using operators like $eq, $ne, $gt, $lt, $contains, $ncontains, $startsWith, $endsWith, $in, $nin, $empty, etc.',
|
||||
visibility: 'user-or-llm',
|
||||
},
|
||||
limit: {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const tableQueryRowsTool: ToolConfig<TableRowQueryParams, TableQueryRespo
|
||||
type: 'object',
|
||||
required: false,
|
||||
description:
|
||||
'Filter conditions (MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains)',
|
||||
'Filter conditions (MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty)',
|
||||
visibility: 'user-or-llm',
|
||||
},
|
||||
sort: {
|
||||
|
||||
@@ -29,7 +29,8 @@ export const tableUpdateRowsByFilterTool: ToolConfig<
|
||||
filter: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'Filter criteria using operators like $eq, $ne, $gt, $lt, $contains, $in, etc.',
|
||||
description:
|
||||
'Filter criteria using operators like $eq, $ne, $gt, $lt, $contains, $ncontains, $startsWith, $endsWith, $in, $nin, $empty, etc.',
|
||||
visibility: 'user-or-llm',
|
||||
},
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user