diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 5a37dbaedaf..4346ef28715 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -2979,7 +2979,7 @@ sim tables rows query [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | @@ -3009,7 +3009,7 @@ sim tables rows count [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index 9f477ab9501..fed2c65ca92 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -642,7 +642,7 @@ sim tables rows query [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | @@ -670,7 +670,7 @@ sim tables rows count [options] | Option | Required | Description | | --- | --- | --- | -| `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | +| `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 877c8b329ce..1cf9a8a629c 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -1407,7 +1407,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.", + "description": "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1424,7 +1424,7 @@ ], "requestBody": { "required": true, - "description": "Workspace scope, optional predicate and sort, and cursor pagination controls.", + "description": "Workspace scope, optional predicate and sort, and cursor pagination controls. The predicate may be one condition or an `all`/`any` group; omitting it matches every row.", "content": { "application/json": { "schema": { @@ -1486,7 +1486,7 @@ "post": { "operationId": "countTableRows", "summary": "Count Rows", - "description": "Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", + "description": "Count the rows matching a typed predicate across the entire table. A predicate may be one condition or an `all`/`any` group. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1503,7 +1503,7 @@ ], "requestBody": { "required": true, - "description": "Workspace scope and the optional predicate whose matches are counted.", + "description": "Workspace scope and the optional condition or `all`/`any` predicate group whose matches are counted.", "content": { "application/json": { "schema": { @@ -5472,6 +5472,188 @@ "title": "Query table rows response", "description": "A cursor-paginated page of matching table rows." }, + "TablePredicateInput": { + "title": "Table predicate input", + "description": "A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "oneOf": [ + { + "type": "object", + "description": "Matches a row when every member matches.", + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["all"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Matches a row when at least one member matches.", + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", + "items": { + "description": "A nested group, or a single condition.", + "anyOf": [ + { + "$ref": "#/components/schemas/TablePredicateInput" + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["any"], + "additionalProperties": false + }, + { + "type": "object", + "title": "Predicate condition", + "description": "One column comparison.", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + }, + "value": { + "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." + } + }, + "required": ["field", "op"], + "additionalProperties": false + } + ] + }, "QueryTableRowsRequest": { "type": "object", "properties": { @@ -5482,7 +5664,7 @@ "description": "Unique workspace identifier." }, "predicate": { - "$ref": "#/components/schemas/TablePredicate" + "$ref": "#/components/schemas/TablePredicateInput" }, "sort": { "description": "Ordered table-row sort specification.", @@ -5522,28 +5704,7 @@ "required": ["workspaceId"], "additionalProperties": false, "title": "Query table rows request", - "description": "Workspace scope, optional predicate and sort, and cursor pagination controls.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "predicate": { - "all": [ - { - "field": "status", - "op": "eq", - "value": "active" - } - ] - }, - "sort": [ - { - "field": "createdAt", - "direction": "desc" - } - ], - "limit": 100 - } - ] + "description": "Workspace scope, optional predicate and sort, and cursor pagination controls. The predicate may be one condition or an `all`/`any` group; omitting it matches every row." }, "V2QueryRowsCountData": { "type": "object", @@ -5583,27 +5744,13 @@ "description": "Unique workspace identifier." }, "predicate": { - "$ref": "#/components/schemas/TablePredicate" + "$ref": "#/components/schemas/TablePredicateInput" } }, "required": ["workspaceId"], "additionalProperties": false, "title": "Count table rows request", - "description": "Workspace scope and the optional predicate whose matches are counted.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "predicate": { - "all": [ - { - "field": "status", - "op": "eq", - "value": "active" - } - ] - } - } - ] + "description": "Workspace scope and the optional condition or `all`/`any` predicate group whose matches are counted." }, "V2ApiTableView": { "type": "object", @@ -5789,188 +5936,6 @@ "title": "Create table view response", "description": "The created saved view." }, - "TablePredicateInput": { - "title": "Table predicate input", - "description": "A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.", - "oneOf": [ - { - "type": "object", - "description": "Matches a row when every member matches.", - "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", - "items": { - "description": "A nested group, or a single condition.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicateInput" - }, - { - "type": "object", - "title": "Predicate condition", - "description": "One column comparison.", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." - }, - "op": { - "type": "string", - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." - }, - "value": { - "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." - } - }, - "required": ["field", "op"], - "additionalProperties": false - } - ] - } - } - }, - "required": ["all"], - "additionalProperties": false - }, - { - "type": "object", - "description": "Matches a row when at least one member matches.", - "properties": { - "any": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", - "items": { - "description": "A nested group, or a single condition.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicateInput" - }, - { - "type": "object", - "title": "Predicate condition", - "description": "One column comparison.", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." - }, - "op": { - "type": "string", - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." - }, - "value": { - "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." - } - }, - "required": ["field", "op"], - "additionalProperties": false - } - ] - } - } - }, - "required": ["any"], - "additionalProperties": false - }, - { - "type": "object", - "title": "Predicate condition", - "description": "One column comparison.", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." - }, - "op": { - "type": "string", - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." - }, - "value": { - "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." - } - }, - "required": ["field", "op"], - "additionalProperties": false - } - ] - }, "CreateTableViewRequest": { "type": "object", "properties": { diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts index 576b3925505..d42d5392f43 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.test.ts @@ -114,6 +114,19 @@ describe('POST /api/v2/tables/[tableId]/query/count', () => { }) }) + it('normalizes a plain condition into a predicate group', async () => { + const condition = { field: 'phone', op: 'isEmpty' } + const invocation = call({ workspaceId: WORKSPACE_ID, predicate: condition }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.queryRows).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ predicate: { all: [condition] } }), + }) + ) + }) + it('counts the whole table when no predicate is sent', async () => { mocks.queryRows.mockResolvedValue({ table: TABLE, diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index e271d8eee6c..93db3bb653e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -128,6 +128,39 @@ describe('POST /api/v2/tables/[tableId]/query', () => { }) }) + it('normalizes a plain condition into a predicate group', async () => { + const condition = { field: 'phone', op: 'isEmpty' } + const invocation = call({ workspaceId: WORKSPACE_ID, predicate: condition }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.queryRows).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ predicate: { all: [condition] } }), + }) + ) + }) + + it('queries every row when the predicate is omitted', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.queryRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate: undefined, + sort: undefined, + cursor: undefined, + limit: 100, + includeTotal: false, + }, + request: invocation.request, + }) + }) + it('preserves explicit limit=0 as the unbounded opt-in', async () => { await call({ workspaceId: WORKSPACE_ID, limit: 0 }).response diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 10690687308..4f024fba6fd 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -666,9 +666,17 @@ const predicateGroupsJsonSchema = (selfRef: string) => * false and the negation true — the same include-nulls behaviour as every other * negation. Pinned by `__tests__/sql.test.ts`. */ +const PREDICATE_LIMITS_DESCRIPTION = `At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.` +const PREDICATE_NEGATION_DESCRIPTION = + 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`.' const PREDICATE_TREE_DESCRIPTION = [ - `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.`, - 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`.', + `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. ${PREDICATE_LIMITS_DESCRIPTION}`, + PREDICATE_NEGATION_DESCRIPTION, + PREDICATE_OPERATOR_GRAMMAR, +].join(' ') +const PREDICATE_INPUT_DESCRIPTION = [ + `A single \`{ field, op, value }\` condition or a recursive \`all\`/\`any\` group; either form is normalized to a grouped predicate after validation. ${PREDICATE_LIMITS_DESCRIPTION}`, + PREDICATE_NEGATION_DESCRIPTION, PREDICATE_OPERATOR_GRAMMAR, ].join(' ') @@ -700,8 +708,7 @@ export const predicateInputSchema = predicateBoundarySchema .meta({ id: 'TablePredicateInput', title: 'Table predicate input', - description: - 'A single `{ field, op, value }` condition or a group, normalized to a grouped predicate after validation. Same grammar and limits as `TablePredicate`.', + description: PREDICATE_INPUT_DESCRIPTION, oneOf: [ ...predicateGroupsJsonSchema('#/$defs/TablePredicateInput'), PREDICATE_LEAF_JSON_SCHEMA, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 481cf479b6e..54defc303fa 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -23,6 +23,7 @@ import { v2FindRowsDataSchema, v2GetTableImportContract, v2QueryRowsBodySchema, + v2QueryRowsCountBodySchema, v2TableImportStatusSchema, v2TableUploadImportSourceSchema, v2UpdateTableColumnBodySchema, @@ -236,6 +237,17 @@ describe('v2 table request bodies', () => { expect(result.success).toBe(false) expect(issueCodes(result.error?.issues ?? [])).toContain('unrecognized_keys') }) + + it.each([ + ['query', v2QueryRowsBodySchema], + ['count', v2QueryRowsCountBodySchema], + ])('normalizes a plain condition on the rows %s body', (_name, schema) => { + const condition = { field: 'status', op: 'eq', value: 'active' } + + expect(schema.parse({ workspaceId: WORKSPACE_ID, predicate: condition })).toMatchObject({ + predicate: { all: [condition] }, + }) + }) }) function uploadSource(size: number) { diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index dfff91921c5..b6685b594a2 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -644,7 +644,7 @@ const declaredRoutes = [ operationId: 'queryTableRows', summary: 'Query Rows', description: - 'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.', + 'Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`.', errors: TABLE_QUERY_ERRORS, success: { description: 'A page of matching table rows.' }, }), @@ -660,14 +660,27 @@ const declaredRoutes = [ v2QueryRowsContract.body, 'QueryTableRowsRequest', 'Query table rows request', - 'Workspace scope, optional predicate and sort, and cursor pagination controls.', + 'Workspace scope, optional predicate and sort, and cursor pagination controls. The predicate may be one condition or an `all`/`any` group; omitting it matches every row.', [ { workspaceId: WORKSPACE_ID, - predicate: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + limit: 100, + }, + { + workspaceId: WORKSPACE_ID, + predicate: { field: 'status', op: 'eq', value: 'active' }, sort: [{ field: 'createdAt', direction: 'desc' }], limit: 100, }, + { + workspaceId: WORKSPACE_ID, + predicate: { + all: [ + { field: 'status', op: 'eq', value: 'active' }, + { field: 'score', op: 'gte', value: 80 }, + ], + }, + }, ] ), response: documentedSchema( @@ -684,7 +697,7 @@ const declaredRoutes = [ operationId: 'countTableRows', summary: 'Count Rows', description: - 'Count the rows matching a typed predicate across the entire table. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', + 'Count the rows matching a typed predicate across the entire table. A predicate may be one condition or an `all`/`any` group. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.', errors: TABLE_QUERY_ERRORS, success: { description: 'The number of matching table rows.' }, }), @@ -700,11 +713,11 @@ const declaredRoutes = [ v2QueryRowsCountContract.body, 'CountTableRowsRequest', 'Count table rows request', - 'Workspace scope and the optional predicate whose matches are counted.', + 'Workspace scope and the optional condition or `all`/`any` predicate group whose matches are counted.', [ { workspaceId: WORKSPACE_ID, - predicate: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + predicate: { field: 'status', op: 'eq', value: 'active' }, }, ] ), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 3708be6662f..bfb77e18be6 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -11,6 +11,7 @@ import { deleteWorkflowGroupBodySchema, exportTableAsyncBodySchema, insertTableRowBodyBaseSchema, + predicateInputSchema, predicateSchema, refineCancelTableRunsScope, refineColumnOptions, @@ -720,8 +721,9 @@ export const v2ListTableRowsContract = defineRouteContract({ }) /** - * Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec, - * field refs keyed by column NAME. `limit`: omitted → + * Rows query body. `predicate` accepts one condition or a typed predicate tree + * and normalizes either form to a tree; predicate/sort field refs are keyed by + * column NAME. `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. * @@ -736,7 +738,7 @@ export const v2ListTableRowsContract = defineRouteContract({ export const v2QueryRowsBodySchema = z .object({ workspaceId: workspaceIdSchema, - predicate: predicateSchema.optional(), + predicate: predicateInputSchema.optional(), sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), limit: z .number({ error: 'Limit must be a number' }) @@ -766,7 +768,7 @@ export type V2QueryRowsBody = z.input export const v2QueryRowsCountBodySchema = z .object({ workspaceId: workspaceIdSchema, - predicate: predicateSchema.optional(), + predicate: predicateInputSchema.optional(), }) .strict() export type V2QueryRowsCountBody = z.input diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 795635f75b8..da0ac01386b 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -101,7 +101,10 @@ describe('renamed commands keep their surface', () => { it('documents the filter operators on the row count', () => { const help = commandAt('tables', 'rows', 'count').helpInformation() expect(help).toContain('--filter ') + expect(help).toContain('{"field":"status","op":"eq","value":"active"}') expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('{"any":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('group entries may also be nested groups') expect(help).not.toContain('--predicate') }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 04ac2378b94..7c9f5d97b6a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -3,6 +3,8 @@ import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' +const TABLE_READ_FILTER_HELP = + 'Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' const TABLE_SORT_HELP = 'Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc)' const CUSTOM_TOOL_SCHEMA_HELP = @@ -294,7 +296,7 @@ export const CLI_CONTRACT: CliContract = { queryRows: { command: 'tables rows query', flags: { - predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + predicate: { name: 'filter', json: true, describe: TABLE_READ_FILTER_HELP }, sort: { json: true, describe: TABLE_SORT_HELP }, }, // A row's cells live under `data`; without this the table showed an id and @@ -772,7 +774,7 @@ export const CLI_CONTRACT: CliContract = { name: 'filter', renamedFrom: ['predicate'], json: true, - describe: TABLE_FILTER_HELP, + describe: TABLE_READ_FILTER_HELP, }, }, }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index b2fc05b9e3e..6271ef1ed30 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4487,6 +4487,31 @@ type QueryRowsBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type QueryRowsBody = { workspaceId: string @@ -4581,6 +4606,31 @@ type QueryRowsCountBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type QueryRowsCountBody = { workspaceId: string @@ -8850,7 +8900,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', }, sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, limit: { @@ -8872,7 +8922,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', }, }, }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index eeee9c4019f..08109381be4 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -507,9 +507,12 @@ describe('commands parsed through commander', () => { expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) }) - it('documents the table predicate and sort wire shapes in help', () => { + it('documents plain and grouped table queries in help', () => { const help = commandAt('tables', 'rows', 'query').helpInformation() + expect(help).toContain('{"field":"status","op":"eq","value":"active"}') expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('{"any":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('group entries may also be nested groups') expect(help).toContain('[{"field":"createdAt","direction":"desc"}]') })