Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions PureQL-Specification.json
Original file line number Diff line number Diff line change
Expand Up @@ -3265,6 +3265,17 @@
"groupBy"
]
},
"dependentSchemas": {
"groupBy": {
"properties": {
"select": {
"items": {
"$ref": "#/definitions/singleValueReturning"
}
}
}
}
},
"title": "PureQL specification",
"type": "object"
}
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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" } },
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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) |

Expand Down
1 change: 0 additions & 1 deletion samples/07_group_by.json
Original file line number Diff line number Diff line change
@@ -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" } },
Expand Down
1 change: 0 additions & 1 deletion samples/08_having.json
Original file line number Diff line number Diff line change
@@ -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" } },
Expand Down
4 changes: 0 additions & 4 deletions samples/12_complex_query.json
Original file line number Diff line number Diff line change
@@ -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" } },
Expand Down
1 change: 0 additions & 1 deletion samples/18_aggregate_of_each_multiply.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"from": { "entity": "order_items", "alias": "oi" },
"select": [
{ "entity": "orders", "field": "user_id", "type": { "name": "uuid" } },
{
"operator": "sum",
"arg": {
Expand Down
Loading