Skip to content

A bound written through a CTE or derived table is typed too - #915

Merged
philcunliffe merged 5 commits into
masterfrom
fix/issue-906
Aug 19, 2026
Merged

A bound written through a CTE or derived table is typed too#915
philcunliffe merged 5 commits into
masterfrom
fix/issue-906

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes the deferred review residuals from PR #868, recorded in LLP 0272 under #not-settled-derived.

Stacked on #868. The code these findings live in (src/core/query/timestamp-literals.js) exists only on fix/issue-860, which is still open and unmerged, so this branch is based on #868's head 79f1c98c and its diff appears here too. Only the top commit 7551c404 is new. Merge #868 first; this becomes a two-file-plus-doc diff after that.

Finding 1: a string bound through a CTE or derived table is not coerced

Gate met: behavioural (failing-then-passing regression test).

What was wrong. LLP 0272 fixed a bound written directly on a dataset column. A bound written one relation further in still returned zero rows on matching data, exit 0 - issue #860's exact signature. Reproduced against the four-row parquet fixture already in test/core/query-timestamp-literals.test.js; all ten cases returned []:

WITH c AS (SELECT * FROM ai_gateway_messages) SELECT id FROM c WHERE message_created_at >= '2026-08-18T21:00:00Z' -> got [], SQL says [3,4]
WITH c AS (SELECT id, message_created_at FROM ai_gateway_messages) SELECT id FROM c WHERE message_created_at < '...' -> got [], SQL says [1,2]
WITH c AS (SELECT id, message_created_at AS ts FROM ai_gateway_messages) SELECT id FROM c WHERE ts >= '...' -> got [], SQL says [3,4]
SELECT id FROM (SELECT * FROM ai_gateway_messages) t WHERE message_created_at >= '...' -> got [], SQL says [3,4]
SELECT t.id FROM (SELECT * FROM ai_gateway_messages) t WHERE t.message_created_at >= '...' -> got [], SQL says [3,4]
WITH c AS (...), d AS (SELECT * FROM c) SELECT id FROM d WHERE message_created_at >= '...' -> got [], SQL says [3,4]
SELECT id FROM (SELECT * FROM m UNION ALL SELECT * FROM m) t WHERE message_created_at >= '...' -> got [], SQL says [3,3,4,4]
... plus HAVING max(...), IN (...), and BETWEEN through a CTE

Each returns rows the moment the literal is written TIMESTAMP '...', which is the tell: the plan is fine, only the literal's type is wrong.

Root cause.timestampScope built its agreed/byRelation sets only from relations registry.getDataset(table) could answer for. A CTE or derived table landed in bound alone (src/core/query/timestamp-literals.js:221 on the old head), so no name in that scope resolved to a TIMESTAMP and no literal was rewritten. Downstream, squirreling compares a Date to a string (false for every row) and icebird's converter prunes every row group, so both halves agree on the same wrong answer.

Fix. Every relation now resolves to the same currency, an ordered { name, isTimestamp }[]:

  • a dataset answers from its declared schema, as before;
  • a CTE answers from the list its body was proved to expose, computed where the CTE is bound and carried in the map that already records the shadowing (so a CTE still shadows a dataset of its name, case-insensitively);
  • a derived table is walked in place;
  • a table function answers with nothing.

Everything LLP 0272 built on a dataset schema (agreed, byRelation, the outward correlated walk, TYPE_PRESERVING_ARGS) works over those unchanged. The carried type is the inner expression's, never the outer name's: WITH c AS (SELECT id, date AS message_created_at FROM m) exposes a STRING and keeps its string comparison, because typing that from the dataset that shares the name is the same silently-empty answer pointed the other way.

The list is complete or it is nothing. If any inner relation cannot be read, or any output column has no derivable name, the walk returns nothing and the relation is treated exactly as LLP 0272 treated every CTE. A partial list would be worse than none: agreed is an intersection, so a name a partial list omitted could be typed from an unrelated relation that declares it - a wrong coercion, wrong rows.

