Repository navigation
fix(service-analytics): qualify a native-SQL base column whenever the statement joins a relationship path, read from the hop resolver's joins, not cube.joins - #21266
Conversation
…ement joins anything, read from the hop resolver's joins The native strategy qualified a bare base-table column only when the cube declared a join. A cube that declares none still joins a lookup's declared reference through the one hop resolver, so a base column sharing its name with a column of the joined target was emitted bare: "ambiguous column" on SQLite and PostgreSQL (500). The statement is now compiled once with bare base columns and, when that compile registered any join, once more with every base column qualified. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…he same qualification; fixtures that pinned a bare base column beside a join Three fixtures asserted a bare base column in a statement that joins: the shape the qualification rule now corrects. Their expectations name the qualified column, and the comments that described the old rule describe the new one. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ionship path the cube declares no join for, on SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…tive-sql-base-column-qualify
…on beside a joined relationship path Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ee80aa23e6af37f03b2d2d18e0d3b2cd74bca5a0 && git checkout ee80aa23e6af37f03b2d2d18e0d3b2cd74bca5a0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 30c530e5c0e13ca283cde133f4e63a9503edf936 560ab3613e432858dc72a617a7e2c0d2be8b4d83 && git checkout -B drift-repro 30c530e5c0e13ca283cde133f4e63a9503edf936 && git merge --no-ff 560ab3613e432858dc72a617a7e2c0d2be8b4d83
node scripts/docs-audit/affected-docs.mjs --json 30c530e5c0e13ca283cde133f4e63a9503edf936
|
Fixes #21249
Clause-②: no
What changed
On the native-SQL strategy, a bare base-table column is now qualified with the base table (
"deal"."note") exactly when the statement joins something. What the statement joins is read from the joins the one hop resolver (hop-object.ts, consumed unchanged) registered for the query. It is no longer read from whether the cube declaresjoins. ⛔ No new resolver.qualifyAndRegisterJoinreadsjoins.qualifyBaseColumns, a flag the statement'sStatementJoinsnow carries, instead ofcanJoin = !!cube?.joins && Object.keys(cube.joins).length > 0.generateSqlcompiles the query once with bare base columns. When that compile registered any join, it compiles the query once more with every base column qualified. The flag cannot be read at call time, because joins are registered lazily: whichever member walks a relationship path first registers the join, and an absorbed$ortakes back the joins its branches registered. In the card's own pair,noteis resolved beforeowner.emailregisters the join. Ablation A2 below measures that a call-time read (joins.size > 0) leaves the card's pair broken. The method is split intocompileClauses(the SELECT / GROUP BY / WHERE half, unchanged in place) andassembleStatement(the allowlist, read-scope and assembly half, unchanged in place). Both compiles walk the same members through the same resolver, so the second registers the same joins. A statement that joins nothing is compiled once.resolveFieldSql's fallback for a filter member the cube does not declare (where: { id }) used to return the bare name. It now goes throughqualifyAndRegisterJoinlike a declared member. Measured below: the fallback was the same 500 beside a join.where, the dataset scope, measure filters), measures and time-dimension windows. ORDER BY emits the member's output alias (ORDER BY "note"), never a table column, so it is not a qualification site. See the Acceptance notes for ordering by a member the query does not select.Measured:
POST /api/v1/analytics/queryand/sqlon the real dispatcher routeSetup:
AnalyticsServicePluginover a realObjectQLengine andSqlDriver, a signed-in caller, the realdispatcher-pluginmount, SQLite in memory and a private PostgreSQL 16.14. A configured cube overdealdeclares no join.owneris a lookup whosereferenceisperson, andpersonalso declaresnote,amount,closed_onandid. A second cube declares the join (the control). Before:origin/mainat4727fcb2. After: this branch's build, re-taken at the merge withorigin/main(1b176fbf). The scratch probe was deleted.note+owner.email(the card's pair)note) → 200owner_email)where: { note: 'x' }andorder: { note: 'asc' }sum(amount)byowner.emailamount) → 200where: { note }byowner.emailclosed_ontime-dimension window byowner.emailclosed_on) → 200where: { id: 'd1' }(undeclared member) byowner.emailid) → 200deal(inferred cube), the pairnotealone, either cube, or an absorbed$oroverowner.emailThe compiled statement for the card's pair, before:
SELECT note AS "note", "owner"."email" AS "owner.email", COUNT(*) AS "count" FROM "deal" LEFT JOIN "person" "owner" ON "deal"."owner" = "owner"."id" GROUP BY note, "owner"."email". After, it is the statement the declared-join cube always compiled:SELECT "deal"."note" AS "note", … GROUP BY "deal"."note", "owner"."email".The no-path control, stated (dispatch Zone 2, item 3). A statement that joins nothing stays unqualified, as before, on a cube that declares no join. On a cube that declares a join, a query that uses none of it now also compiles bare columns. The
/sqldry run showsSELECT note AS "note", COUNT(*) AS "count" FROM "deal" GROUP BY note, where before it showed"deal"."note". The answer is the same on both drivers: the statement reads one table, so both spellings name one column. This follows from triage's predicate (what the query joins, notcube.joins). The other way to keep that statement byte-identical is to keep readingcube.joinsbeside the predicate, which is what triage rules out. A third reading, qualifying every base column always, was measured and rejected: it reddens 64 tests in 21 files of this package, among themnative-sql-rls.test.ts's deliberate pin that a single-object cube keeps bare columns.Pins
New file:
packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts. It uses the plugin's own composition over a real engine, a SQLite cell and a PostgreSQL cell (a named skip withoutOS_TEST_POSTGRES_URL), and both faces. It has 12 tests, 6 per cell. Each serve pin checks the rows on both faces, that the native face answered with one raw statement and no engine aggregate, and the compiled statement.note: (x, a, 2) · (x, b, 1) · (y, b, 1). The person rows carry differentnote/amount/closed_onvalues, so a statement that read the person's column would answer differently.whereonnoteand anorderby it.sum(amount)), a time-dimension window (closed_on), and a member the cube does not declare (where: { id }).dimensions: ['note']withwhere: { 'owner.email': … }. The SELECT list is compiled before the filter joinsowner. Native face only, because the ObjectQL face refuses a cross-object filter of its own accord. The reversed pair is pinned too.4727fcb2, and the no-join cube now compiles the same statement.$ortakes its join back.Ablations
The tests import the subject by relative path (
../plugin.js), so each run readssrcand there is nodistleg. Every mutation went throughscripts/ablation-replace.mjsin WRAP mode, with an outertraprestore onEXIT INT TERMagainst the absolute path. They ran from committed2a575561, with the PostgreSQL cell live. Predictions were written before each run.canJoinput back oncube.joins(joins.qualifyBaseColumns→ the removed expression)joins.qualifyBaseColumns→joins.size > 0)resolveFieldSql's fallback back toreturn fieldName;Each mutation landed: anchor 1 → 0, and the blob changed from
d2c652842c9fto A106e880595f77, A279a30a8137c0and A309066c0079e7. Each was restored and proven: the blob equals the HEAD blobd2c652842c9f, andgit diff HEADis empty.Fixture triage
The full
service-analyticssuite turned up exactly three expectations that pinned a bare base column inside a statement that joins. That is the shape this PR corrects. Each was replaced, and its comment, which described the old rule, now describes the new one. The tests' subjects (member resolution, spelling parity, traversal unification) are unchanged.analytics-service.test.ts, "should resolve client-style lookup.field member references":SUM(amount)→SUM("opportunity"."amount").infer-cube-relation-traversal.test.ts, "unifies a dotted key riding alongside a bare one":WHERE (industry = $1 AND …)→WHERE ("crm_account"."industry" = $1 AND …).infer-cube-where-spelling-parity.test.ts, "bare and dotted keys reach parity together":WHERE (stage = $1 AND …)→WHERE ("deal"."stage" = $1 AND …). The file header and block 2's comment named the old rule, and now name the new one. Block 2's assertion (a statement that joins nothing stays bare) is unchanged and green.Consumer radius, run against this branch's build at
560ab361withOS_TEST_POSTGRES_URLset: every test file that drives the analytics service inpackages/rest(23 files),packages/runtime(19),packages/drivers/driver-memory(27) andpackages/drivers/driver-sql(7). The results were 359 / 359, 551 / 551, 863 / 863 and 133 passed / 1 skipped, with no failures. The 9packages/qa/dogfoodfiles that drive analytics are NOT MEASURED: their build closure (the example apps, the CLI) was not built here. They pin no statement text (git grepforGROUP BY/LEFT JOIN/SELECTover them has zero hits). They are left to the requiredDogfood Regression Gate.Docs
git grep -niEforambiguous,qualif…,declares (no) join,bare column,single-object cube,cube can/has joinanddeclared joinovercontent/docs/**, excludingreleases/andreferences/, gave no sentence about analytics column qualification. The native-SQL join sentences areprotocol/objectql/query-syntax.mdx:517(the positive control: "on the native-SQL path those joins serve dimensions, measures and filter members", about a dataset'sinclude) anddata-modeling/analytics.mdx:189-193(dataset joins come frominclude). Both stay true. No page edited.Verification at
560ab361560ab361is this branch's head. It carriesorigin/mainat434c6c7cmerged in (no overlap with this diff's files), with install and the build refreshed after the merge.pnpm --filter @objectstack/service-analytics exec vitest run, with the PostgreSQL cells live: 165 files, 3827 passed, 0 skipped, 0 failed.typecheck(tsc --noEmit): exit 0, and its program lists all 5 touched.tsfiles (--listFiles).4727fcb2, cells skipped: 164 files, 3758 passed / 56 skipped.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 61 commands, and all 61 exit 0 at560ab361.--ranreconciliation: "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3), because some packages had nodist. Afterpnpm build(72 tasks, 71 cached) it exits 0, and that is the run recorded.eslint --no-inline-config --format jsonover the 5 touched.tsfiles at560ab361reports 5 files, 0 errors and 0 warnings. ① All 5 are inside the populationeslint.config.mjslints (**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}, minus the globalnode_modules/dist/build/.next/.turboignores), and the changeset.mdis in nofilesglob. ② The count of 5 is read from the JSON output. ③ The config enables no type-aware linting (--print-configgivesparserOptions{"ecmaVersion":"latest","sourceType":"module"}, with noproject), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis left to CI.Acceptance notes
Ordering by a member the query does not select is a separate defect, reported to the seat and not handled here. The ORDER BY emitter writes the request key as an identifier (
ORDER BY "note"), which names an output column only when that member is selected. Measured after this PR, all throughPOST /api/v1/analytics/query, native face, with the ObjectQL face answering 200 in every row:{ dimensions: ['owner.email'], order: { note: 'asc' } }answers 500 on both drivers. On PostgreSQL that is 42702,noteambiguous at the ORDER BY.{ dimensions: ['note'], order: { amount: 'asc' } }with no join at all answers 500 on PostgreSQL (42803, must appear in GROUP BY). On SQLite it answers 200, ordered by an arbitrary row's value.order: { 'owner.email': 'asc' }, unselected, answers 500 on both drivers (42703, column "owner.email" does not exist).Qualifying the column would not mend it (PostgreSQL would still answer 42803). The fix is a decision about what an unselected order key means.
The other
cube.joinsreaders in this file (canHandle's federated decline, and the threereadScopedObjectsfallbacks) were not seen failing at a door in these measurements. They stay with PR fix(service-analytics)!: refuse a JSON-stored dimension or count_distinct over a relationship path the cube declares no join for, located through the one hop resolver #21247's acceptance notes.The
/sqldry run's text changes for every statement that joins through an undeclared lookup (now qualified), and for a declared-join cube's statement that joins nothing (now bare). The consumer radius above found no test outside this package that pins that text.Deviation from the declared file surface
The claim names
native-sql-strategy.tsand its tests, plus a changeset. The diff stays inside that. The three fixture files above are this strategy's tests inservice-analytics. TheresolveFieldSqlfallback is in the same file and is the same 500 beside a join, measured at the door.Generated by Claude Code