Skip to content

Commit 41572a0

Browse files
feat(tables): support plain predicates in v2 queries (#6292)
* feat(tables): support plain predicates in v2 queries * fix(tables): regenerate tool metadata * fix(tables): bound predicate validation
1 parent 884a0be commit 41572a0

21 files changed

Lines changed: 377 additions & 90 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: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ import {
3333
TABLE_LIMITS,
3434
} from '@/lib/table/constants'
3535
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
36+
import {
37+
getTablePredicateTreeSizeError,
38+
MAX_PREDICATE_GROUP_SIZE,
39+
normalizeTablePredicate,
40+
} from '@/lib/table/query-builder/predicate'
3641

3742
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
3843

@@ -421,45 +426,8 @@ const filterSchema = domainObjectSchema<Filter>()
421426
*/
422427
export const TABLE_QUERY_MAX_BODY_BYTES = 1024 * 1024
423428

424-
/** Max members in one `all`/`any` group — a generous bound against pathological trees. */
425-
const MAX_PREDICATE_GROUP_SIZE = 100
426429
/** Max sort keys — more than a few is already a smell. */
427430
const MAX_SORT_KEYS = 16
428-
/** Max nesting levels of `all`/`any` groups. Ten is already unreadable. */
429-
const MAX_PREDICATE_DEPTH = 10
430-
/** Max nodes in the whole tree, so a wide-but-shallow tree can't amplify either. */
431-
const MAX_PREDICATE_NODES = 500
432-
433-
/**
434-
* Iterative depth/size walk over an unvalidated predicate tree. Runs BEFORE the
435-
* recursive Zod schema: a few thousand nested `{all:[...]}` levels overflow the
436-
* stack inside `safeParse`, and a `RangeError` from a parser is a 500, not a 400.
437-
* The walk itself must stay iterative for the same reason.
438-
*/
439-
function predicateTreeTooLarge(root: unknown): string | null {
440-
const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 1 }]
441-
let nodes = 0
442-
443-
while (stack.length > 0) {
444-
const { node, depth } = stack.pop()!
445-
if (++nodes > MAX_PREDICATE_NODES) {
446-
return `Filter has too many conditions (max ${MAX_PREDICATE_NODES})`
447-
}
448-
if (depth > MAX_PREDICATE_DEPTH) {
449-
return `Filter nesting is too deep (max ${MAX_PREDICATE_DEPTH} levels)`
450-
}
451-
if (typeof node !== 'object' || node === null) continue
452-
const group = node as { all?: unknown; any?: unknown }
453-
const members = Array.isArray(group.all)
454-
? group.all
455-
: Array.isArray(group.any)
456-
? group.any
457-
: null
458-
if (!members) continue
459-
for (const member of members) stack.push({ node: member, depth: depth + 1 })
460-
}
461-
return null
462-
}
463431

464432
/**
465433
* v2 filter wire format: the typed `{ all | any: [...] }` predicate tree (same
@@ -510,22 +478,31 @@ const predicateTreeSchema: z.ZodType<TablePredicate> = z.lazy(() =>
510478
)
511479
const predicateGroupSchema = predicateTreeSchema
512480

481+
const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => {
482+
const problem = getTablePredicateTreeSizeError(value)
483+
if (problem) ctx.addIssue({ code: 'custom', message: problem })
484+
})
485+
513486
/**
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.
487+
* The canonical grouped predicate schema for dual-grammar boundaries. Keeping
488+
* its root group-only prevents a legacy filter with columns named `field`,
489+
* `op`, and `value` from being reinterpreted as a v2 predicate.
518490
*/
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-
})
491+
export const predicateSchema = predicateBoundarySchema
525492
// double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS
526493
// widen the recursive lazy union through it makes typecheck OOM
527494
.pipe(predicateTreeSchema) as unknown as z.ZodType<TablePredicate>
528495

496+
/**
497+
* The v2-only input schema accepts either a root leaf or a logical group and
498+
* always outputs the canonical grouped shape. The depth/size guard runs before
499+
* recursive parsing so pathological input returns a validation error, not a
500+
* stack overflow.
501+
*/
502+
export const predicateInputSchema = predicateBoundarySchema
503+
.pipe(predicateNodeSchema)
504+
.transform(normalizeTablePredicate) as z.ZodType<TablePredicate, PredicateNode>
505+
529506
/** v2 sort wire format: an ordered list of `{ field, direction }`. */
530507
export const sortSpecSchema: z.ZodType<SortSpec> = z
531508
.array(
@@ -871,7 +848,7 @@ export const listTableRowsContract = defineRouteContract({
871848
*/
872849
export const rowQueryBodySchema = z.object({
873850
workspaceId: z.string().min(1, 'Workspace ID is required'),
874-
predicate: predicateSchema.optional(),
851+
predicate: predicateInputSchema.optional(),
875852
sort: sortSpecSchema.optional(),
876853
// Omitted limit returns the ENTIRE matching result, failing fast (400) when
877854
// it exceeds the response byte budget. An explicit limit caps the page row
@@ -1770,7 +1747,7 @@ export const tableViewConfigSchema = tableMetadataSchema.extend({
17701747
// The v2 predicate/sort grammar — same wire as the query routes, so a saved
17711748
// view gets the same strictness and depth bounds as a live filter, and its
17721749
// config can later feed the v2 surfaces without conversion.
1773-
filter: predicateSchema.nullable().optional(),
1750+
filter: predicateInputSchema.nullable().optional(),
17741751
sort: sortSpecSchema.nullable().optional(),
17751752
}) satisfies z.ZodType<TableViewConfig>
17761753

0 commit comments

Comments
 (0)