Regression test.test/core/query-timestamp-literals.test.js, three new tests running end to end through executeQuerySql over a real parquet partition (the same pushdown-then-scan path hyp query sql runs):

  • a string bound on a CTE or derived table column selects the rows it names - before: fails, all 10 cases return []. After: passes.
  • a CTE that renames another type onto a TIMESTAMP column name keeps its string comparison - passes before and after; it is the guard against over-coercing.
  • an inner select that cannot be read completely types nothing - the declared boundary (unregistered inner table, unnameable output column, table function); passes before and after.

Finding 2: an unqualified correlated reference is not typed

Gate met: behavioural, and the reproduction disproves the finding. No fix, by evidence.

The residual assumed the same silent-empty-result failure. It is not one. squirreling does not resolve an unqualified name into an enclosing select at all:

SELECT m.id FROM ai_gateway_messages m
WHERE EXISTS (SELECT 1 FROM edge e WHERE e.id = m.id AND message_created_at >= <literal>)
literal = TIMESTAMP '2026-08-18T21:00:00Z' -> Error: Column "message_created_at" not found. Available columns: id, weight
literal = '2026-08-18T21:00:00Z' -> Error: Column "message_created_at" not found. Available columns: id, weight

It raises before any comparison is evaluated, and raises identically whether the literal is typed or bare. There is no zero-row answer to fix and typing the literal could not change the outcome; it would only trade one error for another. Closing this needs an engine that can evaluate the resolved reference, which is upstream of this repo.

