mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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:
@@ -2979,7 +2979,7 @@ sim tables rows query <tableId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--filter <json\|@file>` | 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 <json\|@file>` | 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 <json\|@file>` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (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: {"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 <json\|@file>` | 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). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@ sim tables rows query <tableId> [options]
|
||||
|
||||
| Option | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `--filter <json\|@file>` | 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 <json\|@file>` | 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 <json\|@file>` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (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: {"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 <json\|@file>` | 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). |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
|
||||
+190
-225
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
]
|
||||
),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"}]')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user