From 8c41203ff83bf442ed80e5b3e1407221e04910d1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 31 May 2026 12:10:06 -0700 Subject: [PATCH] feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/docs/content/docs/en/tools/table.mdx | 6 +- .../components/table-filter/table-filter.tsx | 30 +++-- apps/sim/blocks/blocks/table.ts | 8 ++ apps/sim/lib/table/__tests__/sql.test.ts | 66 ++++++++++ .../__tests__/converters.test.ts | 119 ++++++++++++++++++ apps/sim/lib/table/query-builder/constants.ts | 15 ++- .../sim/lib/table/query-builder/converters.ts | 58 ++++++++- apps/sim/lib/table/sql.ts | 98 ++++++++++++++- apps/sim/lib/table/types.ts | 8 ++ apps/sim/tools/table/delete_rows_by_filter.ts | 3 +- apps/sim/tools/table/query_rows.ts | 2 +- apps/sim/tools/table/update_rows_by_filter.ts | 3 +- 12 files changed, 388 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/table/query-builder/__tests__/converters.test.ts diff --git a/apps/docs/content/docs/en/tools/table.mdx b/apps/docs/content/docs/en/tools/table.mdx index 388005b772c..e7cf5993704 100644 --- a/apps/docs/content/docs/en/tools/table.mdx +++ b/apps/docs/content/docs/en/tools/table.mdx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index bd683c62515..12f5bb10fb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -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({ - 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) ? ( +
+ ) : ( + 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)]' + /> + )}