Pinned rather than fixed, so a future squirreling that does resolve it fails this suite instead of passing silently: an unqualified correlated reference is rejected by the engine, typed or bare. The machinery a real fix would need (the inner relations' full column lists) is exactly what finding 1 now computes.

Finding 3: calls that do not carry an argument's type

Deferred, unchanged - it is not a defect. The issue lists it "only so the boundary is visible". epoch, extract, date_diff, cast and the string functions are absent from TYPE_PRESERVING_ARGS on purpose: typing a literal from a value-transforming call returns wrong rows rather than none, which is worse than what it prevents. LLP 0272 settled this and the existing test a call that does not carry the column type leaves the literal alone already pins it. No change.

Design record

New LLP 0280 (llp/0280-inner-select-columns-type-a-bound.decision.md) records the decision, its #complete boundary, and why the unqualified correlated reference is not this defect. LLP 0272 is Accepted and settled, so it is not edited: it gains only an Extended-by: LLP 0280 forward-ref on its #not-settled-derived section, which the LLP rules allow as editorial. @ref annotations point at LLP 0280#carry, #complete, #unqualified-correlated, and test/core/llp-ref-hygiene.test.js resolves all of them.

Checks

  • npm test - 4507 pass, 0 fail, 1 skipped (4506 pass before this change, plus the new cases). Every LLP 0272 test still passes untouched, including the three that guard against over-coercion through a CTE or derived table.
  • npm run typecheck - clean.
  • npm run smoke -- cache_roundtrip - ok. local_only_query_withhold fails identically on 79f1c98c with this change stashed, so it is pre-existing and unrelated.

Residual risk

  • The walk is a predicate, not column inference: it answers "is this name a TIMESTAMP" and gives up otherwise. It does not model USING/natural-join column merging, table-function outputs, or unaliased expression columns. Each gives up (no coercion), which is the pre-existing behaviour, never a wrong type. Named in LLP 0280 #not-settled-inference.
  • A relation's columns are recomputed per reference rather than memoized. Cost is O(depth x subtree) on a parsed statement, not exponential.
  • Deeply nested derived tables and set operations are covered by tests only to the depths listed above.

Fixes#906

philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
…IMESTAMP (#915)
A relation can expose one column name more than once: `select *` over a join
expands both sides, and two of them may declare the same name with different
types. The new column list keeps both occurrences, which is right, but two of
its readers took the wrong one out.
`timestampScope` built a relation's `declared` set as the *union* of its
TIMESTAMP names, so a qualified reference to an ambiguous name resolved to
TIMESTAMP whichever side declared it. `declaresTimestamp` took the *first*
occurrence, so an inner select's own expression resolved by join order.
Both type a bound against a column the engine does not hand it. squirreling
flattens a CTE's duplicate last-wins, so
with c as (select * from ai_gateway_messages m join node n on m.id = n.id)
select c.id from c where c.message_created_at >= '2026-08-18'
reads `node`'s STRING column and returned the two matching rows before this
branch; after it, the literal was cast to a TIMESTAMP and the bound went
silently empty on matching data - issue #860's own signature, one join in. Said
loudly, `>= 'n4'` on that same string column stopped being a comparison at all
and became a TimestampLiteralError.
Both readers now collapse a relation's list by name first, keeping a name only
when every occurrence of it is a TIMESTAMP, which is the `#complete` doctrine
already stated for the list as a whole: never mis-type, and pay only the
pruning when unsure. LLP 0280 records the ambiguous-name case under the same
anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round e8e9d5a1 - 1 finding (high), fixed in e0a110e4

Verdict: changes requested, then fixed on the branch. The design is sound and the new
capability is real: a bound written through a CTE or derived table now selects the rows it
names, and the over-coercion guards (#complete, the renamed-column case, the set-operation
pairing) all hold up under probing. One shape the new column list introduced was reading the
wrong occurrence of a duplicated name, and it reintroduced issue #860's own signature. That is
fixed; everything else I probed came back correct.

Finding 1 (high, regression vs master): a name a relation exposes twice is typed from the wrong occurrence

What is wrong. A relation's column list is ordered and keeps duplicates, which is right:
SELECT * over a join expands every relation, and two of them may declare the same name with
different types. But two readers took the wrong occurrence out of it.

  • src/core/query/timestamp-literals.js:425-432 (pre-fix) built each relation's declared set
    as the union of its TIMESTAMP names:
    for(constcolumnofcolumns){if(column.isTimestamp)declared.add(column.name)...}
    so a qualified reference (qualifiedIsTimestamp -> byRelation) resolved an ambiguous name
    to TIMESTAMP whichever side declared it. Note the very next two lines feed seenTypes, which
    does AND-collapse, so the unqualified path was already correct: the two paths disagreed
    with each other about the same question.
  • src/core/query/timestamp-literals.js:365-368 (pre-fix) declaresTimestamp took the first
    match (columns.find(...)), so an inner select's own expression (exprIsTimestamp,
    :328-355) resolved by join order.

Why it matters. squirreling flattens a CTE's duplicate column last-wins (it rejects the
derived-table spelling of the same reference outright), so the engine hands the outer select the
last occurrence while the rewrite typed it from the first / from the union. Reproduced end to
end against the two datasets already in test/core/query-timestamp-literals.test.js
(ai_gateway_messages.message_created_at TIMESTAMP, node.message_created_at STRING, values
'n3'/'n4'), running through executeQuerySql:

J = ai_gateway_messages m JOIN node n ON m.id = n.id
master (60e33eaa) this PR head
WITH c AS (SELECT * FROM J) SELECT c.id FROM c
WHERE c.message_created_at >= '2026-08-18' [3, 4] []
WITH c AS (SELECT * FROM J) SELECT c.id FROM c
WHERE c.message_created_at >= 'n4' [4] TimestampLiteralError
WITH c AS (SELECT * FROM J), d AS (SELECT id,
message_created_at AS ts FROM c)
SELECT id FROM d WHERE ts >= '2026-08-18' [3, 4] []
... same, >= 'n4' [4] TimestampLiteralError

