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..0ebea87 100644 --- a/PureQL-Specification.json +++ b/PureQL-Specification.json @@ -3265,6 +3265,17 @@ "groupBy" ] }, + "dependentSchemas": { + "groupBy": { + "properties": { + "select": { + "items": { + "$ref": "#/definitions/singleValueReturning" + } + } + } + } + }, "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": {