fix(tables): accept plain row query predicates (#6916)

* fix(tables): accept plain row query predicates

* fix(cli): show table predicate group syntax
This commit is contained in:
Theodore Li
2026-08-20 20:48:47 -04:00
committed by GitHub
parent 8eebd6e687
commit 5b28da1989
13 changed files with 351 additions and 248 deletions
+2 -2
View File
@@ -2979,7 +2979,7 @@ sim tables rows query <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Condition: &#123;"field":"status","op":"eq","value":"active"&#125;. Groups: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125; or &#123;"any":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Ordered sort keys: [&#123;"field":"createdAt","direction":"desc"&#125;] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
@@ -3009,7 +3009,7 @@ sim tables rows count <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Condition: &#123;"field":"status","op":"eq","value":"active"&#125;. Groups: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125; or &#123;"any":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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). |
</CommandTable>
+2 -2
View File
@@ -642,7 +642,7 @@ sim tables rows query <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Condition: &#123;"field":"status","op":"eq","value":"active"&#125;. Groups: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125; or &#123;"any":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Ordered sort keys: [&#123;"field":"createdAt","direction":"desc"&#125;] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
@@ -670,7 +670,7 @@ sim tables rows count <tableId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--filter <json\|@file>` | No | Predicate: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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 <json\|@file>` | No | Condition: &#123;"field":"status","op":"eq","value":"active"&#125;. Groups: &#123;"all":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125; or &#123;"any":[&#123;"field":"status","op":"eq","value":"active"&#125;]&#125;; 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). |
</CommandTable>
+190 -225
View File
@@ -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": {
@@ -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,
@@ -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
+11 -4
View File
@@ -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,
@@ -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) {
@@ -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' },
},
]
),
+6 -4
View File
@@ -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<typeof v2QueryRowsBodySchema>
export const v2QueryRowsCountBodySchema = z
.object({
workspaceId: workspaceIdSchema,
predicate: predicateSchema.optional(),
predicate: predicateInputSchema.optional(),
})
.strict()
export type V2QueryRowsCountBody = z.input<typeof v2QueryRowsCountBodySchema>
@@ -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 <json|@file>')
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')
})
+4 -2
View File
@@ -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,
},
},
},
+52 -2
View File
@@ -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.',
},
},
},
+4 -1
View File
@@ -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"}]')
})