SELECT c.message_created_at FROM c returns 'n3', 'n4' on both, so the column really is the
STRING one and master's answers are the correct ones. Two failure modes, both the ones this
line of work exists to end:

  1. Zero rows on matching data, exit 0 - issue TIMESTAMP column compared to a string literal returns zero rows on matching data, silently invalidating every since-X query and six ACCEPTANCE.md steps #860's exact signature, one join in, and a
    regression against master rather than a pre-existing gap.
  2. A refusal of a comparison that is an ordinary string one. Because LLP 0272
    #refuse-uncoercible (correctly) makes an unreadable literal an error, mis-typing a STRING
    column turns >= 'n4' from a working predicate into a hard TimestampLiteralError. That one
    is at least loud, but it is a working query that stops working.

Fix (e0a110e4). Both readers now collapse a relation's list by name before reading it: a
new columnTypes(columns) returns Map<string, boolean> AND-ing every occurrence, so a name is
a TIMESTAMP only when every occurrence of it is.

  • src/core/query/timestamp-literals.js:360-382 new columnTypes, annotated
    @ref LLP 0280#complete [implements].
  • src/core/query/timestamp-literals.js:390declaresTimestamp reads the collapsed map instead
    of .find.
  • src/core/query/timestamp-literals.js:449-458timestampScope builds declared and feeds
    seenTypes from the collapsed map, so the qualified and unqualified paths now answer the same
    question the same way.

This is the #complete doctrine the PR already argues for, applied one level down: a complete
list is not a list of distinct names, and when the walk cannot say which occurrence a reference
reaches, it declines to type it and pays only the pruning. All four rows above go back to
master's answers.

Regression test.test/core/query-timestamp-literals.test.js:612 and :646, both end to end
through executeQuerySql over the real parquet fixtures:

  • a name an inner select exposes twice with two types keeps its string comparison (qualified,
    unqualified, and the two-hop CTE form)
  • an ambiguous name does not refuse a string bound that is not a timestamp

Failing-then-passing gate met: with the two src/ hunks reverted and the tests in place,
node --test test/core/query-timestamp-literals.test.js reports not ok 25 and not ok 26,
# pass 24 / # fail 2. With the fix: # pass 26 / # fail 0.

Design record. LLP 0280 #complete gains a paragraph naming the ambiguous-name case and why
it collapses rather than guesses. The doc is introduced by this PR and unmerged, so this is the
doc landing with its code, not an edit to a settled record. LLP 0272 is untouched beyond the
Extended-by forward-ref the PR already added.

Checked and clean

Probed against a live registry and, where the engine accepts the shape, end to end:

  • Termination / self-reference. A CTE is inserted into the map after its body is inferred,
    so a self-referencing name falls through to registry.getDataset and answers nothing rather
    than looping. A forward reference (WITH d AS (SELECT * FROM c), c AS (...)) correctly types
    nothing. (WITH RECURSIVE does not parse in squirreling 0.15.3 anyway, as LLP 0280 says.)
  • Shadowing.WITH msgs AS (SELECT id, date AS ts FROM msgs) keeps its string comparison;
    a nested inner WITH shadowing an outer CTE of the same name resolves to the inner one; a
    derived-table alias colliding with a CTE name resolves to the derived table.
  • Over-coercion guards.UNION ALL of a TIMESTAMP with a STRING types nothing;
    CAST(ts AS STRING) types nothing while CAST(date AS TIMESTAMP) types; t.* expands only
    the relation it names; a table function types nothing; an unaliased expression column types
    nothing.
  • Refusal still reaches through a CTE.WITH c AS (SELECT * FROM msgs) ... WHERE ts >= 'yesterday' still raises TimestampLiteralError rather than going quietly empty.
  • join.subquery.alias ?? join.alias (:210) drops the old double-bind, but squirreling's
    join parser sets table, alias and subquery.alias to the same string for a subquery join,
    so the binding set is unchanged.
  • Cost. Not exponential. A doubling-fanout derived-table tree measures 42 ms / 128 ms / 304 ms
    at 72 KB / 287 KB / 573 KB of SQL, i.e. roughly linear in statement size; CTE column lists are
    memoized in the map so a chain that references each predecessor twice stays flat.

