Skip to content

Commit a5e6fa8

Browse files
feat(tables): support plain predicates in v2 queries
1 parent 2d90c72 commit a5e6fa8

19 files changed

Lines changed: 301 additions & 51 deletions

File tree

apps/sim/app/api/table/[tableId]/query/route.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,19 @@ describe('POST /api/table/[tableId]/query', () => {
137137
expect(options.withExecutions).toBe(false)
138138
})
139139

140+
it('accepts a root condition and executes its canonical all group', async () => {
141+
authAs('internal_jwt')
142+
const res = await callQuery({
143+
workspaceId: 'workspace-1',
144+
predicate: { field: 'name', op: 'eq', value: 'John' },
145+
})
146+
147+
expect(res.status).toBe(200)
148+
expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
149+
all: [{ field: 'col_aaa', op: 'eq', value: 'John' }],
150+
})
151+
})
152+
140153
it('rejects a keyset cursor combined with a custom sort', async () => {
141154
authAs('internal_jwt')
142155
const cursor = encodeCursor({

apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
141141
})
142142
})
143143

144+
it('accepts a root condition and executes its canonical all group', async () => {
145+
const res = await callQuery({
146+
workspaceId: 'workspace-1',
147+
predicate: { field: 'status', op: 'eq', value: 'active' },
148+
})
149+
150+
expect(res.status).toBe(200)
151+
expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
152+
all: [{ field: 'col_status', op: 'eq', value: 'active' }],
153+
})
154+
})
155+
144156
it('applies the bounded default limit when omitted', async () => {
145157
await callQuery({ workspaceId: 'workspace-1' })
146158
expect(mockQueryRows.mock.calls[0][1].limit).toBe(100)

apps/sim/blocks/blocks/table_v2.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ describe('table_v2 query_rows transformer', () => {
6565
})
6666
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'test' }] })
6767
})
68+
69+
it('normalizes a plain editor condition into the canonical predicate group', () => {
70+
const out = params({
71+
operation: 'query_rows',
72+
tableId: 't',
73+
filterInput: '{"field":"name","op":"eq","value":"test"}',
74+
})
75+
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'test' }] })
76+
})
6877
})
6978

7079
describe('table_v2 bulk transformers', () => {
@@ -90,6 +99,19 @@ describe('table_v2 bulk transformers', () => {
9099
expect(out.limit).toBeUndefined()
91100
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'x' }] })
92101
})
102+
103+
it.each(['update_rows_by_filter', 'delete_rows_by_filter'])(
104+
'normalizes a plain editor condition for %s',
105+
(operation) => {
106+
const out = params({
107+
operation,
108+
tableId: 't',
109+
filterInput: '{"field":"name","op":"eq","value":"x"}',
110+
...(operation === 'update_rows_by_filter' ? { data: '{"active":false}' } : {}),
111+
})
112+
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'x' }] })
113+
}
114+
)
93115
})
94116

