Repository navigation
Commit a78f731
Fixes #20263
Clause-②: no (narrowing)
## What this does
`engine.aggregate` evaluates `having` itself, so no driver reads it, and
none of the doors in front of it judged a temporal comparand. A
comparand the aggregated column's storage rule cannot read was compared
as written: `having { last_placed: { $lt: "not-a-date" } }` on
`max(placed_on)` kept every group with a 200, while its `where` twin
answered 400.
`having` now takes the temporal-comparand door that `where` (#8690) and
the per-aggregation `filter` (#20148) take: the same walk and the same
`@objectstack/core` predicate (`isUninterpretableTemporalComparand`).
When #20264 moves the year range in that predicate, `having` follows
with no second edit (pinned: see "one predicate" below).
- `packages/objectql/src/temporal-comparand-door.ts`: the walk is
factored into one `walkCondition` with a per-position scope (the kind of
the column a key names, and which operators are judged).
`findUninterpretableTemporalComparand` (the `where` entry) keeps its
signature and behaviour. New
`assertHavingTemporalComparandsInterpretable(object, having, classes,
query)`.
- `packages/objectql/src/engine.ts`, `ObjectQL.aggregate`: one call,
after `assertHavingIsEvaluable` and before `getDriver`, so it covers
both `applyHaving` doors (native `driver.aggregate()` and the rows
fallback) with zero driver reads on a refusal.
- `packages/objectql/src/having-filter.ts`: `temporalKindOf` is exported
(one keyword and a doc line), so the door and the storage rule read one
kind.
- Tests in `objectql` and `rest`, and
`.changeset/20263-having-temporal-comparand-door.md` (`minor`, BREAKING
narrowing, ADR-0087 `not-required (no-migration-prescription)`).
## How the kind is read (H3)
From the class #20127 already derives, `aggregatedRowColumnClasses`,
which `engine.aggregate` computes once and now hands to this door too;
nothing is derived again. `min` / `max` of a `date` / `datetime` /
`time` field keeps that kind, a groupBy projection of such a field takes
its kind, a `day` bucket is a `date`. `count` / `count_distinct` / `sum`
/ `avg`, a `week` / `month` / `quarter` / `year` bucket, and every other
column are not judged. An alias that names no column is still refused by
#20123's words, first.
## Two deliberate differences from `where`, each measured
1. **Order: last among the `having` doors.** On a clause another
`having` door refuses (an unknown operator, a key naming no column, a
comparand of no comparable type, an `addDays` pair, an array in the
equality slot), that door answers in its own words. So every cell
refused at the base keeps its words byte for byte (H4), and only cells
that answered 200 move.
2. **The text operators are not judged.** On `where` a text operator on
a temporal field never reaches this door: #15661's declared-type door
refuses it one step earlier, because "not a date value" is the wrong
sentence for an operator no comparand could make runnable. That door
does not front `having`, and #15661's ruling keeps the row beneath it
answered there (pinned in
`engine-text-operator-declared-type-door.test.ts`). Judging them here
would revive the retired wording, so `$contains: "2026"` and
`$startsWith: "not-a-date"` on `max(placed_on)` answer exactly as
before.
It judges the caller's own clause before the bigint narrowing, which is
what `where`'s object form judges (see Acceptance notes for the bigint
consequence).
## Measured: base `89f87f2344` and head, three drivers, both doors, both
paths
InMemoryDriver, SqlDriver on SQLite, SqlDriver on PostgreSQL 16 (private
cluster). Through `engine.aggregate` and `POST
/api/v1/data/:object/query`, native path and rows path, `groupBy`
customer over four groups. 1650 cells per run; 162 moved, all of them
`having` cells that went from 200 with one read to 400 `INVALID_FILTER`
with zero reads. Within each row below the three drivers, both doors and
both paths agree.
| `having` column · comparand | `$gt` base → head | `$lt` base → head |
`where` twin |
|:--|:--|:--|:--|
| `max(date)` · "not-a-date" | no group → 400 | c1–c4 → 400 | 400 |
| `max(date)` · "+010000-01-01T00:00:00.000Z" | c1–c4 → 400 | no group →
400 | 400 |
| `max(date)` · the number (REST and engine) or `Date` (engine) for
10000-01-01 | c1–c4 → 400 | no group → 400 | 400 |
| `max(date)` · "2026-02-01" (control) | c2 → c2 | c1, c3 → c1, c3 | 200
|
| `min(datetime)` · "not-a-date" | no group → 400 | c1–c4 → 400 | 400 |
| `min(datetime)` · the extended-year ISO, number or `Date` | unchanged
| unchanged | not refused by the door (the `datetime` rule reads it;
PostgreSQL answers 500, #20264's range) |
| `min(datetime)` · 2026 instant (control) | c2–c4 → same | c1 → same |
200 |
| `max(time)` · "not-a-date" | no group → 400 | c1–c4 → 400 | 400 |
| `max(time)` · extended-year ISO, number, `Date` | unchanged |
unchanged | not refused by the door |
| `max(time)` · "12:00" (control) | c3, c4 → same | c1 → same | 200 |
| `count` / `sum` / `avg` · a string | unchanged | unchanged | not
judged (not temporal) |
H3 cells: "not-a-date" on a `placed_on` or `opened_at` groupBy key,
"noon" on a `slot` key, "not-a-date" and the number for 10000-01-01 on a
`day` bucket: 200 → 400 on all three drivers and both doors. A `month`
bucket and a text key: unchanged.
**H2, paths.** The call sits before `getDriver`, the middleware chain
and both `applyHaving` doors. Driver reads of the object on a refusal: 0
on every refused cell (was 1), on both paths, on an empty and a
populated object.
**H4, collateral.** Byte-identical base → head on all three drivers,
both paths, both doors: every `where` and per-aggregation `filter` cell
(11 shapes, each at both doors), and 41 `having` shapes: a day, an ISO
instant, an epoch-ms number or string, in-range `Date`, zone-naive
instant, wall clock, extended-year instant on `datetime`, `{today}` /
`{30_days_ago}` / `{not_a_token}`, the empty and whitespace string,
`null`, `$exists`, `$in` / `$nin` / `$between`, `$not` / `$or` / `$and`,
`{ $field }`, `$contains` / `$startsWith`, a string on `sum`, and the
ten existing refusals (#20099 x3, #20123 x3, #20127, #19974, the type
door, `$icontains`), in their words.
**H5, the words.** Envelope `INVALID_FILTER` / 400, the `where` door's
wording class, naming the `having` path, the column, what it aggregates
and its kind:
```text
aggregate('ledger_20263'): `having` on 'col' (max(placed_on), a date column) compares against "not-a-date" at having.col.$lt, which is not a date value this platform can interpret. Compared with each group as written, it would keep no group or every group, a 200 indistinguishable from a real answer. The `having` was NOT applied. Write a "YYYY-MM-DD" calendar day.
```
The year class (a number or `Date` outside 0..9999 on a `date` column)
keeps #20240's sentence. Disclosure: like `where`, the column's declared
kind, and otherwise only what the query carries (alias, function, field,
comparand, path). The remedy names no `{placeholder}`, unlike `where`'s:
`having` resolves none, so naming one would send the author to a
literal.
## Tests and evidence (head `a2ff967202`)
- `pnpm --filter @objectstack/objectql exec vitest run --project local
--project repo --maxWorkers=2`: 321 files, 5828 tests passed, at
`438014d26c`, before merging `main` (the merge touched no objectql file;
the objectql source is byte-identical at head, and the one objectql test
file changed since was re-run at head).
- `pnpm --filter @objectstack/rest exec vitest run --project local
--project repo --maxWorkers=2`, at `49d218bf7a` (after merging `main`,
closure rebuilt): 204 files, 3681 passed, 1 skipped.
- `pnpm --filter @objectstack/driver-sql exec vitest run
--maxWorkers=2`, at `438014d26c`, with `OS_TEST_POSTGRES_URL` (server
`Asia/Shanghai`, process `TZ=America/New_York`): 199 files passed, 3
skipped; 3888 tests passed, 88 skipped. The suite's own summary: `live
postgres RAN`, `live mysql NOT RUN` (no MySQL here; CI's Temporal
Conformance job runs it).
- `pnpm --filter @objectstack/driver-memory exec vitest run
--maxWorkers=2`, at `49d218bf7a`: 57 files, 1374 tests passed.
- New: `engine-aggregate-having-temporal-door.test.ts` 51 passed;
`data-query-having-temporal-door.test.ts` 11 passed (re-run at head).
- `pnpm --filter @objectstack/objectql run typecheck` and `pnpm --filter
@objectstack/rest run typecheck`: exit 0, test layers included (objectql
debt held at 40 files / 234 errors, unchanged; rest 0).
- ESLint, narrowed and proven: `eslint --no-inline-config --format json`
over the 6 changed `.ts` files, 6 files counted in the JSON, 0 errors, 0
warnings. Population read from `eslint.config.mjs`
(`packages/**/*.{ts,tsx,mts,cts}`); invariance: that config enables no
type-aware linting (its own note), so this diff cannot move a verdict on
an untouched file.
**Ablation 1 (the door call), at `438014d26c`, through
`scripts/ablation-replace.mjs` with restore proven.** The call in
`engine.ts` was replaced by a marker; objectql rebuilt (exit 0);
`ablation-dist-preflight` found the marker in 4 built files. objectql
test: 20 failed / 31 passed (every refused, one-predicate and H3 case
red; every unchanged and earlier-door case green). REST test: 6 failed /
5 passed. Restore leg: blob equals HEAD, rebuilt, marker absent from all
14 built files, tree clean, 51/51 and 11/11 green.
**Ablation 2 (the text-operator skip).** `judgesOperator` forced to
judge every operator: exactly the two text-operator cells went red (2
failed / 49 passed); restored byte-identical.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at head: 64 commands. All run at
`a2ff967202`; `--ran` reconciliation: 64 derived, 62 run, 2 NOT
MEASURED, 0 unrun.
- NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`, reason: PREREQUISITE NOT MET (exit 3), both
read a whole-workspace `dist/` this worktree does not hold. CI builds
it.
- Red by design: `node scripts/check-empty-changeset.mjs --base
origin/main` exits 1 on the two DELIBERATE CORRECTIONS below. Its own
text: say so on the PR and get it confirmed; it stays red either way.
- `check:query-options-erasure` went red on the first run (test surface
236 → 238, two `as any` option bags in the new tests); the options are
typed now and it holds at 236.
- `node scripts/check-issue-citations.mjs --base 2dccb7d`: exit 0, 16
citations resolve.
- Also run, as the derivation flagged their rosters under `packages/`:
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, all exit 0.
## Deviations
- **DELIBERATE CORRECTION,
`.changeset/20240-date-year-four-digits.md`**, one clause. It said
"`having` does not reach the temporal-comparand door for any comparand,
so a number or `Date` outside 0..9999 there is still compared as
written." Now: "`having` reaches the same door in the same release
(#20263), so a number or `Date` outside 0..9999 is refused there too."
- **DELIBERATE CORRECTION,
`.changeset/19974-having-comparand-shape-face.md`**, one clause. It said
neither the type door nor the declared-type gates "are run on `having`;
this change adds the comparand-shape face only". Now it scopes that to
"by this change" and adds that the comparand-TYPE door (#20099) and the
temporal-comparand door (#20263) reach `having` in the same release. The
type-door half was already false since #20099; this corrects both halves
in one clause.
- A test-file header in
`packages/rest/src/data-query-date-year-range.test.ts` said `having`
does not reach the door; corrected to point at the new pin.
- **"Pin on the three drivers":** the refusal precedes any driver, so it
is pinned where CI can boot the engine: both `having` paths with
counting drivers (objectql) and the REST door over a real SqlDriver on
SQLite (rest). InMemoryDriver and PostgreSQL were measured (the table
above), not pinned: no package in the claim's surface has both the
engine and those drivers (rest has no `driver-memory` dependency, and no
CI job hands the REST suite a PostgreSQL URL). Adding either is outside
the claimed file surface.
## Acceptance notes (observations, not filed)
- A bigint comparand is judged by neither `having` nor `where`'s object
form (the door runs before the type door narrows it); `where`'s array
form narrows first and refuses it. In-process only, as JSON carries no
bigint. Reported to the seat below.
- On PostgreSQL, `where` with a non-numeric string on a `number` field
answers 500 `DATABASE_ERROR` where memory and SQLite answer 200. Outside
the temporal family; noted, not measured further.
## Out-of-scope findings for the seat (not filed by this PR)
1. `having` resolves no `{placeholder}`: `{ lp: { $gt:
"{current_year_start}" } }` on `max(placed_on)` keeps no group (both
groups are after 2026-01-01), and `{not_a_token}` answers 200 where
`where` answers `FILTER_TOKEN_UNKNOWN` / 400. `resolveWhereTokens` reads
`ast.where` only.
2. The per-aggregation `filter`'s temporal refusal names the wrong
position: a bad date in `aggregations[1].filter` is reported "at
where.placed_on.$gt".
3. On PostgreSQL, SqlDriver's native aggregate returns `count` / `sum` /
`avg` as strings (`"n":"1"`,
`"total":"20.000000000000000000000000000000"`, also over REST), so
`having { n: { $in: [2] } }` keeps no group there while memory and
SQLite keep c1, c2, and a string comparand on those columns answers
differently on the native and the rows path.
4. The bigint hole in the first Acceptance note, if the seat grades it a
defect.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21ab410 commit a78f731
9 files changed
Lines changed: 776 additions & 15 deletions
File tree
- .changeset
- packages
- objectql/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | | - | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
0 commit comments