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
33 changes: 33 additions & 0 deletions .changeset/20099-having-where-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/objectql": minor
---

fix(objectql)!: `engine.aggregate({ having })` takes the rest of the filter doors `where` takes — the comparand-type door, a check that `having` is a filter condition at all, refusals that no longer depend on the rows, and `{ $field }` references resolved against the aggregated row (#20099)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (already-registered filter-between-field-reference-endpoint-refused, filter-icontains-comparand-refused-at-parse, filter-regex-options-retired) this change adds no new transition. The first id's replacement prescribes the two-bound { $field } spelling and states that a reference is legal as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte; `having` now evaluates exactly that, and refuses the list-member position the same ruling removed. The other two register the $icontains comparand and the retired $regex / $options refusals, which `having` already made on a populated grouped set and now makes whatever the rows. The comparand-type set was ruled at the shared face with no ledger entry; this change runs that face, unmodified, on one more position. The remaining refusals are of inputs `having` never had a meaning for: a value that is not a filter condition (FilterConditionSchema, the declared type of `having`, refuses it), an operator outside the vocabulary, and a reference that names no column. `having` is a request-only key: no metadata type stores it, so there is no stored document for `objectstack migrate meta` to rewrite. The table below is the author-facing remedy for each row, not a mechanical rewrite. -->

**BREAKING**: this narrows what `having` accepts on `engine.aggregate` (and on the REST aggregate query that forwards it there). Every refusal below is `INVALID_FILTER` / 400, raised once per query before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback. It ships as `minor` under the launch-window convention for accept-set narrowings.

`having` took one of `where`'s filter doors, the comparand-shape face. The engine evaluates `having` itself, once per aggregated row, and that walker answered the shapes the other doors refuse — usually with no group and no error. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sqlite-wasm`, both paths, over groups with totals 500, 900 and 20 and a `max_cap` of 50, 5000 and 20:

| you wrote in `having` | what it did before | write instead |
|:--|:--|:--|
| `{ total: { $eq: { v: 1 } } }`, `{ total: undefined }`, a `Map`, a function, a `Symbol`, a bigint beyond 2^53, or `{ $field: 5 }` as a comparand | depended on the operator. On the numeric `total`: in the implicit slot (the non-object values) and under `$eq`, `$gt` and `$gte` it kept no group; under `$ne` it kept every group; under `$lt` / `$lte` it kept no group, except the bigint, which kept every group. A `Symbol` under an ordering operator threw a raw `TypeError` with no `code` and no `status` on a populated grouped set. The same comparand in `where` is refused by the comparand-type door, and `having` now gets that door's refusal, with the path rooted at `having` | a string, number, bigint, boolean, `null` or `Date`; `{ total: { $eq: null } }` for "has no value" |
| `[['total', '>', 100]]`, `['total', '>', 100]` or `['and', …]` | kept no group: the array's index keys were read as column names | `{ total: { $gt: 100 } }`. The array form is input-only sugar declared on `where` alone, and `having` is declared as a filter condition object |
| `[]`, a string such as `'total > 100'`, a number, a boolean, a `Map` or a `Date` | kept every group, as if there were no `having` | the object form, or no `having` |
| an unknown or retired operator (`$median`, `$regex`), or an empty or non-string `$icontains` | refused only when a grouped row reached it: an empty grouped set, a condition on a column the row does not carry, or a `$or` whose earlier branch held all answered without an error | the operator the refusal names |
| `{ total: { $field: 'max_cap' } }` (a reference with no operator) | refused as an unsupported operator, again only when a grouped row carried `total` | `{ total: { $eq: { $field: 'max_cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` |
| a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` / `$startsWith` / `$endsWith` / `$notContains` pattern, or an `$exists` / `$null` operand | compared the reference object itself, so the answer never depended on the column it named: no group under `$in` / `$contains`, every group under `$nin` / `$notContains` / `$exists` | a literal there, or the comparison as one of the six scalar operators |
| a `{ $field }` reference naming a column the aggregated row does not have, or carrying an `addDays` that is not an integer or a `{ $field }` | compared the reference object itself, so the answer depended on the operator: no group under `$eq`; every group under `$ne`; under an ordering operator, no group against a number column, and against a text or date column an answer that follows each value's string order against the text `[object Object]` | a groupBy projection or an aggregation alias of the same query (the refusal lists them); a whole-day `addDays` |

Not refused, but answering differently:

- **A `{ $field }` reference as the whole comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` is now resolved against the aggregated row.** Before, it was compared as an object: `{ total: { $gt: { $field: 'max_cap' } } }` kept no group, and the same reference under `$ne` kept every group. It now keeps the groups whose `total` exceeds their own `max_cap`. The two-bound spelling the `{ $field }` `$between` refusal prescribes, `{ total: { $gte: { $field: 'max_cap' }, $lte: 1000 } }`, now works on `having`. The reference names another column of the same aggregated row: a groupBy projection or an aggregation alias. The comparison is the one the platform's in-memory filter evaluator makes and the SQL cross-field compiler matches row for row: an ordering against a missing value is false, `$eq` holds when both sides have no value, and `addDays` adds whole days to a date column.
- **The same resolution applies to a per-aggregation `filter`** (`aggregations[i].filter`), which the engine evaluates with the same walker against the source rows. `{ function: 'count', filter: { amount: { $gt: { $field: 'cap' } } } }` used to count no row. It now counts the rows whose `amount` exceeds their `cap`.
- **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ total: { $in: [500n, 20n] } }` kept no group, because `[500n].includes(500)` is false. It now keeps the 500 and 20 groups. The caller's `having` object is not edited.

Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills is a scalar comparison against an aggregation alias (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured.

Not changed: scalars, `null` in the equality slot, `$in` / `$nin` lists, a two-bound `$between`, scalar ordering bounds, `{}`, `null` and an omitted `having`, on both paths. The `$like` / `$ilike` operators are still refused on `having` (they are staged out of `FILTER_OPERATORS`), and `$ne` with a list is still answered until the shared face judges it. A `having` key that names no column still keeps no group rather than being refused.
Loading
Loading