95117
/**
@@ -116,4 +138,10 @@ describe('table_v2 blank and malformed editor inputs', () => {
116138
expect(() => params({ ...base, filterInput: '{not json}' })).toThrow(/Invalid JSON in Filter/)
117139
expect(() => params({ ...base, sortInput: '{not json}' })).toThrow(/Invalid JSON in Sort/)
118140
})
141+
142+
it('fails fast on a legacy or malformed filter object', () => {
143+
expect(() => params({ ...base, filterInput: '{"status":"active"}' })).toThrow(
144+
/group.*condition/i
145+
)
146+
})
119147
})

apps/sim/blocks/blocks/table_v2.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@ import { toError } from '@sim/utils/errors'
22
import { TableIcon } from '@/components/icons'
33
import { TABLE_LIMITS } from '@/lib/table/constants'
44
import { filterRulesToPredicate, sortRulesToSortSpec } from '@/lib/table/query-builder/converters'
5-
import type { FilterRule, SortRule, SortSpec, TablePredicate } from '@/lib/table/types'
5+
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
6+
import { validatePredicateShape } from '@/lib/table/query-builder/validate'
7+
import type {
8+
FilterRule,
9+
SortRule,
10+
SortSpec,
11+
TablePredicate,
12+
TablePredicateInput,
13+
} from '@/lib/table/types'
614
import type { BlockConfig } from '@/blocks/types'
715
import type { TableQueryV2Response } from '@/tools/table/types'
816
import { getTrigger } from '@/triggers'
917

1018
/**
1119
* Table v2 — same operations as the v1 Table block, but the filter grammar is a
12-
* typed predicate tree (`{all:[{field:'wins',op:'gte',value:10}]}`), validated
13-
* server-side. Pagination is an opaque cursor (no offset). The filter compiler,
20+
* typed predicate (`{field:'wins',op:'gte',value:10}`), with `all`/`any` groups
21+
* for compound conditions, validated server-side. Pagination is an opaque cursor (no offset). The filter compiler,
1422
* upsert conflict probe, and unique checks share one case-sensitive containment
1523
* leaf, so upserts can't wedge on a case-mismatched unique value the way they
1624
* could under v1.
@@ -64,7 +72,10 @@ function resolveFilter(params: TableBlockParams): TablePredicate | undefined {
6472
return raw.length > 0 ? (filterRulesToPredicate(raw as FilterRule[]) ?? undefined) : undefined
6573
}
6674
const parsed = parseJSON(raw, 'Filter')
67-
return (parsed as TablePredicate | undefined) || undefined
75+
if (parsed === undefined) return undefined
76+
const predicate = parsed as TablePredicateInput
77+
validatePredicateShape(predicate)
78+
return normalizeTablePredicate(predicate)
6879
}
6980

7081
function resolveOrder(params: TableBlockParams): SortSpec | undefined {
@@ -178,16 +189,16 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
178189
description: 'User-defined data tables',
179190
longDescription:
180191
'Create and manage custom data tables. Store, query, and manipulate structured data within workflows. ' +
181-
'Query Rows filters with a predicate tree — `{"all":[{"field":"wins","op":"gte","value":10}]}` ' +
182-
'(`all` = AND, `any` = OR; groups nest). Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
192+
'Query Rows accepts a plain predicate — `{"field":"wins","op":"gte","value":10}` — for one condition. ' +
193+
'Use `all` (AND) or `any` (OR) groups for multiple or nested conditions. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
183194
'nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Order is a sort ' +
184195
'spec `[{"field":"wins","direction":"desc"}]`. Query Rows returns every matching row when Limit is omitted ' +
185196
'(fails if the result exceeds 5MB — add a filter or a Limit). With a Limit, responses page: a non-null ' +
186197
'nextCursor means more rows exist — pass it back as the cursor.',
187198
bestPractices: `
188-
- To fetch specific rows, use Query Rows with a predicate filter (e.g. {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}) — do NOT read every row and filter downstream with a Condition block.
199+
- To fetch specific rows, use Query Rows with a predicate filter (e.g. {"field":"slack_user_id","op":"in","value":["U1","U2"]}) — do NOT read every row and filter downstream with a Condition block.
189200
- Use "Get Row by ID" only when you have the row's id; otherwise filter with a predicate.
190-
- A group is {"all":[...]} (AND) or {"any":[...]} (OR); nest groups as members for mixed logic.
201+
- A single condition can be plain. For multiple conditions, use {"all":[...]} (AND) or {"any":[...]} (OR); nest groups as members for mixed logic.
191202
- Example: players who won ≥10 and are active → {"all":[{"field":"wins","op":"gte","value":10},{"field":"status","op":"eq","value":"active"}]}.
192203
- like/ilike use * as the wildcard (e.g. {"field":"name","op":"ilike","value":"*jo*"}).
193204
- Omit Limit to get the entire matching result in one response — the query fails with a clear error if it exceeds 5MB (narrow with a filter or set a Limit).
@@ -354,7 +365,7 @@ Return ONLY the rows array:`,
354365
type: 'code',
355366
canonicalParamId: 'filterInput',
356367
mode: 'advanced',
357-
placeholder: '{"all":[{"field":"wins","op":"gte","value":10}]}',
368+
placeholder: '{"field":"wins","op":"gte","value":10}',
358369
condition: {
359370
field: 'operation',
360371
value: ['query_rows', 'update_rows_by_filter', 'delete_rows_by_filter'],
@@ -370,16 +381,16 @@ Return ONLY the rows array:`,
370381
### INSTRUCTION
371382
Return ONLY the JSON object. No explanations, surrounding quotes, or markdown.
372383
373-
A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {"field","op","value"} or nested groups.
384+
A single condition is a plain predicate {"field","op","value"}. Use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple conditions; group members may be conditions or nested groups.
374385
375386
### OPERATORS
376387
eq, ne, gt, gte, lt, lte, in, nin (in/nin take an array value), like, ilike (use * as the wildcard), nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty.
377388
378389
### EXAMPLES
379-
"status is active" → {"all":[{"field":"status","op":"eq","value":"active"}]}
390+
"status is active" → {"field":"status","op":"eq","value":"active"}
380391
"wins at least 10 and active" → {"all":[{"field":"wins","op":"gte","value":10},{"field":"active","op":"eq","value":true}]}
381392
"status active or pending" → {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}
382-
"name contains jo (any case)" → {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}
393+
"name contains jo (any case)" → {"field":"name","op":"ilike","value":"*jo*"}
383394
384395
Return ONLY the JSON object:`,
385396
generationType: 'table-schema',
@@ -475,7 +486,7 @@ Return ONLY the JSON object:`,
475486
filterInput: {
476487
type: 'json',
477488
description:
478-
'Filter — a predicate object {"all":[{"field":"wins","op":"gte","value":10}]} (or visual builder conditions). Used by query and bulk update/delete.',
489+
'Filter — a predicate object {"field":"wins","op":"gte","value":10}; use all/any groups for multiple conditions (or use visual builder conditions). Used by query and bulk update/delete.',
479490
},
480491
sortInput: {
481492
type: 'json',

apps/sim/lib/api/contracts/tables-predicate.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,27 @@
88
import { describe, expect, it } from 'vitest'
99
import {
1010
deleteTableRowsBodySchema,
11+
predicateInputSchema,
1112
predicateSchema,
1213
rowQueryBodySchema,
1314
tableRowsQuerySchema,
15+
tableViewConfigSchema,
1416
updateRowsByFilterBodySchema,
1517
} from '@/lib/api/contracts/tables'
1618
import { validatePredicate } from '@/lib/table/query-builder/validate'
1719

1820
describe('rowQueryBodySchema', () => {
21+
it('accepts a root condition and normalizes it to the canonical all group', () => {
22+
const parsed = rowQueryBodySchema.parse({
23+
workspaceId: 'ws-1',
24+
predicate: { field: 'status', op: 'eq', value: 'active' },
25+
})
26+
27+
expect(parsed.predicate).toEqual({
28+
all: [{ field: 'status', op: 'eq', value: 'active' }],
29+
})
30+
})
31+
1932
it('accepts a predicate/sort object, leaves limit unbounded, has no offset', () => {
2033
const parsed = rowQueryBodySchema.parse({
2134
workspaceId: 'ws-1',
@@ -78,6 +91,16 @@ describe('rowQueryBodySchema', () => {
7891
})
7992
})
8093

94+
describe('tableViewConfigSchema', () => {
95+
it('normalizes a root condition before it is persisted', () => {
96+
expect(
97+
tableViewConfigSchema.parse({
98+
filter: { field: 'status', op: 'eq', value: 'active' },
99+
}).filter
100+
).toEqual({ all: [{ field: 'status', op: 'eq', value: 'active' }] })
101+
})
102+
})
103+
81104
describe('bulk schemas accept either a predicate tree or the legacy filter object', () => {
82105
it('delete accepts a predicate filter', () => {
83106
expect(
@@ -95,6 +118,15 @@ describe('bulk schemas accept either a predicate tree or the legacy filter objec
95118
).toBe(true)
96119
})
97120

121+
it('does not reinterpret a legacy object with field/op/value columns as a root predicate', () => {
122+
const filter = { field: 'status', op: 'eq', value: 'active' }
123+
const parsed = deleteTableRowsBodySchema.parse({ workspaceId: 'ws-1', filter })
124+
125+
expect(parsed.filter).toEqual(filter)
126+
expect(predicateSchema.safeParse(filter).success).toBe(false)
127+
expect(predicateInputSchema.parse(filter)).toEqual({ all: [filter] })
128+
})
129+
98130
it('update accepts a predicate filter', () => {
99131
expect(
100132
updateRowsByFilterBodySchema.safeParse({

apps/sim/lib/api/contracts/tables.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
TABLE_LIMITS,
3434
} from '@/lib/table/constants'
3535
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
36+
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
3637

3738
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
3839

@@ -510,22 +511,31 @@ const predicateTreeSchema: z.ZodType<TablePredicate> = z.lazy(() =>
510511
)
511512
const predicateGroupSchema = predicateTreeSchema
512513

514+
const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => {
515+
const problem = predicateTreeTooLarge(value)
516+
if (problem) ctx.addIssue({ code: 'custom', message: problem })
517+
})
518+
513519
/**
514-
* The boundary predicate schema: depth/size guard first, then the recursive
515-
* structural parse. The guard is only applied at the top level — every nested
516-
* group is strictly shallower, so re-checking inside the recursion would be
517-
* redundant work on the hot path.
520+
* The canonical grouped predicate schema for dual-grammar boundaries. Keeping
521+
* its root group-only prevents a legacy filter with columns named `field`,
522+
* `op`, and `value` from being reinterpreted as a v2 predicate.
518523
*/
519-
export const predicateSchema = z
520-
.unknown()
521-
.superRefine((value, ctx) => {
522-
const problem = predicateTreeTooLarge(value)
523-
if (problem) ctx.addIssue({ code: 'custom', message: problem })
524-
})
524+
export const predicateSchema = predicateBoundarySchema
525525
// double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS
526526
// widen the recursive lazy union through it makes typecheck OOM
527527
.pipe(predicateTreeSchema) as unknown as z.ZodType<TablePredicate>
528528

