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
This commit is contained in:
Theodore Li
2026-08-05 15:10:26 -04:00
committed by GitHub
parent 884a0be574
commit 41572a0a6a
21 changed files with 377 additions and 90 deletions
@@ -137,6 +137,19 @@ describe('POST /api/table/[tableId]/query', () => {
expect(options.withExecutions).toBe(false)
})
it('accepts a root condition and executes its canonical all group', async () => {
authAs('internal_jwt')
const res = await callQuery({
workspaceId: 'workspace-1',
predicate: { field: 'name', op: 'eq', value: 'John' },
})
expect(res.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
all: [{ field: 'col_aaa', op: 'eq', value: 'John' }],
})
})
it('rejects a keyset cursor combined with a custom sort', async () => {
authAs('internal_jwt')
const cursor = encodeCursor({
@@ -141,6 +141,18 @@ describe('POST /api/v2/tables/[tableId]/query', () => {
})
})
it('accepts a root condition and executes its canonical all group', async () => {
const res = await callQuery({
workspaceId: 'workspace-1',
predicate: { field: 'status', op: 'eq', value: 'active' },
})
expect(res.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
all: [{ field: 'col_status', op: 'eq', value: 'active' }],
})
})
it('applies the bounded default limit when omitted', async () => {
await callQuery({ workspaceId: 'workspace-1' })
expect(mockQueryRows.mock.calls[0][1].limit).toBe(100)
+28
View File
@@ -65,6 +65,15 @@ describe('table_v2 query_rows transformer', () => {
})
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'test' }] })
})
it('normalizes a plain editor condition into the canonical predicate group', () => {
const out = params({
operation: 'query_rows',
tableId: 't',
filterInput: '{"field":"name","op":"eq","value":"test"}',
})
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'test' }] })
})
})
describe('table_v2 bulk transformers', () => {
@@ -90,6 +99,19 @@ describe('table_v2 bulk transformers', () => {
expect(out.limit).toBeUndefined()
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'x' }] })
})
it.each(['update_rows_by_filter', 'delete_rows_by_filter'])(
'normalizes a plain editor condition for %s',
(operation) => {
const out = params({
operation,
tableId: 't',
filterInput: '{"field":"name","op":"eq","value":"x"}',
...(operation === 'update_rows_by_filter' ? { data: '{"active":false}' } : {}),
})
expect(out.filter).toEqual({ all: [{ field: 'name', op: 'eq', value: 'x' }] })
}
)
})
/**
@@ -116,4 +138,10 @@ describe('table_v2 blank and malformed editor inputs', () => {
expect(() => params({ ...base, filterInput: '{not json}' })).toThrow(/Invalid JSON in Filter/)
expect(() => params({ ...base, sortInput: '{not json}' })).toThrow(/Invalid JSON in Sort/)
})
it('fails fast on a legacy or malformed filter object', () => {
expect(() => params({ ...base, filterInput: '{"status":"active"}' })).toThrow(
/group.*condition/i
)
})
})
+24 -13
View File
@@ -2,15 +2,23 @@ import { toError } from '@sim/utils/errors'
import { TableIcon } from '@/components/icons'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { filterRulesToPredicate, sortRulesToSortSpec } from '@/lib/table/query-builder/converters'
import type { FilterRule, SortRule, SortSpec, TablePredicate } from '@/lib/table/types'
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
import { validatePredicateShape } from '@/lib/table/query-builder/validate'
import type {
FilterRule,
SortRule,
SortSpec,
TablePredicate,
TablePredicateInput,
} from '@/lib/table/types'
import type { BlockConfig } from '@/blocks/types'
import type { TableQueryV2Response } from '@/tools/table/types'
import { getTrigger } from '@/triggers'
/**
* Table v2 — same operations as the v1 Table block, but the filter grammar is a
* typed predicate tree (`{all:[{field:'wins',op:'gte',value:10}]}`), validated
* server-side. Pagination is an opaque cursor (no offset). The filter compiler,
* typed predicate (`{field:'wins',op:'gte',value:10}`), with `all`/`any` groups
* for compound conditions, validated server-side. Pagination is an opaque cursor (no offset). The filter compiler,
* upsert conflict probe, and unique checks share one case-sensitive containment
* leaf, so upserts can't wedge on a case-mismatched unique value the way they
* could under v1.
@@ -64,7 +72,10 @@ function resolveFilter(params: TableBlockParams): TablePredicate | undefined {
return raw.length > 0 ? (filterRulesToPredicate(raw as FilterRule[]) ?? undefined) : undefined
}
const parsed = parseJSON(raw, 'Filter')
return (parsed as TablePredicate | undefined) || undefined
if (parsed === undefined) return undefined
const predicate = parsed as TablePredicateInput
validatePredicateShape(predicate)
return normalizeTablePredicate(predicate)
}
function resolveOrder(params: TableBlockParams): SortSpec | undefined {
@@ -178,16 +189,16 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
description: 'User-defined data tables',
longDescription:
'Create and manage custom data tables. Store, query, and manipulate structured data within workflows. ' +
'Query Rows filters with a predicate tree — `{"all":[{"field":"wins","op":"gte","value":10}]}` ' +
'(`all` = AND, `any` = OR; groups nest). Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
'Query Rows accepts a plain predicate — `{"field":"wins","op":"gte","value":10}` — for one condition. ' +
'Use `all` (AND) or `any` (OR) groups for multiple or nested conditions. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
'nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Order is a sort ' +
'spec `[{"field":"wins","direction":"desc"}]`. Query Rows returns every matching row when Limit is omitted ' +
'(fails if the result exceeds 5MB — add a filter or a Limit). With a Limit, responses page: a non-null ' +
'nextCursor means more rows exist — pass it back as the cursor.',
bestPractices: `
- 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.
- 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.
- Use "Get Row by ID" only when you have the row's id; otherwise filter with a predicate.
- A group is {"all":[...]} (AND) or {"any":[...]} (OR); nest groups as members for mixed logic.
- A single condition can be plain. For multiple conditions, use {"all":[...]} (AND) or {"any":[...]} (OR); nest groups as members for mixed logic.
- Example: players who won ≥10 and are active → {"all":[{"field":"wins","op":"gte","value":10},{"field":"status","op":"eq","value":"active"}]}.
- like/ilike use * as the wildcard (e.g. {"field":"name","op":"ilike","value":"*jo*"}).
- 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:`,
type: 'code',
canonicalParamId: 'filterInput',
mode: 'advanced',
placeholder: '{"all":[{"field":"wins","op":"gte","value":10}]}',
placeholder: '{"field":"wins","op":"gte","value":10}',
condition: {
field: 'operation',
value: ['query_rows', 'update_rows_by_filter', 'delete_rows_by_filter'],
@@ -370,16 +381,16 @@ Return ONLY the rows array:`,
### INSTRUCTION
Return ONLY the JSON object. No explanations, surrounding quotes, or markdown.
A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {"field","op","value"} or nested groups.
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.
### OPERATORS
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.
### EXAMPLES
"status is active" → {"all":[{"field":"status","op":"eq","value":"active"}]}
"status is active" → {"field":"status","op":"eq","value":"active"}
"wins at least 10 and active" → {"all":[{"field":"wins","op":"gte","value":10},{"field":"active","op":"eq","value":true}]}
"status active or pending" → {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}
"name contains jo (any case)" → {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}
"name contains jo (any case)" → {"field":"name","op":"ilike","value":"*jo*"}
Return ONLY the JSON object:`,
generationType: 'table-schema',
@@ -475,7 +486,7 @@ Return ONLY the JSON object:`,
filterInput: {
type: 'json',
description:
'Filter — a predicate object {"all":[{"field":"wins","op":"gte","value":10}]} (or visual builder conditions). Used by query and bulk update/delete.',
'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.',
},
sortInput: {
type: 'json',
@@ -8,14 +8,27 @@
import { describe, expect, it } from 'vitest'
import {
deleteTableRowsBodySchema,
predicateInputSchema,
predicateSchema,
rowQueryBodySchema,
tableRowsQuerySchema,
tableViewConfigSchema,
updateRowsByFilterBodySchema,
} from '@/lib/api/contracts/tables'
import { validatePredicate } from '@/lib/table/query-builder/validate'
describe('rowQueryBodySchema', () => {
it('accepts a root condition and normalizes it to the canonical all group', () => {
const parsed = rowQueryBodySchema.parse({
workspaceId: 'ws-1',
predicate: { field: 'status', op: 'eq', value: 'active' },
})
expect(parsed.predicate).toEqual({
all: [{ field: 'status', op: 'eq', value: 'active' }],
})
})
it('accepts a predicate/sort object, leaves limit unbounded, has no offset', () => {
const parsed = rowQueryBodySchema.parse({
workspaceId: 'ws-1',
@@ -78,6 +91,16 @@ describe('rowQueryBodySchema', () => {
})
})
describe('tableViewConfigSchema', () => {
it('normalizes a root condition before it is persisted', () => {
expect(
tableViewConfigSchema.parse({
filter: { field: 'status', op: 'eq', value: 'active' },
}).filter
).toEqual({ all: [{ field: 'status', op: 'eq', value: 'active' }] })
})
})
describe('bulk schemas accept either a predicate tree or the legacy filter object', () => {
it('delete accepts a predicate filter', () => {
expect(
@@ -95,6 +118,15 @@ describe('bulk schemas accept either a predicate tree or the legacy filter objec
).toBe(true)
})
it('does not reinterpret a legacy object with field/op/value columns as a root predicate', () => {
const filter = { field: 'status', op: 'eq', value: 'active' }
const parsed = deleteTableRowsBodySchema.parse({ workspaceId: 'ws-1', filter })
expect(parsed.filter).toEqual(filter)
expect(predicateSchema.safeParse(filter).success).toBe(false)
expect(predicateInputSchema.parse(filter)).toEqual({ all: [filter] })
})
it('update accepts a predicate filter', () => {
expect(
updateRowsByFilterBodySchema.safeParse({
+26 -49
View File
@@ -33,6 +33,11 @@ import {
TABLE_LIMITS,
} from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
import {
getTablePredicateTreeSizeError,
MAX_PREDICATE_GROUP_SIZE,
normalizeTablePredicate,
} from '@/lib/table/query-builder/predicate'
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
@@ -421,45 +426,8 @@ const filterSchema = domainObjectSchema<Filter>()
*/
export const TABLE_QUERY_MAX_BODY_BYTES = 1024 * 1024
/** Max members in one `all`/`any` group — a generous bound against pathological trees. */
const MAX_PREDICATE_GROUP_SIZE = 100
/** Max sort keys — more than a few is already a smell. */
const MAX_SORT_KEYS = 16
/** Max nesting levels of `all`/`any` groups. Ten is already unreadable. */
const MAX_PREDICATE_DEPTH = 10
/** Max nodes in the whole tree, so a wide-but-shallow tree can't amplify either. */
const MAX_PREDICATE_NODES = 500
/**
* Iterative depth/size walk over an unvalidated predicate tree. Runs BEFORE the
* recursive Zod schema: a few thousand nested `{all:[...]}` levels overflow the
* stack inside `safeParse`, and a `RangeError` from a parser is a 500, not a 400.
* The walk itself must stay iterative for the same reason.
*/
function predicateTreeTooLarge(root: unknown): string | null {
const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 1 }]
let nodes = 0
while (stack.length > 0) {
const { node, depth } = stack.pop()!
if (++nodes > MAX_PREDICATE_NODES) {
return `Filter has too many conditions (max ${MAX_PREDICATE_NODES})`
}
if (depth > MAX_PREDICATE_DEPTH) {
return `Filter nesting is too deep (max ${MAX_PREDICATE_DEPTH} levels)`
}
if (typeof node !== 'object' || node === null) continue
const group = node as { all?: unknown; any?: unknown }
const members = Array.isArray(group.all)
? group.all
: Array.isArray(group.any)
? group.any
: null
if (!members) continue
for (const member of members) stack.push({ node: member, depth: depth + 1 })
}
return null
}
/**
* v2 filter wire format: the typed `{ all | any: [...] }` predicate tree (same
@@ -510,22 +478,31 @@ const predicateTreeSchema: z.ZodType<TablePredicate> = z.lazy(() =>
)
const predicateGroupSchema = predicateTreeSchema
const predicateBoundarySchema = z.unknown().superRefine((value, ctx) => {
const problem = getTablePredicateTreeSizeError(value)
if (problem) ctx.addIssue({ code: 'custom', message: problem })
})
/**
* The boundary predicate schema: depth/size guard first, then the recursive
* structural parse. The guard is only applied at the top level — every nested
* group is strictly shallower, so re-checking inside the recursion would be
* redundant work on the hot path.
* The canonical grouped predicate schema for dual-grammar boundaries. Keeping
* its root group-only prevents a legacy filter with columns named `field`,
* `op`, and `value` from being reinterpreted as a v2 predicate.
*/
export const predicateSchema = z
.unknown()
.superRefine((value, ctx) => {
const problem = predicateTreeTooLarge(value)
if (problem) ctx.addIssue({ code: 'custom', message: problem })
})
export const predicateSchema = predicateBoundarySchema
// double-cast-allowed: the pipe's inferred input is `unknown`, and letting TS
// widen the recursive lazy union through it makes typecheck OOM
.pipe(predicateTreeSchema) as unknown as z.ZodType<TablePredicate>
/**
* The v2-only input schema accepts either a root leaf or a logical group and
* always outputs the canonical grouped shape. The depth/size guard runs before
* recursive parsing so pathological input returns a validation error, not a
* stack overflow.
*/
export const predicateInputSchema = predicateBoundarySchema
.pipe(predicateNodeSchema)
.transform(normalizeTablePredicate) as z.ZodType<TablePredicate, PredicateNode>
/** v2 sort wire format: an ordered list of `{ field, direction }`. */
export const sortSpecSchema: z.ZodType<SortSpec> = z
.array(
@@ -871,7 +848,7 @@ export const listTableRowsContract = defineRouteContract({
*/
export const rowQueryBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
predicate: predicateSchema.optional(),
predicate: predicateInputSchema.optional(),
sort: sortSpecSchema.optional(),
// Omitted limit returns the ENTIRE matching result, failing fast (400) when
// it exceeds the response byte budget. An explicit limit caps the page row
@@ -1770,7 +1747,7 @@ export const tableViewConfigSchema = tableMetadataSchema.extend({
// The v2 predicate/sort grammar — same wire as the query routes, so a saved
// view gets the same strictness and depth bounds as a live filter, and its
// config can later feed the v2 surfaces without conversion.
filter: predicateSchema.nullable().optional(),
filter: predicateInputSchema.nullable().optional(),
sort: sortSpecSchema.nullable().optional(),
}) satisfies z.ZodType<TableViewConfig>
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import {
predicateSchema,
predicateInputSchema,
sortSpecSchema,
tableColumnSchema,
tableIdParamsSchema,
@@ -11,9 +11,9 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
/**
* Public v2 tables API — typed predicate grammar, cursor paging.
*
* Filters are the `{ all | any: [...] }` predicate tree (same shape the engine
* consumes); no string querystring dialect. Response bodies are fully typed. Row
* data is name-keyed and carries no storage internals (`position`/`orderKey`/
* A filter may be one `{ field, op, value }` condition or an `{ all | any: [...] }`
* predicate tree; no string querystring dialect. Response bodies are fully typed.
* Row data is name-keyed and carries no storage internals (`position`/`orderKey`/
* `executions`) — the public wire is `{ id, data, createdAt, updatedAt }`.
*/
@@ -52,13 +52,14 @@ export const v2ListTablesQuerySchema = z.object({
})
/**
* Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec.
* Rows query body. `predicate` accepts one condition or a grouped tree; `sort`
* is the ordered sort spec.
* `limit`: omitted → {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result
* or a 400 `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap.
*/
export const v2QueryRowsBodySchema = z.object({
workspaceId: workspaceIdSchema,
predicate: predicateSchema.optional(),
predicate: predicateInputSchema.optional(),
sort: sortSpecSchema.optional(),
limit: z
.number({ error: 'Limit must be a number' })
@@ -3924,7 +3924,7 @@ export const QueryUserTable: ToolCatalogEntry = {
filter: {
type: 'object',
description:
'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*"}]}.',
'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*"}.',
},
limit: {
type: 'number',
@@ -5049,7 +5049,7 @@ export const UserTable: ToolCatalogEntry = {
filter: {
type: 'object',
description:
'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"]}]}.',
'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"]}.',
},
groupId: {
type: 'string',
@@ -3790,7 +3790,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
filter: {
type: 'object',
description:
'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*"}]}.',
'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*"}.',
},
limit: {
type: 'number',
@@ -4900,7 +4900,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
filter: {
type: 'object',
description:
'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"]}]}.',
'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"]}.',
},
groupId: {
type: 'string',
@@ -806,6 +806,21 @@ describe('userTableServerTool.query_rows', () => {
expect(options.limit).toBeUndefined()
})
it('normalizes a root condition before querying', async () => {
const result = await userTableServerTool.execute(
{
operation: 'query_rows',
args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'r1' } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({
all: [{ field: 'name', op: 'eq', value: 'r1' }],
})
})
it('decodes an opaque cursor into after/offset and skips the count', async () => {
const cursor = encodeCursor({
lastRow: { id: 'row_9', orderKey: 'a9' },
@@ -931,6 +946,19 @@ describe('userTableServerTool.delete_rows_by_filter', () => {
expect(mockReleaseJobClaim).toHaveBeenCalled()
})
it('normalizes a root condition before deleting', async () => {
const result = await userTableServerTool.execute(
{
operation: 'delete_rows_by_filter',
args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'x' } },
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockDeleteRowsByFilter.mock.calls[0][1].filter).toEqual({ $and: [{ name: 'x' }] })
})
it('rejects an inline delete while another job holds the table slot', async () => {
mockMarkTableJobRunning.mockResolvedValueOnce(false)
@@ -1090,6 +1118,23 @@ describe('userTableServerTool.update_rows_by_filter', () => {
expect(mockMarkTableJobRunning).not.toHaveBeenCalled()
})
it('normalizes a root condition before updating', async () => {
const result = await userTableServerTool.execute(
{
operation: 'update_rows_by_filter',
args: {
tableId: 'tbl_1',
filter: { field: 'name', op: 'eq', value: 'x' },
data: { age: 1 },
},
},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.success).toBe(true)
expect(mockUpdateRowsByFilter.mock.calls[0][1].filter).toEqual({ $and: [{ name: 'x' }] })
})
it('dispatches a background update when the unbounded match count exceeds the cap', async () => {
mockQueryRows.mockResolvedValueOnce({
rows: [],
@@ -52,6 +52,7 @@ import { runTableImport, type TableImportPayload } from '@/lib/table/import-runn
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks'
import { predicateToFilter } from '@/lib/table/query-builder/converters'
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate'
import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor'
import {
@@ -78,6 +79,7 @@ import type {
TableDefinition,
TableDeleteJobPayload,
TablePredicate,
TablePredicateInput,
TableSchema,
TableUpdateJobPayload,
WorkflowGroup,
@@ -689,8 +691,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
// NAMES) then translated to storage ids.
let predicate: TablePredicate | undefined
if (args.filter) {
validatePredicate(args.filter, table.schema.columns)
predicate = predicateToStorage(args.filter, table.schema)
const filter = args.filter as TablePredicateInput
validatePredicate(filter, table.schema.columns)
const normalizedFilter = normalizeTablePredicate(filter)
predicate = predicateToStorage(normalizedFilter, table.schema)
}
let orderSpec = args.order as SortSpec | undefined
if (orderSpec?.length) {
@@ -872,8 +876,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
// Agent authors a predicate object; validate → translate → Filter for
// the bulk engine (same fieldPredicate leaf → identical SQL). Select
// operands arrive as option NAMES and must resolve to stored ids.
validatePredicate(args.filter, table.schema.columns)
const idFilter = predicateToFilter(predicateToStorage(args.filter, table.schema))
const filter = args.filter as TablePredicateInput
validatePredicate(filter, table.schema.columns)
const normalizedFilter = normalizeTablePredicate(filter)
const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema))
const idData = rowDataNameToId(args.data, idByName)
// Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation
@@ -975,8 +981,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
// Agent authors a predicate object; validate → translate → Filter for
// the bulk engine (same fieldPredicate leaf → identical SQL). Select
// operands arrive as option NAMES and must resolve to stored ids.
validatePredicate(args.filter, table.schema.columns)
const idFilter = predicateToFilter(predicateToStorage(args.filter, table.schema))
const filter = args.filter as TablePredicateInput
validatePredicate(filter, table.schema.columns)
const normalizedFilter = normalizeTablePredicate(filter)
const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema))
// Inline handles up to MAX_BULK_OPERATION_SIZE rows; a larger delete (an explicit limit
// above the cap, or unbounded "delete everything matching") hands off to the background
@@ -0,0 +1,24 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
import type { TablePredicate } from '@/lib/table/types'
describe('normalizeTablePredicate', () => {
it('wraps a root condition in the canonical all group', () => {
expect(normalizeTablePredicate({ field: 'status', op: 'eq', value: 'active' })).toEqual({
all: [{ field: 'status', op: 'eq', value: 'active' }],
})
})
it('keeps explicit groups unchanged', () => {
const predicate: TablePredicate = {
any: [
{ field: 'status', op: 'eq', value: 'active' },
{ field: 'status', op: 'eq', value: 'pending' },
],
}
expect(normalizeTablePredicate(predicate)).toBe(predicate)
})
})
@@ -213,3 +213,22 @@ describe('empty groups are rejected at every layer', () => {
expect(() => validatePredicate({ all: [] }, COLS)).toThrow(/at least one condition/)
})
})
describe('predicate complexity limits', () => {
it('rejects deeply nested untrusted input before recursive validation', () => {
let predicate: unknown = { field: 'status', op: 'eq', value: 'active' }
for (let depth = 0; depth < 20_000; depth++) predicate = { all: [predicate] }
expect(() => validatePredicateShape(predicate as never)).toThrow(/Filter nesting is too deep/)
})
it('rejects oversized groups at the shared runtime boundary', () => {
const conditions = Array.from({ length: 101 }, () => ({
field: 'status',
op: 'eq' as const,
value: 'active',
}))
expect(() => validatePredicateShape({ all: conditions })).toThrow(/at most 100 conditions/)
})
})
@@ -4,4 +4,5 @@
export * from '@/lib/table/query-builder/constants'
export * from '@/lib/table/query-builder/converters'
export * from '@/lib/table/query-builder/predicate'
export * from '@/lib/table/query-builder/use-query-builder'
@@ -0,0 +1,51 @@
import type { TablePredicate, TablePredicateInput } from '@/lib/table/types'
/** Max members in one `all`/`any` group. */
export const MAX_PREDICATE_GROUP_SIZE = 100
const MAX_PREDICATE_DEPTH = 10
const MAX_PREDICATE_NODES = 500
/**
* Returns the predicate size-limit violation for an untrusted tree, if any.
* The walk stays iterative so pathological input cannot overflow the call stack
* before the caller turns the result into its boundary-specific validation error.
*/
export function getTablePredicateTreeSizeError(root: unknown): string | null {
const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 1 }]
let nodes = 0
while (stack.length > 0) {
const { node, depth } = stack.pop()!
if (++nodes > MAX_PREDICATE_NODES) {
return `Filter has too many conditions (max ${MAX_PREDICATE_NODES})`
}
if (depth > MAX_PREDICATE_DEPTH) {
return `Filter nesting is too deep (max ${MAX_PREDICATE_DEPTH} levels)`
}
if (typeof node !== 'object' || node === null) continue
const group = node as { all?: unknown; any?: unknown }
const members = Array.isArray(group.all)
? group.all
: Array.isArray(group.any)
? group.any
: null
if (!members) continue
if (members.length > MAX_PREDICATE_GROUP_SIZE) {
return `A filter group can contain at most ${MAX_PREDICATE_GROUP_SIZE} conditions`
}
for (const member of members) stack.push({ node: member, depth: depth + 1 })
}
return null
}
/**
* Converts the readable single-condition v2 input into the grouped shape every
* downstream table path stores and executes. Callers validate untrusted input
* before normalization; contract transforms call this only after a strict parse.
*/
export function normalizeTablePredicate(predicate: TablePredicateInput): TablePredicate {
return 'field' in predicate ? { all: [predicate] } : predicate
}
+19 -5
View File
@@ -2,6 +2,7 @@ import { isRecordLike } from '@sim/utils/object'
import { getColumnId } from '@/lib/table/column-keys'
import { NAME_PATTERN } from '@/lib/table/constants'
import { TableQueryValidationError } from '@/lib/table/errors'
import { getTablePredicateTreeSizeError } from '@/lib/table/query-builder/predicate'
import type {
ColumnDefinition,
ColumnType,
@@ -10,6 +11,7 @@ import type {
PredicateNode,
SortSpec,
TablePredicate,
TablePredicateInput,
} from '@/lib/table/types'
/**
@@ -113,17 +115,26 @@ function validateLeaf(leaf: Predicate, typeByName: Map<string, ColumnType> | nul
* dual-grammar boundaries where the predicate may be NAME- or ID-keyed, so a
* column-existence check against either keying would be wrong.
*/
export function validatePredicateShape(predicate: TablePredicate): void {
export function validatePredicateShape(predicate: TablePredicateInput): void {
validateNode(predicate, null)
}
function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> | null): void {
const sizeError = getTablePredicateTreeSizeError(node)
if (sizeError) throw new TableQueryValidationError(sizeError, 'INVALID_FILTER')
validateNodeStructure(node, typeByName)
}
function validateNodeStructure(
node: PredicateNode,
typeByName: Map<string, ColumnType> | null
): void {
// Guard before the `in` checks below: an untrusted caller (copilot args, a raw
// block value) can hand us a string/number/null, where `'all' in node` throws
// a raw TypeError. Fail with a clean, actionable message instead.
if (typeof node !== 'object' || node === null) {
throw new TableQueryValidationError(
'Filter must be a predicate object ({ all | any: [...] }).',
'Filter must be a predicate condition ({ field, op, value }) or group ({ all | any: [...] }).',
'INVALID_FILTER'
)
}
@@ -165,7 +176,7 @@ function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> |
'INVALID_FILTER'
)
}
for (const child of members) validateNode(child, typeByName)
for (const child of members) validateNodeStructure(child, typeByName)
return
}
// Neither a group nor a leaf. Overwhelmingly this is the legacy `$`-grammar
@@ -179,7 +190,7 @@ function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> |
)
throw new TableQueryValidationError(
looksLegacy
? 'Filter uses the legacy operator-object grammar. Use a predicate tree instead: { all: [{ field, op, value }] } (or "any" for OR), with bare operators like eq/gte/contains/in.'
? 'Filter uses the legacy operator-object grammar. Use a predicate condition instead: { field, op, value }, or an "all"/"any" group for multiple conditions, with bare operators like eq/gte/contains/in.'
: 'A filter node must be a group ({ all | any: [...] }) or a condition ({ field, op, value }).',
'INVALID_FILTER'
)
@@ -192,7 +203,10 @@ function validateNode(node: PredicateNode, typeByName: Map<string, ColumnType> |
* exists, no equality/containment op targets a `json` column, `in`/`nin` carry a
* non-empty array. Throws {@link TableQueryValidationError} (`INVALID_FILTER`).
*/
export function validatePredicate(predicate: TablePredicate, columns: ColumnDefinition[]): void {
export function validatePredicate(
predicate: TablePredicateInput,
columns: ColumnDefinition[]
): void {
validateNode(predicate, buildTypeByName(columns))
}
+2
View File
@@ -538,6 +538,8 @@ export interface Predicate {
*/
export type PredicateNode = Predicate | TablePredicate
export type TablePredicate = { all: PredicateNode[] } | { any: PredicateNode[] }
/** Accepted v2 filter input: either one bare condition or an explicit logical group. */
export type TablePredicateInput = PredicateNode
/** v2 sort specification: an ordered list of `{ field, direction }`. */
export type SortSpec = Array<{ field: string; direction: SortDirection }>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { tableQueryRowsV2Tool } from '@/tools/table/query_rows_v2'
describe('tableQueryRowsV2Tool request body', () => {
it('normalizes a root condition before sending the request', () => {
const body = tableQueryRowsV2Tool.request.body!({
tableId: 'tbl_1',
filter: { field: 'status', op: 'eq', value: 'active' },
_context: { workspaceId: 'ws-1' },
})
expect(body).toEqual({
workspaceId: 'ws-1',
predicate: { all: [{ field: 'status', op: 'eq', value: 'active' }] },
})
})
it('keeps an explicit group unchanged', () => {
const filter = {
any: [
{ field: 'status', op: 'eq' as const, value: 'active' },
{ field: 'status', op: 'eq' as const, value: 'pending' },
],
}
const body = tableQueryRowsV2Tool.request.body!({
tableId: 'tbl_1',
filter,
_context: { workspaceId: 'ws-1' },
})
expect((body as { predicate: unknown }).predicate).toBe(filter)
})
it('fails fast on a malformed filter before issuing the request', () => {
expect(() =>
tableQueryRowsV2Tool.request.body!({
tableId: 'tbl_1',
filter: { status: 'active' } as never,
_context: { workspaceId: 'ws-1' },
})
).toThrow(/group.*condition/i)
})
})
+7 -4
View File
@@ -1,3 +1,5 @@
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
import { validatePredicateShape } from '@/lib/table/query-builder/validate'
import type { TableQueryV2Response, TableRowQueryV2Params } from '@/tools/table/types'
import type { ToolConfig } from '@/tools/types'
@@ -10,8 +12,8 @@ export const tableQueryRowsV2Tool: ToolConfig<TableRowQueryV2Params, TableQueryV
name: 'Query Rows',
description:
'Query rows with a typed predicate filter and cursor pagination. ' +
'Filter is a predicate tree: `{"all":[{"field":"wins","op":"gte","value":10},{"field":"status","op":"in","value":["active","pending"]}]}` ' +
'(`all` = AND, `any` = OR; groups nest). Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
'A single filter can be a plain condition: `{"field":"wins","op":"gte","value":10}`. ' +
'Use `all` (AND) or `any` (OR) groups for multiple or nested conditions. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, ' +
'nlike, nilike, contains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. ' +
'Order is a sort spec, e.g. `[{"field":"wins","direction":"desc"}]`. Omit limit to return the entire result — ' +
'the query fails if it exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, ' +
@@ -30,7 +32,7 @@ export const tableQueryRowsV2Tool: ToolConfig<TableRowQueryV2Params, TableQueryV
type: 'json',
required: false,
description:
'Predicate tree, e.g. `{"all":[{"field":"wins","op":"gte","value":10}]}`. Omit to match all rows.',
'Predicate condition, e.g. `{"field":"wins","op":"gte","value":10}`. Use `all` or `any` for multiple conditions; omit to match all rows.',
visibility: 'user-or-llm',
},
order: {
@@ -63,9 +65,10 @@ export const tableQueryRowsV2Tool: ToolConfig<TableRowQueryV2Params, TableQueryV
if (!workspaceId) {
throw new Error('Workspace ID is required in execution context')
}
if (params.filter) validatePredicateShape(params.filter)
return {
workspaceId,
...(params.filter ? { predicate: params.filter } : {}),
...(params.filter ? { predicate: normalizeTablePredicate(params.filter) } : {}),
...(params.order ? { sort: params.order } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}),
...(params.cursor ? { cursor: params.cursor } : {}),
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
Sort,
SortSpec,
TableDefinition,
TablePredicate,
TablePredicateInput,
TableRow,
TableSchema,
} from '@/lib/table/types'
@@ -61,7 +61,7 @@ export interface TableRowGetParams {
/** v2 query params: typed predicate/sort objects + opaque cursor (no offset). */
export interface TableRowQueryV2Params {
tableId: string
filter?: TablePredicate
filter?: TablePredicateInput
order?: SortSpec
limit?: number
cursor?: string