Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/sim/app/api/table/[tableId]/query/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/blocks/blocks/table_v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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' }] })
}
)
})

/**
Expand All @@ -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
)
})
})
37 changes: 24 additions & 13 deletions apps/sim/blocks/blocks/table_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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'],
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/api/contracts/tables-predicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand All @@ -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({
Expand Down
75 changes: 26 additions & 49 deletions apps/sim/lib/api/contracts/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>

Expand Down
Loading
Loading