529+
/**
530+
* The v2-only input schema accepts either a root leaf or a logical group and
531+
* always outputs the canonical grouped shape. The depth/size guard runs before
532+
* recursive parsing so pathological input returns a validation error, not a
533+
* stack overflow.
534+
*/
535+
export const predicateInputSchema = predicateBoundarySchema
536+
.pipe(predicateNodeSchema)
537+
.transform(normalizeTablePredicate) as z.ZodType<TablePredicate, PredicateNode>
538+
529539
/** v2 sort wire format: an ordered list of `{ field, direction }`. */
530540
export const sortSpecSchema: z.ZodType<SortSpec> = z
531541
.array(
@@ -871,7 +881,7 @@ export const listTableRowsContract = defineRouteContract({
871881
*/
872882
export const rowQueryBodySchema = z.object({
873883
workspaceId: z.string().min(1, 'Workspace ID is required'),
874-
predicate: predicateSchema.optional(),
884+
predicate: predicateInputSchema.optional(),
875885
sort: sortSpecSchema.optional(),
876886
// Omitted limit returns the ENTIRE matching result, failing fast (400) when
877887
// it exceeds the response byte budget. An explicit limit caps the page row
@@ -1770,7 +1780,7 @@ export const tableViewConfigSchema = tableMetadataSchema.extend({
17701780
// The v2 predicate/sort grammar — same wire as the query routes, so a saved
17711781
// view gets the same strictness and depth bounds as a live filter, and its
17721782
// config can later feed the v2 surfaces without conversion.
1773-
filter: predicateSchema.nullable().optional(),
1783+
filter: predicateInputSchema.nullable().optional(),
17741784
sort: sortSpecSchema.nullable().optional(),
17751785
}) satisfies z.ZodType<TableViewConfig>
17761786

apps/sim/lib/api/contracts/v2/tables/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
33
import {
4-
predicateSchema,
4+
predicateInputSchema,
55
sortSpecSchema,
66
tableColumnSchema,
77
tableIdParamsSchema,
@@ -11,9 +11,9 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1111
/**
1212
* Public v2 tables API — typed predicate grammar, cursor paging.
1313
*
14-
* Filters are the `{ all | any: [...] }` predicate tree (same shape the engine
15-
* consumes); no string querystring dialect. Response bodies are fully typed. Row
16-
* data is name-keyed and carries no storage internals (`position`/`orderKey`/
14+
* A filter may be one `{ field, op, value }` condition or an `{ all | any: [...] }`
15+
* predicate tree; no string querystring dialect. Response bodies are fully typed.
16+
* Row data is name-keyed and carries no storage internals (`position`/`orderKey`/
1717
* `executions`) — the public wire is `{ id, data, createdAt, updatedAt }`.
1818
*/
1919

@@ -52,13 +52,14 @@ export const v2ListTablesQuerySchema = z.object({
5252
})
5353

5454
/**
55-
* Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec.
55+
* Rows query body. `predicate` accepts one condition or a grouped tree; `sort`
56+
* is the ordered sort spec.
5657
* `limit`: omitted → {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result
5758
* or a 400 `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap.
5859
*/
5960
export const v2QueryRowsBodySchema = z.object({
6061
workspaceId: workspaceIdSchema,
61-
predicate: predicateSchema.optional(),
62+
predicate: predicateInputSchema.optional(),
6263
sort: sortSpecSchema.optional(),
6364
limit: z
6465
.number({ error: 'Limit must be a number' })

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3924,7 +3924,7 @@ export const QueryUserTable: ToolCatalogEntry = {
39243924
filter: {
39253925
type: 'object',
39263926
description:
3927-
'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.',
3927+
'Predicate filter object for query_rows. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}.',
39283928
},
39293929
limit: {
39303930
type: 'number',
@@ -5049,7 +5049,7 @@ export const UserTable: ToolCatalogEntry = {
50495049
filter: {
50505050
type: 'object',
50515051
description:
5052-
'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.',
5052+
'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}; {"field":"slack_user_id","op":"in","value":["U1","U2"]}.',
50535053
},
50545054
groupId: {
50555055
type: 'string',

0 commit comments

Comments
 (0)