Skip to content

Fix duplicate CTE name when a query has multiple arithmetic-wrapped transforms#247

Merged
whimo merged 5 commits into
mainfrom
artemy/dev-1692-multiple-time_shift-calls-in-one-query-fail-duplicate-cte
Jul 20, 2026
Merged

Fix duplicate CTE name when a query has multiple arithmetic-wrapped transforms#247
whimo merged 5 commits into
mainfrom
artemy/dev-1692-multiple-time_shift-calls-in-one-query-fail-duplicate-cte

Conversation

@whimo

@whimo whimo commented Jul 20, 2026

Copy link
Copy Markdown
Member

Fixes DEV-1692.

Problem

A query with two or more time_shift() calls embedded in arithmetic formulas — the standard period-over-period shape — failed at SQL generation:

Database error: Parser Error: Duplicate CTE name "shifted__t0"
"measures": [
  {"formula": "order_total:sum", "name": "revenue"},
  {"formula": "order_total:sum - time_shift(order_total:sum, -1, 'month')", "name": "growth"},
  {"formula": "(order_total:sum - time_shift(order_total:sum, -1, 'month')) / time_shift(order_total:sum, -1, 'month')", "name": "growth_pct"}
]

Root cause

When a transform is wrapped in arithmetic it gets hoisted out of the formula under a placeholder name. slayer/core/formula.py::_parse_mixed_arithmetic mints those placeholders (_t0, _t1, …) from a counter that restarts on every formula parse, so every such measure claimed the name _t0. That name flows through _flatten_spec verbatim and becomes both the self-join CTE name (shifted_{name} / sjoin_{name}) and the projection alias — so two measures collided on both.

Fix

Qualify each placeholder with the owning measure's field_name in the MixedArithmeticField branch of _flatten_spec, mirroring the _ts_{field_name} scheme the change/change_pct desugar already uses. field_name is unique per query (duplicate measure names are rejected), so the hoisted names are too. One file, ~20 lines.

Bare top-level time_shift measures already used their own measure name and are untouched.

Two things worth flagging beyond the ticket

1. The duplicate CTE name was only the visible half. Both measures' expressions also resolved to the same alias (orders._t0), so a rename-only fix at emission time would have left the second measure silently reading the first's shifted value. Uniquifying at hoist time re-wires the expression references too.

2. The same collision silently corrupted results for window transforms. cumsum / lag / lead wrapped in arithmetic emit no CTE, so two such measures produced valid SQL with duplicate aliases — no error, just wrong numbers. Confirmed against the pre-fix code:

cumsum-in-arith x2:  CTE-dupes=none  alias-dupes=['orders._t0']   # silently wrong

Relatedly, the ticket's suggested workaround ("use change() instead") only held for bare change(x) measures — change(x) * 2 in two measures hit the identical duplicate-CTE failure. Both are fixed here.

Tests

  • tests/test_sql_generator.py — three tests: distinct CTE names, distinct aliases/offsets (pins the silent-corruption half), and the ticket's exact four-measure repro shape.
  • tests/integration/test_integration.py — one execution-level test asserting real values with distinct offsets (-1 vs -2), so a regression that re-collapses the shifts surfaces as wrong numbers rather than only a parser error.

All four verified to fail without the fix and pass with it.

Verification

