From f5030da885c8daa3830a9229a3fe697faf01da63 Mon Sep 17 00:00:00 2001 From: Dmitry Kurochkin Date: Thu, 24 Sep 2026 12:57:42 +0000 Subject: [PATCH 1/2] feat(schema)!: restrict grouped select to single-value expressions When groupBy is present, select items must be singleValueReturning, enforced via a root dependentSchemas rule and a new singleValueSelectExpression definition. Group keys are emitted automatically as the leading result columns, so fields that aren't grouped and per-row each* columns can no longer appear next to aggregates in a grouped query. Remove group-key fields from select in samples 07, 08, 12 and 18, and document the rule in README, CLAUDE.md and CHANGELOG. BREAKING CHANGE: grouped queries that list groupBy fields (or any other field or each* expression) in select no longer validate. Refs #52 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CLAUDE.md | 6 ++++- PureQL-Specification.json | 27 ++++++++++++++++++++++ README.md | 21 ++++++++++++++--- samples/07_group_by.json | 1 - samples/08_having.json | 1 - samples/12_complex_query.json | 4 ---- samples/18_aggregate_of_each_multiply.json | 1 - 8 files changed, 51 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e1439..5608f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Versioning follows Semantic Versioning with preview suffix `major.minor.patch-pr - **`having` now requires `groupBy`**: Queries that filter with `having` must also group their rows with `groupBy`. Previously the schema accepted `having` on its own, even though it has no meaning without groups. (#40) - **`groupBy` can no longer be empty**: `groupBy` must list at least one field. To skip grouping, leave the clause out. +- **Grouped queries select only single values**: When a query has `groupBy`, `select` accepts only aggregates, scalars, parameters and arithmetic over them. The `groupBy` fields now appear in the result automatically as the first columns, so remove them from `select`. Fields that aren't grouped and per-row `each*` columns are rejected, because a group has no single value for them. (#52) --- diff --git a/CLAUDE.md b/CLAUDE.md index 5a89603..025e0a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Every operator in the schema belongs to one of two parallel families: | `where` | single-value boolean **or** per-row boolean (per-row is the common case) | | `join.on` | single-value boolean **or** per-row boolean (per-row equi-join is the common case) | | `having` | single-value boolean **only** — operands must reduce to one value per group | -| `select` | any value-returning expression, including per-row computed columns | +| `select` | any value-returning expression, including per-row computed columns — **single-value only when `groupBy` is present** | | `groupBy` / `orderBy` | field references only | ### Fields are `arrayReturning` @@ -120,6 +120,10 @@ Only the root `from` expression supports an `alias`. Joined entities are always Not select expressions — just plain `{ entity, field, type }` field references. No aliases, no operators. +### Grouped `select` is single-value only + +When `groupBy` is present, every `select` item must be `singleValueReturning` (enforced via root `dependentSchemas`). Group keys are emitted automatically as the leading result columns, so **never repeat `groupBy` fields in `select`** — the schema rejects them, along with any non-grouped field or `each*` column. + ## Workflow rules - Never commit directly to `main`. Always create a new branch and open a pull request. diff --git a/PureQL-Specification.json b/PureQL-Specification.json index b683849..bf8b1c5 100644 --- a/PureQL-Specification.json +++ b/PureQL-Specification.json @@ -319,6 +319,22 @@ } ] }, + "singleValueSelectExpression": { + "allOf": [ + { + "$ref": "#/definitions/singleValueReturning" + }, + { + "type": "object", + "properties": { + "alias": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, "singleValueReturning": { "oneOf": [ { @@ -3265,6 +3281,17 @@ "groupBy" ] }, + "dependentSchemas": { + "groupBy": { + "properties": { + "select": { + "items": { + "$ref": "#/definitions/singleValueSelectExpression" + } + } + } + } + }, "title": "PureQL specification", "type": "object" } diff --git a/README.md b/README.md index edf6e94..895bac1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ PureQL is a JSON-based declarative query language for relational data. Queries a | `select` | yes | Array of expressions to return | | `where` | no | Boolean filter applied before grouping | | `joins` | no | Array of join clauses | -| `groupBy` | no | Fields to group rows by (at least one) | +| `groupBy` | no | Fields to group rows by (at least one); group keys are output automatically | | `having` | no | Boolean filter applied after grouping; requires `groupBy` | | `orderBy` | no | Fields to order results by | | `pagination` | no | `skip` and `take` for paging | @@ -91,6 +91,21 @@ Parameters are named placeholders resolved at execution time, analogous to prepa Each item in `select` is a value-returning expression (field, scalar, aggregate, arithmetic, boolean expression) with an optional `alias`. +When `groupBy` is present, `select` accepts **single-value expressions only** (aggregates, scalars, parameters, arithmetic over them). Fields and per-row `each*` columns are rejected by the schema, because a group has no single value for them. The `groupBy` fields are output automatically as the leading result columns, in `groupBy` order and named after the field, followed by the `select` entries: + +```json +"select": [ + { "operator": "count", "arg": { "entity": "orders", "field": "id", "type": { "name": "uuid" } }, "alias": "order_count" } +], +"groupBy": [ + { "entity": "orders", "field": "user_id", "type": { "name": "uuid" } } +] +``` + +Result columns: `user_id`, `order_count`. + +Ungrouped example: + ```json "select": [ { "entity": "users", "field": "name", "type": { "name": "string" } }, @@ -142,7 +157,7 @@ Each join specifies its type (`inner`, `left`, `right`, `full`), the entity to j ### `groupBy` / `orderBy` -`groupBy` accepts an array of field references. `orderBy` accepts an array of `orderByItem` objects, each pairing a `field` with an optional `direction` (`"asc"` | `"desc"`, default `"asc"`). +`groupBy` accepts an array of field references. Group keys are added to the result automatically, so they are not repeated in `select`. `orderBy` accepts an array of `orderByItem` objects, each pairing a `field` with an optional `direction` (`"asc"` | `"desc"`, default `"asc"`). ```json "groupBy": [ @@ -179,7 +194,7 @@ Where each family fits: |---|---| | `where` / `join.on` | per-row boolean (typical) or single-value boolean | | `having` | single-value boolean only — non-aggregated fields are structurally rejected | -| `select` | any value-returning expression, including per-row computed columns | +| `select` | any value-returning expression, including per-row computed columns; single-value only when `groupBy` is present | | `sum.arg` / `min_*.arg` / `max_*.arg` / `average_*.arg` | any array-returning expression (field, per-row computation) | | right operand of any `each*` comparison | matching `*Returning` (broadcast scalar) or `*ArrayReturning` (element-wise) | diff --git a/samples/07_group_by.json b/samples/07_group_by.json index 76d3068..53d489a 100644 --- a/samples/07_group_by.json +++ b/samples/07_group_by.json @@ -1,7 +1,6 @@ { "from": { "entity": "order_items" }, "select": [ - { "entity": "order_items", "field": "product_id", "type": { "name": "uuid" } }, { "operator": "count", "arg": { "entity": "order_items", "field": "id", "type": { "name": "uuid" } }, diff --git a/samples/08_having.json b/samples/08_having.json index 408c45b..3417635 100644 --- a/samples/08_having.json +++ b/samples/08_having.json @@ -1,7 +1,6 @@ { "from": { "entity": "orders" }, "select": [ - { "entity": "orders", "field": "user_id", "type": { "name": "uuid" } }, { "operator": "count", "arg": { "entity": "orders", "field": "id", "type": { "name": "uuid" } }, diff --git a/samples/12_complex_query.json b/samples/12_complex_query.json index 6ac4a34..ee0afc0 100644 --- a/samples/12_complex_query.json +++ b/samples/12_complex_query.json @@ -1,10 +1,6 @@ { "from": { "entity": "orders", "alias": "o" }, "select": [ - { "entity": "users", "field": "id", "type": { "name": "uuid" }, "alias": "customer_id" }, - { "entity": "users", "field": "name", "type": { "name": "string" }, "alias": "customer_name" }, - { "entity": "users", "field": "email", "type": { "name": "string" } }, - { "entity": "users", "field": "tier", "type": { "name": "string" }, "alias": "membership_tier" }, { "operator": "count", "arg": { "entity": "o", "field": "id", "type": { "name": "uuid" } }, diff --git a/samples/18_aggregate_of_each_multiply.json b/samples/18_aggregate_of_each_multiply.json index 3b6acfb..3ef47a5 100644 --- a/samples/18_aggregate_of_each_multiply.json +++ b/samples/18_aggregate_of_each_multiply.json @@ -1,7 +1,6 @@ { "from": { "entity": "order_items", "alias": "oi" }, "select": [ - { "entity": "orders", "field": "user_id", "type": { "name": "uuid" } }, { "operator": "sum", "arg": { From 2ee3aa1f438db3f48ebbbd19aa63c0a9c9f6a13f Mon Sep 17 00:00:00 2001 From: Dmitry Kurochkin Date: Thu, 24 Sep 2026 13:45:36 +0000 Subject: [PATCH 2/2] refactor(schema): drop redundant singleValueSelectExpression dependentSchemas applies on top of the root select schema, so alias is already validated by selectExpression. The grouped select rule can reference singleValueReturning directly. Co-Authored-By: Claude Opus 5.5 --- PureQL-Specification.json | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/PureQL-Specification.json b/PureQL-Specification.json index bf8b1c5..0ebea87 100644 --- a/PureQL-Specification.json +++ b/PureQL-Specification.json @@ -319,22 +319,6 @@ } ] }, - "singleValueSelectExpression": { - "allOf": [ - { - "$ref": "#/definitions/singleValueReturning" - }, - { - "type": "object", - "properties": { - "alias": { - "type": "string", - "minLength": 1 - } - } - } - ] - }, "singleValueReturning": { "oneOf": [ { @@ -3286,7 +3270,7 @@ "properties": { "select": { "items": { - "$ref": "#/definitions/singleValueSelectExpression" + "$ref": "#/definitions/singleValueReturning" } } }