Noted, not actionable

  • The SELECT * model and squirreling's own expansion differ for a join. The walk models
    * over a join as the concatenated bare names; squirreling exposes a derived table's join
    columns only in prefixed form (m.id, s.shared) and rejects both q.ts and bare ts
    against it, while flattening a CTE's to bare names last-wins. So for the derived-table
    spelling the walk can type a name the engine will refuse either way - an error, never wrong
    rows, and identical typed or bare. Worth a line under LLP 0280 #not-settled-inference if
    anyone widens this later; it belongs with the USING/natural-join merging already named there.
  • A CTE join whose last-wins occurrence is the TIMESTAMP still gets no coercion after the fix
    (the ambiguity check is symmetric). That is the pre-existing conservative answer, not a
    regression: a missed coercion costs pruning, not rows.
  • Finding 2 and 3 of issue Follow-up: deferred review findings from PR #868 #906 are handled correctly. The disproof of [codex] Remove OpenTelemetry npm dependencies #2 reproduces exactly
    as described (Column "message_created_at" not found, identical typed or bare), and pinning
    the rejection in both spellings is the right call. [codex] Add root tests and remove donor tree #3 is settled by LLP 0272 and needs nothing.

Checks

  • npm test in the worktree: 4526 pass, 0 fail, 1 skipped (4524 before, plus the two new
    cases). Every pre-existing timestamp-literal test still passes untouched.
  • npm run typecheck: clean.
  • Style: no semicolons, no em dashes, JSDoc types only, @ref anchors resolve
    (test/core/llp-ref-hygiene.test.js green as part of the suite).

Head is now e0a110e4. Not merged, not marked ready - a human decides that.

philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
`relationKeys` recorded an aliased relation under both its table name and
its alias, so `timestampScope` wrote the same relation into `byRelation`
twice. A second relation whose table name equals the first one's alias then
overwrote that entry, and a qualified bound was typed from the wrong
relation's columns:
select m.id from node ai_gateway_messages
join ai_gateway_messages m on m.id = ai_gateway_messages.id
where ai_gateway_messages.message_created_at >= '2026-08-18'
The qualifier names `node`, whose `message_created_at` is a STRING, but the
literal was cast to a TIMESTAMP from the joined dataset. The query returned
no rows where the same query without the rewrite returns two, and the same
shape with a bound that is not a timestamp at all (`>= 'n4'`) was refused
outright with TimestampLiteralError instead of running as the ordinary
string comparison it is. Both are issue #860's own failure produced by its
fix.
SQL hides the base name once a relation is aliased, and squirreling agrees:
it keys every relation by `alias ?? table` and rejects
`ai_gateway_messages.id` under `from ai_gateway_messages m` outright
("Table not found ... Available tables: m"). So the base name under an
alias is not a second spelling of the same relation and typing through it
can only ever type a different one. `relationKeys` now yields the one name
a qualifier can reach the relation by, which is what `byRelation`, the
`bound` stop for the correlated walk, and `inferColumns`' star and prefix
lookups all wanted.
The test that pinned the old behaviour asserted only on the rewritten AST,
never on rows, for a query the engine refuses to run; it now pins the two
spellings that are real (unaliased by name, aliased by alias), and a new
test covers the shadowing shape end to end in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review record

Reviewed:e0a110e4ce92574f4026d572111272c4887c6173 (full stack, git diff origin/master...HEAD)
Verdict: findings - 2 raised, 1 actionable and fixed, 1 deferred by design.
Fix pushed:3dbb467afdf8e931327679d41f38ce6f74518d5f

The design is sound. The "complete list or nothing" rule and the columnTypes
collapse both fail in the safe direction, and I could not construct a case
where the walk types a bound TIMESTAMP against a column the engine hands it as
a string, except the one below.


1. medium - FIXED - src/core/query/timestamp-literals.js:224 (relationKeys)

An aliased relation was recorded under both its table name and its alias.
timestampScope then wrote it into byRelation twice, so a later relation
whose table name equals an earlier relation's alias overwrote that alias
entry, and the bound was typed from the wrong relation's columns. The same
first-wins/last-wins split affected inferColumns' star qualifier
(timestamp-literals.js:305) and exprIsTimestamp' prefix lookup (:332).

Reproduced against a two-dataset registry where node.message_created_at is
STRING and ai_gateway_messages.message_created_at is TIMESTAMP:

selectm.idfrom node ai_gateway_messages
join ai_gateway_messages m onm.id=ai_gateway_messages.idwhereai_gateway_messages.message_created_at>='2026-08-18'
without coerceTimestampLiteralswith it (pre-fix)
>= '2026-08-18'[{id:1}][]
>= 'n4'rowsTimestampLiteralError

The qualifier names node, a STRING column, yet the literal was cast to a
TIMESTAMP from the joined dataset: issue #860's own silent-empty signature,
produced by its fix, plus a hard error on an ordinary string comparison.

SQL hides the base name once a relation is aliased, and squirreling agrees. It
keys every relation by alias ?? table (plan/columns.js:36,75,163) and
rejects the base name outright under an alias:

Table "ai_gateway_messages" not found in "ai_gateway_messages.message_created_at". Available tables: m

So the base name under an alias is not a second spelling of the same relation,
and typing through it can only ever type a different one. relationKeys now
yields the one name a qualifier can reach the relation by. Nothing real is
lost: every query the old key made typeable is one the engine refuses to run.

Verified landed:git show HEAD:src/core/query/timestamp-literals.js has
const key = relation.alias ?? relation.table; the new test
an alias that shadows another relation table name keeps its own columns
fails on e0a110e4 (expected 'literal', actual 'cast') and passes on
3dbb467a. The test that pinned the old behaviour
(a qualified reference to the base table (by alias or name) is still typed)
asserted only on the rewritten AST, never on rows, for a query the engine
refuses to run; it now pins the two spellings that are real.


2. low - NOT FIXED (settled by LLP 0280) - src/core/query/timestamp-literals.js:310 (inferColumns)

inferColumns bails out on the whole relation when an output column has
neither an alias nor a bare identifier expression. squirreling does derive a
name for those, via its exported derivedAlias (expression/alias.js,
re-exported from squirreling): count(*) -> count_all. So

with c as (select message_created_at, count(*) from ai_gateway_messages group by message_created_at)
select*from c where message_created_at >='2026-08-18'

still returns [] on matching data with exit 0, while adding as n to the
count(*) makes it work. Confirmed by direct execution.

Left as-is deliberately. LLP 0280 #complete settles exactly this ("any output
column cannot be given a name (an unaliased expression) ... the walk returns
nothing"), #not-settled-inference names it as out of scope and asks for a
separate design call made against evidence, and the doc is Accepted. It also
fails in the safe direction: no wrong rows, only lost typing and lost pruning,
which is strictly better than the pre-#860 behaviour. Widening it belongs in a
new LLP, not in a review fix that edits a settled decision. Worth noting for
that document: #not-settled-inference's phrase "an output column that has no
name" is imprecise for this engine, which always derives one - the real rule is
"an unaliased expression column".


Checked and cleared

Rewrite-before-infer ordering in the with branch; recursion cost
(O(depth x subtree), not exponential); compound positional pairing against
squirreling's actual select * flattening; BETWEEN (desugars to two
comparisons, so it is covered - and pinned by a test); childExprs coverage
against the full ExprNode union; bound parity for subquery and
table-function joins; column-name and dataset-name case sensitivity; passing
the mutated Statement to executeSql (accepted, execute/execute.js:31);
TRY_CAST typing; and scaffold.js's schema: [...] -> schema: { columns: [...] }
correction, which is a real bug fix against DatasetSchema
(src/core/registry/datasets.js:14).

One gap observed and judged not worth acting on: rewriteStatement never walks
a compound statement's own orderBy (SetOperationStatement.orderBy), so a
comparison written in a UNION-level ORDER BY is not typed. It fails safe
(no coercion) and the shape is vanishingly rare.

Checks

npm test 4527 pass / 0 fail / 1 skipped. npm run typecheck clean. Advisory;
CI is authoritative.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral triage record

Triaged: head 3dbb467afdf8e931327679d41f38ce6f74518d5f. The review-round cap was reached with findings still open; triage judged every residual finding non-blocking, so this PR can merge safely.

Both actionable review findings (round 1 high: duplicated-name typing; round 2 medium: alias/base-name relation keys) are verified fixed at this head, each with a failing-then-passing regression test. The remaining residuals all fail toward "no coercion", which is the pre-#860 status quo: a missed pushdown, never a wrong row and never a wrong coercion. Verified against the diff (git diff origin/master...HEAD) and by running the branch: npm test 4527 pass / 0 fail / 1 skipped, npm run typecheck clean, node --test test/core/query-timestamp-literals.test.js 27/27.

Deferred findings are enumerated with file:line evidence in follow-up issue #936:

  1. Unaliased expression column disables typing for the whole inner select (low, settled by LLP 0280 #complete, widening named under #not-settled-inference).
  2. SELECT * model diverges from squirreling's derived-table join expansion (engine error either way, never wrong rows).
  3. Symmetric ambiguity collapse forgoes a coercion when the last-wins occurrence is the TIMESTAMP (costs pruning only).
  4. Compound-statement ORDER BY is not walked (fail-safe, rare).

Not merged, not marked ready - a human decides that.

@philcunliffe
philcunliffe marked this pull request as ready for review August 19, 2026 11:10
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 19, 2026
testand others added 5 commits August 19, 2026 15:10
LLP 0272 typed a string literal from the dataset schema of the relation it
was compared against, and named the relations the registry cannot name as an
open residual. That residual is issue #860's own failure one relation further
in: `with c as (select * from ai_gateway_messages) select id from c where
message_created_at >= '...'` compared a Date to a string and returned zero
rows on matching data, exit 0, while the same bound written
`TIMESTAMP '...'` returned the rows.
A relation now supplies an ordered `{ name, isTimestamp }` list whatever kind
of relation it is: a dataset from its schema, a CTE from the list its body was
proved to expose, a derived table walked in place. Everything LLP 0272 built
on a dataset schema (`agreed`, `byRelation`, the outward walk, the
type-carrying calls) works over those unchanged. The list is complete or it is
nothing, so an inner select the walk cannot read end to end contributes no
types rather than a partial list a later name could be mis-typed against.
The second residual LLP 0272 named, an unqualified correlated reference, is
not a coercion defect: squirreling does not resolve an unqualified name into
an enclosing select at all, and raises identically whether the literal is bare
or typed. The suite pins that rejection in both spellings rather than fixing
what does not fail.
LLP 0280 records the decision and extends LLP 0272.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…IMESTAMP (#915)
A relation can expose one column name more than once: `select *` over a join
expands both sides, and two of them may declare the same name with different
types. The new column list keeps both occurrences, which is right, but two of
its readers took the wrong one out.
`timestampScope` built a relation's `declared` set as the *union* of its
TIMESTAMP names, so a qualified reference to an ambiguous name resolved to
TIMESTAMP whichever side declared it. `declaresTimestamp` took the *first*
occurrence, so an inner select's own expression resolved by join order.
Both type a bound against a column the engine does not hand it. squirreling
flattens a CTE's duplicate last-wins, so
with c as (select * from ai_gateway_messages m join node n on m.id = n.id)
select c.id from c where c.message_created_at >= '2026-08-18'
reads `node`'s STRING column and returned the two matching rows before this
branch; after it, the literal was cast to a TIMESTAMP and the bound went
silently empty on matching data - issue #860's own signature, one join in. Said
loudly, `>= 'n4'` on that same string column stopped being a comparison at all
and became a TimestampLiteralError.
Both readers now collapse a relation's list by name first, keeping a name only
when every occurrence of it is a TIMESTAMP, which is the `#complete` doctrine
already stated for the list as a whole: never mis-type, and pay only the
pruning when unsure. LLP 0280 records the ambiguous-name case under the same
anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`relationKeys` recorded an aliased relation under both its table name and
its alias, so `timestampScope` wrote the same relation into `byRelation`
twice. A second relation whose table name equals the first one's alias then
overwrote that entry, and a qualified bound was typed from the wrong
relation's columns:
select m.id from node ai_gateway_messages
join ai_gateway_messages m on m.id = ai_gateway_messages.id
where ai_gateway_messages.message_created_at >= '2026-08-18'
The qualifier names `node`, whose `message_created_at` is a STRING, but the
literal was cast to a TIMESTAMP from the joined dataset. The query returned
no rows where the same query without the rewrite returns two, and the same
shape with a bound that is not a timestamp at all (`>= 'n4'`) was refused
outright with TimestampLiteralError instead of running as the ordinary
string comparison it is. Both are issue #860's own failure produced by its
fix.
SQL hides the base name once a relation is aliased, and squirreling agrees:
it keys every relation by `alias ?? table` and rejects
`ai_gateway_messages.id` under `from ai_gateway_messages m` outright
("Table not found ... Available tables: m"). So the base name under an
alias is not a second spelling of the same relation and typing through it
can only ever type a different one. `relationKeys` now yields the one name
a qualifier can reach the relation by, which is what `byRelation`, the
`bound` stop for the correlated walk, and `inferColumns`' star and prefix
lookups all wanted.
The test that pinned the old behaviour asserted only on the rewritten AST,
never on rows, for a query the engine refuses to run; it now pins the two
spellings that are real (unaliased by name, aliased by alias), and a new
test covers the shadowing shape end to end in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…put column (#936)
The compound ORDER BY walk typed only an unqualified name, on the reasoning
that the compound binds no relation and so has no qualifier to resolve. The
engine disagrees: squirreling evaluates that clause against the combined
output row, whose column names carry no qualifier, and its identifier lookup
falls through to the bare name. So `m.message_created_at >= '...'` read the
same output column the unqualified spelling reads, kept comparing a Date to a
string, and came back in the wrong order with exit 0 - the exact failure this
change set exists to end, one character away from the spelling it fixed. An
uncoercible literal in that spelling was swallowed too, where LLP 0272
#refuse-uncoercible calls for an error.
A qualifier written in the clause is now registered against the compound's
output columns, with the two shapes that reach a different value first held
back: one an enclosing select binds is correlated, resolved by the ordinary
outward walk (so `outer` is threaded in rather than dropped), and one an
output column name is or ends with is struct field access, recorded in
`bound` so the outward walk stops rather than borrowing an enclosing
relation of the same name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Consolidation update: this branch is now rebuilt directly on current master with only #915's three own commits plus #944's two reviewed commits. The old #868 stack and unrelated merge-history commits are gone. Focused query tests (37/37), typecheck, LLP reference hygiene, and GitHub Node 22/24 CI are green. #944 is fully contained here and will be closed after this merges.

@philcunliffe
philcunliffe merged commit 5def93d into masterAug 19, 2026
10 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-906 branch August 19, 2026 22:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #868

1 participant

@philcunliffe