Regressions were checked by diffing the full failure set before and after, not by comparing counts: 141 unit failures before, 141 after, identical test names. Those are all pre-existing environment issues (the advanced_search/tantivy extra isn't installed, Postgres unavailable, plus a pre-existing ClickHouse WEEK_SUNDAY and SQLite-ingest failure). Same identical-set result for the integration suites (54 before / 54 after).

Dialect tests asserting on CTE names (shifted__ts_pct, shifted_rev_prev) are unaffected — both of those paths keep their naming. ruff check slayer/ tests/ passes.

Edge cases verified to compose: change() inside arithmetic (_ts__t0_a), mixed lag+time_shift within one formula, the filter-extraction _ft{n} namespace, and the nested-self-join guard still raising.

Not included

Deduplicating content-identical shift CTEs — the repro emits one per occurrence, which is correct but verbose. That's an optimization rather than a correctness issue and is better scoped alongside DEV-1446.

Docs: no page claimed this limitation, so nothing to correct. The related transform-doc rewording is tracked separately as DEV-1693. Added a CLAUDE.md note on the naming rule, since "derive hoisted names from field_name, never from a formula-local counter" is exactly what the next hoisting path would otherwise re-break.

Summary by CodeRabbit

  • Bug Fixes

    • Resolved incorrect results and SQL alias/CTE name collisions when multiple arithmetic-wrapped time_shift calculations appear in the same query.
    • Kept distinct time_shift offsets independent across expressions to avoid unintended reuse.
    • Ensured change/change_pct and grouped period-over-period calculations reset by non-time dimensions and return NULL for missing calendar periods.
  • Documentation

    • Clarified time_shift as calendar-aware and change/change_pct as partition-safe and gap-safe.
    • Added intent recipes for common growth and comparison scenarios.
  • Tests

    • Added integration and SQL-generator coverage for multi-shift queries and collision-free SQL generation.

…ransforms

A query with two or more `time_shift()` calls embedded in arithmetic
formulas failed at SQL generation with `Parser Error: Duplicate CTE name
"shifted__t0"`.

`_parse_mixed_arithmetic` mints the placeholder names for transforms
hoisted out of an arithmetic formula from a counter that restarts on
every formula parse, so every such measure claimed the name `_t0`. That
name becomes both the self-join CTE name (`shifted_{name}`) and the
projection alias, so two measures collided on both.

Qualify each placeholder with the owning measure's `field_name` in the
`MixedArithmeticField` branch of `_flatten_spec`, mirroring the
`_ts_{field_name}` scheme the change/change_pct desugar already uses.
`field_name` is unique per query (duplicate measure names are rejected),
so the hoisted names are too.

The duplicate CTE name was the visible half of the bug: both expressions
also resolved to the same alias, so the second measure read the first
one's value. That made the same collision silently wrong — rather than a
parser error — for the CTE-less window transforms (`cumsum`, `lag`,
`lead`) wrapped in arithmetic, which generated valid SQL with duplicate
aliases. Both are fixed here.

Fixes DEV-1692.

Co-Authored-By: Claude Fable 5 <[email protected]>
@linear

linear Bot commented Jul 20, 2026

Copy link
Copy Markdown

DEV-1692

The "diff from previous row" gloss on change/change_pct read as naive
row-diffing, steering users toward time_shift for grouped
period-over-period queries — exactly the case where change_pct is the
right tool. Reword the MCP query docstring, formulas.md, the
help.transforms memory, terminology.md, and the query skill to state
the actual behavior: calendar-aware self-join matched on all non-time
dimensions, partition-safe, resets per group. Add intent recipes
(month-over-month growth -> change_pct).

Also correct two stale claims that granularity-less time_shift(x, -1)
compiles to LAG — verified via dry-run that it emits the calendar
self-join at the query's time grain.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0b47baa5-3ae6-4c79-9ef7-1ed4fa76c2d8

📥 Commits

Reviewing files that changed from the base of the PR and between e4a402c and 66d9ca9.

📒 Files selected for processing (3)
  • CLAUDE.md
  • slayer/engine/enrichment.py
  • tests/test_sql_generator.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • CLAUDE.md
  • slayer/engine/enrichment.py
  • tests/test_sql_generator.py

📝 Walkthrough

Walkthrough

The change documents calendar-aware, partition-safe transform semantics, qualifies hidden arithmetic-transform aliases by measure name, and adds regression tests for multiple time_shift expressions and collision-free generated SQL.

Changes

Calendar transforms and hidden aliases

Layer / File(s) Summary
Calendar transform semantics and usage guidance
.claude/skills/slayer-query.md, docs/concepts/formulas.md, docs/concepts/terminology.md, docs/examples/04_time/time_nb.ipynb, slayer/mcp/server.py, slayer/memories/help_content/04_transforms.md
Documentation and query-tool guidance describe calendar-aware, partition-safe behavior and usage of change_pct, change, and time_shift.
Field-qualified hidden transform aliases
CLAUDE.md, slayer/engine/enrichment.py
Arithmetic-wrapped sub-transforms use collision-safe, field-qualified aliases, with scoped placeholder bindings during SQL resolution.
Multiple-shift regression coverage
tests/integration/test_integration.py, tests/test_sql_generator.py
Tests validate calendar gap behavior, independent month offsets, distinct aliases, and collision-free CTE names for multiple time_shift expressions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Query
  participant Enrichment
  participant SQLGenerator
  participant IntegrationTests
  Query->>Enrichment: submit multiple arithmetic-wrapped time_shift formulas
  Enrichment->>Enrichment: allocate distinct collision-safe aliases
  Enrichment->>SQLGenerator: emit independent shifted CTEs and expressions
  SQLGenerator-->>IntegrationTests: return calendar-aware query results
  IntegrationTests->>IntegrationTests: verify gap handling and distinct offsets
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing duplicate CTE names for arithmetic-wrapped transforms.
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch artemy/dev-1692-multiple-time_shift-calls-in-one-query-fail-duplicate-cte

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/concepts/formulas.md`:
- Around line 138-139: The user-facing documentation for change_pct must state
that a zero shifted baseline returns NULL instead of showing unconditional
division. Update the change_pct entries at docs/concepts/formulas.md lines
138-139 and slayer/memories/help_content/04_transforms.md lines 14-15
consistently, either documenting the CASE WHEN ts != 0 guard or explicitly
describing the NULL behavior; no direct change is required for change.

In `@slayer/engine/enrichment.py`:
- Around line 895-903: The hidden alias allocation around _flatten_spec must
avoid collisions with all public measure names and previously allocated hidden
aliases, not just rely on field_name qualification; update this flow to choose
an available alias before flattening each sub-transform and preserve the
resulting alias mappings. In CLAUDE.md lines 75-75, document that hidden names
use collision-checked allocation and remove any claim that field_name uniqueness
alone guarantees uniqueness.
- Around line 895-903: Update the `_flatten_spec` call in this placeholder-alias
flow to pass its arguments by keyword, and replace
`known_aliases.pop(placeholder, None)` with `del known_aliases[placeholder]`
because each temporary binding is guaranteed to exist. Keep the surrounding
alias resolution behavior unchanged.

In `@slayer/mcp/server.py`:
- Around line 357-361: Update the transform documentation near the
change/change_pct/time_shift description to state that change and change_pct
default to comparing with the prior time bucket, while time_shift compares using
its explicitly requested offset and granularity. Preserve the existing
explanation of non-time dimension joins and grouped-query behavior.

In `@tests/integration/test_integration.py`:
- Line 473: Update the regression test around the time_shift calendar self-join
to use non-contiguous month data, such as January and March with February
absent, and assert that March shifted by one month returns NULL. Rename the test
and its comments to describe calendar shifting rather than row-based LAG/LEAD
behavior, ensuring the assertions distinguish the two implementations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fee66179-35e6-4ae5-81ad-4ee98d9ceb56

📥 Commits

Reviewing files that changed from the base of the PR and between 34baa4a and 81c01ab.

📒 Files selected for processing (10)
  • .claude/skills/slayer-query.md
  • CLAUDE.md
  • docs/concepts/formulas.md
  • docs/concepts/terminology.md
  • docs/examples/04_time/time_nb.ipynb
  • slayer/engine/enrichment.py
  • slayer/mcp/server.py
  • slayer/memories/help_content/04_transforms.md
  • tests/integration/test_integration.py
  • tests/test_sql_generator.py

Comment thread docs/concepts/formulas.md Outdated
Comment thread slayer/engine/enrichment.py Outdated
Comment thread slayer/mcp/server.py Outdated
Comment thread tests/integration/test_integration.py Outdated
whimo and others added 2 commits July 20, 2026 21:10
…-aware test

- Document change_pct's CASE WHEN ts != 0 guard (NULL on zero/missing
  prior period) in formulas.md and the help.transforms memory.
- MCP query docstring: change/change_pct compare against the prior time
  bucket at the query's granularity; time_shift at its explicit
  offset/granularity.
- Rework the default-granularity time_shift test to use a gap month
  (completed orders only: Jan and Mar, no Feb) so calendar self-join
  semantics are distinguished from row-based LAG/LEAD in both directions.
Qualifying a hoisted transform's name with the owning measure's field_name
keeps hoisted names distinct from each other, but not from user measure
names. A query declaring a measure literally named `_t0_growth` alongside
an arithmetic-wrapped transform on a measure named `growth` put both on
the alias `orders._t0_growth`: the self-join CTE projected two columns
under that name and the hoisted reference resolved to the user's measure.
Valid SQL, silently wrong numbers — verified against DuckDB, where the
February row of the repro returned -125.0 instead of -175.0, degenerating
`growth` to `-revenue`.

Allocate hidden names through `_allocate_hidden_name`, which checks the
query's public measure names, previously allocated hidden names, and
`known_aliases`, appending `_2`, `_3`, ... until free. Extract
`_public_field_name` so the reserved set and the main measure loop derive
public names identically.

Also restore rather than drop a shadowed `known_aliases` binding after
resolving a formula, since a user measure may itself be named `_t0`.

Co-Authored-By: Claude Fable 5 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/enrichment.py`:
- Around line 619-621: Extend the _reserved_public_names construction to include
the public names of all selected dimensions and time dimensions in addition to
query measures. Reuse the existing field-name helper and selection symbols so
names such as _t0_growth are reserved before hoisted transforms are assigned
aliases, while preserving the current measure reservations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c8ab919-1a93-4d36-aae3-5bbc54adce26

📥 Commits

Reviewing files that changed from the base of the PR and between 81c01ab and e4a402c.

📒 Files selected for processing (7)
  • CLAUDE.md
  • docs/concepts/formulas.md
  • slayer/engine/enrichment.py
  • slayer/mcp/server.py
  • slayer/memories/help_content/04_transforms.md
  • tests/integration/test_integration.py
  • tests/test_sql_generator.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • slayer/mcp/server.py
  • CLAUDE.md
  • slayer/memories/help_content/04_transforms.md

Comment thread slayer/engine/enrichment.py Outdated
Dimensions and time dimensions alias as `<model>.<name>`, the same shape
hoisted transforms use, but only measure names were reserved. A dimension
named `_t0_growth` therefore claimed the same alias as the shift hoisted
out of a measure named `growth`, and the measure silently computed
`revenue - <dimension>` instead of the period difference.

Verified against DuckDB with a numeric colliding dimension (tier=7):
growth returned 293/118/318 — `revenue - 7` — where Jan=NULL, Feb=-175,
Mar=200 was correct. A non-numeric colliding dimension surfaces as a
binder type error instead, so the failure mode is silent only when the
types happen to line up.

Reserve dimensions and time dimensions by bare name alongside the
existing measure reservations.

Co-Authored-By: Claude Fable 5 <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

@whimo
whimo merged commit 15b29b0 into main Jul 20, 2026
6 checks passed
@whimo whimo mentioned this pull request Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant