mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user