Skip to content

fix(driver-sql): boolean aggregands answer the ruled #11249 contract — PG lowering cast + all-dialect min/max JSON-boolean presentation - #11785

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-11635-boolean-aggregand-pg-cast
Aug 25, 2026
Merged

os-warren merged 3 commits into
mainfrom
claude/issue-11635-boolean-aggregand-pg-cast

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #11635

The ruling this implements

#11249 (maintainer 2026-08-23, comment 5386670755, verbatim and untranslated: 「10950 不考虑存量,其他接受你的建议」): min/max over a boolean aggregand answer false/true in JSON — order statistics return a member of the input domain, SQL drivers convert at the driver boundary; sum/avg answer arithmetic (3 / 0.5 on the 3-true/3-false fixture, the settled #11065 family shape). Cast, not refuse: a face that refuses cannot satisfy the ruled contract. This is declared = enforced restoration of an already-ruled contract, not a new capability.

Premise re-measured on current main (2a6122bd9d), before building

Both card measurements reproduced through SqlDriver.aggregate() on the live servers this container runs at CI parity (PG 16.13 timezone=Asia/Shanghai, MySQL 8.0.46 time_zone=+08:00, process TZ=America/New_York):

The change (2 seams in sql-driver.ts + tests)

  1. PG lowering cast — in aggregate()'s statement builder: sum/avg/min/max over a field in booleanFields emit func(cast("col" as int)) on Postgres only. The column stays a knex identifier binding. count/count_distinct are deliberately not cast (both lower to count, defined over boolean everywhere; the acceptance keeps them unchanged). No new dialect predicate — the existing isPostgres getter selects the branch (read-coupling with driver-sql: the isSqlite/isPostgres/isMysql getters recognise fewer knex client spellings than knex accepts, so a valid client: 'postgres' silently loses every dialect-specific behaviour #11550 noted below; its getters were not edited).
  2. All-dialect min/max boolean presentation — the min/max result-column tracking now presents a declared-boolean aggregand as 'boolean' on every dialect, not only under readPresentationKind's SQLite gate: MySQL's tinyint(1) and PG's casted int both answer 1/0 from the backend and leave the driver as JSON false/true. null (empty window) passes through untouched — min/max over no rows still answer null, matching emptyGroupValueFor.
  3. Tests — new sql-driver-11635-boolean-aggregand-answers.test.ts pins the ruled answers on all three dialect cells (AGGREGATION_ROWS + a flag boolean column, 3 true / 3 false, declared type: 'boolean'; east group all-true so grouped min=true catches whole-table/sticky presentation). The driver-sql (PG): sum/avg/min/max over a boolean column throw the raw PostgreSQL 42883 with no ADR-0112 envelope (status undefined) #11455 suite's PG-only 42883 block is retired per its own retirement clause ("if [finding] AGGREGATION_ROWS has no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152/[Decision] min/max over a boolean aggregand: pin the cross-driver JSON answer — 0/1 (SQL) vs false/true (both in-memory faces) #11249 rule that the lowering should CAST, these cases stop failing and this block is RETIRED by that card") — the four 42883 pins, the fence case, and its log case are replaced by a tombstone naming the ruling; its count/count_distinct controls live on in the new suite; the all-dialect terminal-envelope sweep (missing table) is untouched. No pin was deleted to make room — the retirement is the honest update the ruling forces, and this paragraph is its declaration.

Acceptance numbers, measured through SqlDriver.aggregate()

live PG 16.13 live MySQL 8.0.46 SQLite
sum(flag) "3" (Number 3) "3" (Number 3) 3
avg(flag) "0.50000000000000000000" (Number 0.5) "0.5000" (Number 0.5) 0.5
min(flag) false (JSON boolean) false false
max(flag) true (JSON boolean) true true
count(flag) "6" — unchanged 6 — unchanged 6
count_distinct(flag) "2" — unchanged 2 — unchanged 2

Contract surface (clause ②) — the answer set and its JSON types, named

Verification

  • New suite: 28/28 green across sqlite + live postgres + live mysql with OS_EXPECT_LIVE_DIALECT_MATRIX=1 (no cell skipped).
  • Full @objectstack/driver-sql suite with both live servers: 133 files, 2696 passed | 1 skipped — verify-lock VERDICT command-exit 0. pnpm --filter @objectstack/driver-sql typecheck VERDICT command-exit 0.
  • Reverse verification (fix committed first; mutation = git restore --source=2a6122bd9d of sql-driver.ts, confirmed on disk by marker greps — castBooleanAggregand count 0 and the pre-fix bare-lowering line back — with a trap ... EXIT INT TERM restore): predicted 9 failed / 19 passed with PG reds by THROW and MySQL reds on VALUES; measured exactly that — PG: 6 red, all DATABASE_ERROR 42883 (sum/avg/min/max/grouped/empty-window); MySQL: 3 red on values (expected +0 to be false, expected 1 to be true, grouped {lo:1,hi:1}); SQLite all green. Restore confirmed (marker count back to 2, git status clean). The suite imports the driver by relative path, so vitest reads src — no build stands between the mutation and the measurement.
  • Gates at final head 05c95a7b85 (union re-run after the last commit): all derived + convention families green (exit 0 each): check:changeset-gate-self-tests, check:objectui-changeset, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:cross-package-test-inputs, check:where-matcher, check:nul-bytes, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, check-affected-docs, release-rehearsal-clone --self-test, check:driver-conformance. Declared narrowing: check:type-check-debt's --re-measure half was not run locally (needs the full workspace build) — its population is the DEBT/TEST_DEBT ledgers in check-type-check-coverage.mjs, @objectstack/driver-sql is in none of them, driver-sql's own tsc program (src/**/*, tests included) covers every file this PR touches and exits 0, and the diff moves no export surface, so no ledgered package's program reads anything this PR changed; CI runs the full re-measure regardless.
  • Driver-conformance lane numbers: before 45 covered / 0 DEBT / 0 exempt → after 45 covered / 0 DEBT / 0 exempt (unchanged — this card adds per-driver pins, not a new conformance case-set enrollment).

Sequencing

Out of scope, filed


Generated by Claude Code

claude added 2 commits August 24, 2026 15:54
…max as JSON booleans on every dialect

Implements the #11249 ruling at the driver boundary: sum/avg over a
declared boolean answer arithmetic (3 / 0.5 on the 3-true/3-false
fixture) and min/max answer false/true in JSON — order statistics
return a member of the input domain.

- Postgres: the lowering now emits cast("flag" as int) for
  sum/avg/min/max over a declared boolean column (PG defines no
  arithmetic/order aggregates over boolean; SQLSTATE 42883, measured
  on PG 16.13). count/count_distinct are deliberately not cast.
- Every dialect: min/max results over a declared boolean are tracked
  for boolean read-presentation, closing the MySQL gap where
  tinyint(1) answered 0/1 through the driver boundary (measured on
  MySQL 8.0.46). NULL (empty window) passes through untouched.
- The #11455 suite's PG-only 42883 block is retired per its own
  retirement clause; the ruled answers are pinned on all three
  dialects by sql-driver-11635-boolean-aggregand-answers.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0e5bea6128cf5c663c9e48f30aa99a61fa23b374 → packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants