From 3892d2d7cab4060a9d017190ce65473bd4daba7e Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Wed, 21 Oct 2026 23:24:46 +0200 Subject: [PATCH 1/6] Preserve declared joins on saved query-backed models --- docs/concepts/models.md | 2 + .../.openspec.yaml | 2 + .../fix-query-backed-declared-joins/design.md | 19 +++ .../proposal.md | 20 +++ .../specs/queries/query-backed-inline/spec.md | 33 +++++ .../fix-query-backed-declared-joins/tasks.md | 17 +++ slayer/engine/bundle_builder.py | 4 +- slayer/engine/plan.py | 12 +- slayer/ir/source_bundle.py | 11 +- tests/test_dev1902_join_key_spelling.py | 1 - ...t_query_backed_declared_join_boundaries.py | 119 ++++++++++++++++++ tests/test_query_backed_declared_joins.py | 53 ++++++++ 12 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/fix-query-backed-declared-joins/.openspec.yaml create mode 100644 openspec/changes/fix-query-backed-declared-joins/design.md create mode 100644 openspec/changes/fix-query-backed-declared-joins/proposal.md create mode 100644 openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md create mode 100644 openspec/changes/fix-query-backed-declared-joins/tasks.md create mode 100644 tests/test_query_backed_declared_join_boundaries.py create mode 100644 tests/test_query_backed_declared_joins.py diff --git a/docs/concepts/models.md b/docs/concepts/models.md index cf12f0cc8..88bf4157e 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -453,6 +453,8 @@ Or use the saved result as a model in another query: A consumed query-backed model is spliced into the consuming query as stages whose names stay private to it, and one that references itself, directly or through other query-backed models, is rejected. +Joins declared on the saved model belong to its result relation: their keys name the auto-generated output columns. They remain available when another query uses the model as its source or reaches it through a join, so joined filters and aggregates follow those relationships. The backing source model's joins are not inherited; declare the relationships needed by the saved result itself. Ordinary query stages still expose only their projected columns. + ### Variables in model SQL `{variable}` substitution is not limited to query-level filters — the same mechanism injects caller-supplied values into a model's **raw-SQL (Mode A) surfaces**. This is the primitive for parameterizing hand-written model SQL (for example, representing a Cube `FILTER_PARAMS` cube as an `sql`-mode model whose filter bounds arrive as variables). diff --git a/openspec/changes/fix-query-backed-declared-joins/.openspec.yaml b/openspec/changes/fix-query-backed-declared-joins/.openspec.yaml new file mode 100644 index 000000000..dc36fb1bf --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/fix-query-backed-declared-joins/design.md b/openspec/changes/fix-query-backed-declared-joins/design.md new file mode 100644 index 000000000..01cb99b6d --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/design.md @@ -0,0 +1,19 @@ +## Context + +The public save path validates and persists joins after populating a query-backed model's output columns. Execution strips those joins from its placeholder, then creates a flat synthetic model from the final StageSchema. Product-hop support can turn the missing edge into a successful uncorrelated filter rather than a refusal. + +## Decisions + +1. Repair the saved-relation boundary. Preserve only the query-backed model's own declared joins; never inherit its backing source's join graph. +2. Reuse `model_from_stage_schema` to carry optional declared joins for saved final relation names in source, extension and sibling/target paths. Keep StageSchema unchanged. Validate joins against the saved relation's real identity so a legal target named `_stage` is not mistaken for a self-join. +3. Select ModelScope for a materialized saved relation by its identity in the query-backed model map, including relations with incoming reverse edges and no outgoing joins. Ordinary/private query stages retain flat StageSchema binding. +4. Keep demand-based splicing. Merely declaring or reaching an unread query-backed model must not plan it or manufacture a cycle. +5. Reuse the existing logical join-key and extension semantics. Test distinct/named extension joins. Preserve the existing workaround that repeats an identical stored join on a saved-relation extension by applying that exact edge once; avoid global join de-duplication or graph redesign. + +## Validation + +Record failing segmented-total tests before the implementation. Execute the repaired paths on SQLite and DuckDB using renamed physical/logical keys. Check root and target consumption, aggregate attribution, extension parity, private-stage scopes and cycles. Run the required unit, lint, type and architecture gates and the documented CI integration selection, then inspect current-head CI and review results. + +## Risks + +An incomplete root-only fix leaves target/sibling reads broken. Passing saved relationships to ordinary stages breaks stage isolation. Eager traversal of retained edges can introduce false cycles. Preserve those distinctions and use existing fixtures and regression suites. diff --git a/openspec/changes/fix-query-backed-declared-joins/proposal.md b/openspec/changes/fix-query-backed-declared-joins/proposal.md new file mode 100644 index 000000000..8a3eba3c7 --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/proposal.md @@ -0,0 +1,20 @@ +## Why + +A saved query-backed model's declared joins disappear when its final stage becomes a consuming relation. A customer-segment filter consequently returns all-customer revenue (150 instead of 120), because the missing relationship is treated as a product hop. The older outbound traversal gap is tracked as DEV-1918; this change fixes its wrong-result consequence reported in GitHub issue #507. + +## What Changes + +- Retain the saved relation's own joins through query-backed placeholder and relation materialization. +- Bind saved relations as model scopes, keeping ordinary query-stage outputs flat. +- Add executable SQLite/DuckDB tests for filtered totals, traversal and aggregate attribution. +- Preserve existing demand splicing, private stage scopes, cycle errors and extension behavior. + +## Capabilities + +### Modified Capabilities + +- `queries/query-backed-inline`: consuming a saved relation retains its own declared relationships. + +## Impact + +A focused engine/IR fix with regression tests and a clarification in the models guide. No new dependency, persisted schema, public request field or normative architecture change. diff --git a/openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md b/openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md new file mode 100644 index 000000000..2096afe94 --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Saved relation relationships survive splicing + +A stored query-backed model consumed as a relation SHALL retain that model's own declared joins, with keys resolved against its final-stage output columns. Those relationships SHALL remain available when the relation is a source, a join target or an extended source. Ordinary query-stage outputs SHALL remain flat and SHALL NOT inherit joins from their backing source. + +#### Scenario: Joined segment filter on a saved relation +- **WHEN** a saved customer-total relation declares a customer-key join and a query filters to gold customers +- **THEN** only the related gold customers contribute, with the same values as an equivalent relationship declared solely on a query extension, on SQLite and DuckDB + +#### Scenario: Joined dimensions and aggregate attribution +- **WHEN** a query reads a dimension or aggregate through a saved relation's declared join +- **THEN** the relationship determines the rows and attribution instead of being treated as an unrelated dataset + +#### Scenario: A saved relation consumed through another model +- **WHEN** another model joins a saved query-backed relation and a query traverses onward through that relation's own join +- **THEN** the declared relationships remain available with logical key spelling and correct values + +#### Scenario: Extending a saved relation +- **WHEN** a query extension adds a distinct named join to a saved relation +- **THEN** both its stored joins and the extension's joins remain available + +#### Scenario: Repeating an existing saved relationship +- **WHEN** an extension over a saved query-backed relation repeats an exactly identical declared join +- **THEN** that edge is applied once, without introducing an ambiguous parallel path + +#### Scenario: Legal join targets do not collide with temporary identities +- **WHEN** a saved relation declares a join to a legal model named `_stage` +- **THEN** both unjoined and joined consumers execute without a false self-join error + +#### Scenario: Ordinary stage isolation +- **WHEN** an ordinary query stage is consumed by a later stage +- **THEN** only its projected output schema is visible and its backing source's joins are not inherited diff --git a/openspec/changes/fix-query-backed-declared-joins/tasks.md b/openspec/changes/fix-query-backed-declared-joins/tasks.md new file mode 100644 index 000000000..ab9554411 --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/tasks.md @@ -0,0 +1,17 @@ +## 1. Plan and reproduce + +- [x] 1.1 Refresh current upstream and public issue/PR history; report the wrong-result behavior in #507, distinguishing the known DEV-1918 traversal gap. +- [x] 1.2 Review the relation-boundary design independently, preserving flat ordinary stages and demand splicing without a normative architecture edit. +- [x] 1.3 Record failing SQLite/DuckDB segmented-total regressions before production changes. + +## 2. Implement and document + +- [x] 2.1 Preserve declared joins through saved-relation placeholder, root/extension and sibling/target materialization. +- [x] 2.2 Verify logical-key traversal, aggregate attribution, extension compatibility, ordinary-stage isolation and cycle behavior. +- [x] 2.3 Clarify saved-relation joins in the user models guide. + +## 3. Validate and review + +- [x] 3.1 Pass the focused regressions and related query-backed scope/cycle tests. +- [ ] 3.2 Pass the full documented unit, lint, type and architecture pre-push gates. +- [x] 3.3 Independently review the implementation and verify the final tree. diff --git a/slayer/engine/bundle_builder.py b/slayer/engine/bundle_builder.py index c4442f6f6..1ad41d04b 100644 --- a/slayer/engine/bundle_builder.py +++ b/slayer/engine/bundle_builder.py @@ -262,7 +262,7 @@ async def _split_query_backed( ) -> "Tuple[List[SlayerModel], Dict[str, SlayerModel]]": """Split ``models`` into referenced models and query-backed placeholders, closing over each placeholder's stage bases and each model's definition-named models (and - their components). Placeholders keep their stored form with joins dropped; on-chain + their components). Placeholders keep their stored form, including declared joins; on-chain names are never loaded.""" referenced: Dict[str, SlayerModel] = {} query_backed: Dict[str, SlayerModel] = {} @@ -279,7 +279,7 @@ async def _split_query_backed( storage=storage, data_source=data_source, failures=failures, ) if not is_spine(m)) continue - query_backed[model.name] = model.model_copy(update={"joins": []}) + query_backed[model.name] = model pending.extend(await _stage_base_components( model=model, skip=[*chain, *query_backed], known={*referenced, *query_backed}, storage=storage, data_source=data_source, failures=failures, diff --git a/slayer/engine/plan.py b/slayer/engine/plan.py index 16dbc7f00..c04813e07 100644 --- a/slayer/engine/plan.py +++ b/slayer/engine/plan.py @@ -131,8 +131,15 @@ def _stage_scope_and_bundle_unframed( if sib is not None and isinstance(src, ModelExtension): base = model_from_stage_schema( name=sib, schema=stage_schemas[sib], data_source=data_source, + joins=(bundle.query_backed[sib].joins if sib in bundle.query_backed else []), ) - overlaid = apply_extension_overlay(base, src) + extension = src + if sib in bundle.query_backed and src.joins: + # Keep the existing repeated-join workaround idempotent for saved relations. + extension = src.model_copy(update={ + "joins": [join for join in src.joins if join not in base.joins], + }) + overlaid = apply_extension_overlay(base, extension) others = {n: s for n, s in stage_schemas.items() if n != sib} sb = stage_bundle_with_siblings( bundle=bundle, source_model=overlaid, @@ -144,12 +151,15 @@ def _stage_scope_and_bundle_unframed( if isinstance(src, str) and src in stage_schemas: synth = model_from_stage_schema( name=src, schema=stage_schemas[src], data_source=data_source, + joins=(bundle.query_backed[src].joins if src in bundle.query_backed else []), ) others = {n: s for n, s in stage_schemas.items() if n != src} sb = stage_bundle_with_siblings( bundle=bundle, source_model=synth, sibling_schemas=others, data_source=data_source, ) + if src in bundle.query_backed: + return ModelScope(source_model=synth), sb return stage_schemas[src], sb # 3. Model-scoped: the stage's own resolved source model. diff --git a/slayer/ir/source_bundle.py b/slayer/ir/source_bundle.py index 98e8bb172..224785498 100644 --- a/slayer/ir/source_bundle.py +++ b/slayer/ir/source_bundle.py @@ -13,6 +13,7 @@ from slayer.core.granularity import CustomGranularity from slayer.core.models import ( Column, + ModelJoin, SlayerModel, _check_column_measure_namespace, _check_join_keys, @@ -50,7 +51,7 @@ class ResolvedSourceBundle(BaseModel): dialect: str # sqlglot dialect the query renders in # Minted stage identity → its user-facing spelling. stage_displays: Dict[str, StageDisplay] = Field(default_factory=dict) - # Stored query-backed models the statement may splice (stored form, joins dropped). + # Stored query-backed models the statement may splice, including result joins. query_backed: Dict[str, SlayerModel] = Field(default_factory=dict) # Query-backed models whose stages are being planned, outermost first. splice_chain: Tuple[str, ...] = () @@ -185,6 +186,7 @@ def model_from_stage_schema( sql: Optional[str] = None, column_sql: Optional[Dict[str, str]] = None, default_time_dimension: Optional[str] = None, + joins: Optional[List[ModelJoin]] = None, ) -> SlayerModel: """The model a stage's output is read as: over its CTE (``sql=None``) or over ``sql``. @@ -194,7 +196,8 @@ def model_from_stage_schema( composite = len(grain) > 1 column_sql = column_sql or {} model = SlayerModel( - name="_stage", + # Validate saved joins at their real identity; minted flat stages need the temporary name. + name=name if joins else "_stage", data_source=data_source, sql_table="_stage" if sql is None else None, sql=sql, @@ -212,6 +215,7 @@ def model_from_stage_schema( ).with_respellings(c.respellings) for c in schema.columns ], + joins=joins or [], ) # A minted stage identity carries the reserved prefix the name validator rejects. return model.model_copy(update={ @@ -237,7 +241,8 @@ def stage_bundle_with_siblings( """ synths = [ model_from_stage_schema( - name=n, schema=s, data_source=data_source + name=n, schema=s, data_source=data_source, + joins=(bundle.query_backed[n].joins if n in bundle.query_backed else []), ) for n, s in sibling_schemas.items() ] diff --git a/tests/test_dev1902_join_key_spelling.py b/tests/test_dev1902_join_key_spelling.py index 7ea92f1ba..4a303c574 100644 --- a/tests/test_dev1902_join_key_spelling.py +++ b/tests/test_dev1902_join_key_spelling.py @@ -199,7 +199,6 @@ def _customer_totals(*, key: str = "customer_id") -> SlayerModel: class TestQueryBackedJoinKeys: - @pytest.mark.xfail(strict=True, reason="DEV-1918: joins on a query-backed model are not traversable") async def test_join_on_backing_output_column_executes(self, all_renamed): await all_renamed.save_model(_customer_totals()) resp = await all_renamed.execute(_query( diff --git a/tests/test_query_backed_declared_join_boundaries.py b/tests/test_query_backed_declared_join_boundaries.py new file mode 100644 index 000000000..aeb6b0aec --- /dev/null +++ b/tests/test_query_backed_declared_join_boundaries.py @@ -0,0 +1,119 @@ +"""Saved relation joins survive consumption without leaking into ordinary stages.""" + +from __future__ import annotations + +import pytest + +from slayer.core.errors import UnrelatedDatasetError +from slayer.core.models import ModelJoin + +from tests._dev1902_fixtures import ( + AMOUNT_BY_CUSTOMER, + _seed_duckdb, + _seed_sqlite, + cells, + customers_model, + orders_model, + renamed_graph, +) +from tests._engine_helpers import seeded_exec_engine +from tests.test_dev1902_join_key_spelling import _customer_totals, _query + + +@pytest.fixture(params=["sqlite", "duckdb"]) +async def engine(request): + seed = _seed_duckdb if request.param == "duckdb" else _seed_sqlite + async with seeded_exec_engine( + dialect=request.param, seed=seed, models=renamed_graph() + ) as (query_engine, _db): + yield query_engine + + +async def test_join_target_keeps_its_onward_relationship(engine): + await engine.save_model(_customer_totals()) + consumer = orders_model().model_copy(update={ + "name": "consumer", + "joins": [ModelJoin(target_model="customer_totals", join_pairs=[["customer_id", "customer_id"]])], + }) + await engine.save_model(consumer) + + response = await engine.execute(_query( + source_model="consumer", + dimensions=["customer_totals.customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + )) + + assert cells(response, dim_suffix="customers.name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_saved_relation_without_outgoing_joins_can_use_incoming_edge(engine): + await engine.save_model(_customer_totals().model_copy(update={"joins": []})) + customers = customers_model() + await engine.save_model(customers.model_copy(update={"joins": [ + *customers.joins, + ModelJoin(target_model="customer_totals", join_pairs=[["id", "customer_id"]], name="totals"), + ]})) + + response = await engine.execute(_query( + source_model="customer_totals", + dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + + assert cells(response, dim_suffix="name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_extension_keeps_distinct_named_stored_and_added_joins(engine): + totals = _customer_totals() + await engine.save_model(totals.model_copy(update={ + "joins": [totals.joins[0].model_copy(update={"name": "owner"})], + })) + + response = await engine.execute(_query( + source_model={"source_name": "customer_totals", "joins": [{ + "target_model": "customers", "join_pairs": [["customer_id", "id"]], "name": "reviewer", + }]}, + dimensions=["owner.name", "reviewer.tier"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + + assert { + row["customer_totals.owner.name"]: ( + row["customer_totals.reviewer.tier"], row["customer_totals.total"] + ) + for row in response.data + } == {"Ann": ("gold", 30.0), "Bob": ("silver", 30.0), "Cid": ("gold", 90.0)} + + +async def test_ordinary_stage_does_not_inherit_backing_source_joins(engine): + stage = _query( + name="s", source_model="orders", dimensions=["customer_id"], + measures=[{"formula": "sum(amount)", "name": "amt"}], + ) + consumer = _query( + source_model="s", dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + ) + + with pytest.raises(UnrelatedDatasetError): + await engine.execute([stage, consumer]) + + +async def test_legal_stage_named_join_target_is_not_a_synthetic_self_join(engine): + await engine.save_model(customers_model().model_copy(update={"name": "_stage"})) + totals = _customer_totals() + await engine.save_model(totals.model_copy(update={ + "joins": [totals.joins[0].model_copy(update={"target_model": "_stage"})], + })) + + unjoined = await engine.execute(_query( + source_model="customer_totals", + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert unjoined.data == [{"customer_totals.total": 150.0}] + + joined = await engine.execute(_query( + source_model="customer_totals", dimensions=["_stage.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert cells(joined, dim_suffix="_stage.name", measure="total") == AMOUNT_BY_CUSTOMER diff --git a/tests/test_query_backed_declared_joins.py b/tests/test_query_backed_declared_joins.py new file mode 100644 index 000000000..bba944117 --- /dev/null +++ b/tests/test_query_backed_declared_joins.py @@ -0,0 +1,53 @@ +"""Saved query-backed models retain their declared relationship graph.""" + +from __future__ import annotations + +import pytest + +from tests._dev1902_fixtures import cells, make_exec_engine, renamed_graph +from tests.test_dev1902_join_key_spelling import _customer_totals, _query + + +@pytest.fixture(params=["sqlite", "duckdb"]) +async def saved_totals_engine(request): + async for engine in make_exec_engine(request, models=renamed_graph()): + await engine.save_model(_customer_totals()) + yield engine + + +async def test_saved_query_backed_join_filters_population(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model="customer_totals", + measures=[{"formula": "sum(amt)", "name": "total"}], + filters=["customers.tier = 'gold'"], + )) + assert response.data == [{"customer_totals.total": 120.0}] + + +async def test_saved_query_backed_join_traverses_multihop_for_aggregate(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model="customer_totals", + dimensions=["customers.regions.name"], + measures=[{"formula": "sum(customers.credit)", "name": "credit"}], + )) + assert cells(response, dim_suffix="regions.name", measure="credit") == { + "North": 300.0, + "South": 300.0, + } + + +async def test_repeating_saved_join_in_extension_is_still_usable(saved_totals_engine): + model = _customer_totals() + response = await saved_totals_engine.execute(_query( + source_model={ + "source_name": "customer_totals", + "joins": [join.model_dump() for join in model.joins], + }, + dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert cells(response, dim_suffix="customers.name", measure="total") == { + "Ann": 30.0, + "Bob": 30.0, + "Cid": 90.0, + } From 45cacec2099234bf8696bdcc8c7b65f26b22d48c Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Wed, 21 Oct 2026 23:44:11 +0200 Subject: [PATCH 2/6] Record query-backed join validation results --- openspec/changes/fix-query-backed-declared-joins/tasks.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openspec/changes/fix-query-backed-declared-joins/tasks.md b/openspec/changes/fix-query-backed-declared-joins/tasks.md index ab9554411..ec0cc42b9 100644 --- a/openspec/changes/fix-query-backed-declared-joins/tasks.md +++ b/openspec/changes/fix-query-backed-declared-joins/tasks.md @@ -13,5 +13,7 @@ ## 3. Validate and review - [x] 3.1 Pass the focused regressions and related query-backed scope/cycle tests. -- [ ] 3.2 Pass the full documented unit, lint, type and architecture pre-push gates. +- [x] 3.2 Run the full documented unit selection, retest environment/timing failures, and pass lint, type and architecture gates. - [x] 3.3 Independently review the implementation and verify the final tree. + +Validation note: the local Python 3.14 unit run reported 31,377 passed, 99 skipped, 7 xfailed and three failures. All three passed isolated retries; the Poetry PATH was corrected for the affected subprocess test, and the timing-sensitive failure also reproduced on unchanged upstream. The documented integration selection passed with 656 passed, 36 skipped and 1 xfailed. CI validates the full suites on Python 3.11 and 3.14. From f88cd8003ad115e5a1b4360cbc9e1843764e62f0 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Thu, 22 Oct 2026 15:00:11 +0200 Subject: [PATCH 3/6] Treat a repeated stored join in any extension as that edge Move the exact-repeat extension-join dedupe from the query-backed-only planner branch into apply_extension_overlay, keyed on a semantic edge identity (target, name, join type, cardinality, pair set), and mirror it in the schema-drift stage graph. Attach saved joins after the stage-model rename with an explicit key check, route the saved-joins lookup through one bundle helper, and shorten the models-guide addition to one sentence. --- docs/concepts/models.md | 2 +- slayer/core/join_edges.py | 10 ++++++++ slayer/engine/plan.py | 12 +++------ slayer/engine/schema_drift.py | 3 +++ slayer/ir/source_bundle.py | 25 +++++++++++++----- tests/test_dev2073_join_edge_identity.py | 22 +++++++++++++++- tests/test_query_backed_declared_joins.py | 31 ++++++++++++++++++++++- tests/test_schema_drift_typed.py | 14 ++++++++++ 8 files changed, 101 insertions(+), 18 deletions(-) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 88bf4157e..bb2244277 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -453,7 +453,7 @@ Or use the saved result as a model in another query: A consumed query-backed model is spliced into the consuming query as stages whose names stay private to it, and one that references itself, directly or through other query-backed models, is rejected. -Joins declared on the saved model belong to its result relation: their keys name the auto-generated output columns. They remain available when another query uses the model as its source or reaches it through a join, so joined filters and aggregates follow those relationships. The backing source model's joins are not inherited; declare the relationships needed by the saved result itself. Ordinary query stages still expose only their projected columns. +Joins declared on a saved query-backed model, keyed on its output columns, stay usable wherever the model is consumed; the backing source model's joins are not inherited. ### Variables in model SQL diff --git a/slayer/core/join_edges.py b/slayer/core/join_edges.py index 7567408da..69ee07eb5 100644 --- a/slayer/core/join_edges.py +++ b/slayer/core/join_edges.py @@ -18,6 +18,7 @@ "relationship_signature", "resolve_join_ref", "resolve_join_refs", + "same_edge", "without_joins", ] @@ -58,6 +59,15 @@ def _pair_set(pairs: list[list[str]]) -> frozenset[tuple[str, str]]: return frozenset((p[0], p[1]) for p in pairs) +def same_edge(a: ModelJoin, b: ModelJoin) -> bool: + """Whether two declarations on one model are one edge: equal in all but metadata and pair order.""" + return ( + (a.target_model, a.name, a.join_type, a.cardinality) + == (b.target_model, b.name, b.join_type, b.cardinality) + and _pair_set(a.join_pairs) == _pair_set(b.join_pairs) + ) + + def _joins_to(model: SlayerModel, target: str) -> list[ModelJoin]: return [j for j in model.joins if j.target_model == target] diff --git a/slayer/engine/plan.py b/slayer/engine/plan.py index c04813e07..d205357af 100644 --- a/slayer/engine/plan.py +++ b/slayer/engine/plan.py @@ -131,15 +131,9 @@ def _stage_scope_and_bundle_unframed( if sib is not None and isinstance(src, ModelExtension): base = model_from_stage_schema( name=sib, schema=stage_schemas[sib], data_source=data_source, - joins=(bundle.query_backed[sib].joins if sib in bundle.query_backed else []), + joins=bundle.saved_joins(sib), ) - extension = src - if sib in bundle.query_backed and src.joins: - # Keep the existing repeated-join workaround idempotent for saved relations. - extension = src.model_copy(update={ - "joins": [join for join in src.joins if join not in base.joins], - }) - overlaid = apply_extension_overlay(base, extension) + overlaid = apply_extension_overlay(base, src) others = {n: s for n, s in stage_schemas.items() if n != sib} sb = stage_bundle_with_siblings( bundle=bundle, source_model=overlaid, @@ -151,7 +145,7 @@ def _stage_scope_and_bundle_unframed( if isinstance(src, str) and src in stage_schemas: synth = model_from_stage_schema( name=src, schema=stage_schemas[src], data_source=data_source, - joins=(bundle.query_backed[src].joins if src in bundle.query_backed else []), + joins=bundle.saved_joins(src), ) others = {n: s for n, s in stage_schemas.items() if n != src} sb = stage_bundle_with_siblings( diff --git a/slayer/engine/schema_drift.py b/slayer/engine/schema_drift.py index bc2c27270..4db89f6d3 100644 --- a/slayer/engine/schema_drift.py +++ b/slayer/engine/schema_drift.py @@ -69,6 +69,7 @@ from slayer.sql.client import SlayerSQLClient, build_sql_model_trial_query from slayer.sql.dialects import dialect_for_ds_type from slayer.sql.engine_factory import EngineCacheKey, _sql_client_cache_key +from slayer.ir.source_bundle import added_joins logger = logging.getLogger(__name__) @@ -770,6 +771,8 @@ def _build_stage_graph( extension_joins = _stage_source_joins(stage) extension_targets = {j.target_model for j in extension_joins} base = models_by_name.get(stage_source_name) if stage_source_name else None + if base is not None: + extension_joins = added_joins(base=base, joins=extension_joins) source = base.model_copy(update={"joins": [*base.joins, *extension_joins]}) if base is not None else None models = {**models_by_name, source.name: source} if source is not None else models_by_name roots = [source] if source is not None else [models[t] for t in sorted(extension_targets) if t in models] diff --git a/slayer/ir/source_bundle.py b/slayer/ir/source_bundle.py index 224785498..935492569 100644 --- a/slayer/ir/source_bundle.py +++ b/slayer/ir/source_bundle.py @@ -11,6 +11,7 @@ from slayer.core.enums import DataType from slayer.core.granularity import CustomGranularity +from slayer.core.join_edges import same_edge from slayer.core.models import ( Column, ModelJoin, @@ -25,6 +26,7 @@ __all__ = [ "resolve_scope", "ResolvedSourceBundle", + "added_joins", "apply_extension_overlay", "as_extension_over_nonsibling", "follow_sibling_chain", @@ -72,6 +74,11 @@ def get_referenced_model(self, name: str) -> Optional[SlayerModel]: return m return None + def saved_joins(self, name: str) -> List[ModelJoin]: + """The declared joins of stored query-backed model ``name``; none for any other stage.""" + saved = self.query_backed.get(name) + return list(saved.joins) if saved is not None else [] + def factor_free(self, model: SlayerModel) -> SlayerModel: """``model`` as stored: a population without its product hops.""" if model.unrelated_datasets: @@ -106,6 +113,11 @@ def models_by_name(self) -> Dict[str, SlayerModel]: return out +def added_joins(*, base: SlayerModel, joins: Optional[List[ModelJoin]]) -> List[ModelJoin]: + """Extension ``joins`` new to ``base``: one repeating a stored join is that edge.""" + return [j for j in joins or [] if not any(same_edge(j, s) for s in base.joins)] + + def apply_extension_overlay( base: SlayerModel, ext: ModelExtension ) -> SlayerModel: @@ -119,7 +131,7 @@ def apply_extension_overlay( update={ "columns": list(base.columns) + list(ext.columns or []), "measures": overlay_measures, - "joins": list(base.joins) + list(ext.joins or []), + "joins": list(base.joins) + added_joins(base=base, joins=ext.joins), } ) # model_copy runs no validators; re-run the namespace and join-key checks @@ -196,8 +208,7 @@ def model_from_stage_schema( composite = len(grain) > 1 column_sql = column_sql or {} model = SlayerModel( - # Validate saved joins at their real identity; minted flat stages need the temporary name. - name=name if joins else "_stage", + name="_stage", data_source=data_source, sql_table="_stage" if sql is None else None, sql=sql, @@ -215,12 +226,15 @@ def model_from_stage_schema( ).with_respellings(c.respellings) for c in schema.columns ], - joins=joins or [], ) + joins = joins or [] + # Joins attach after the rename (validators skip model_copy), so check their keys here. + _check_join_keys(model_name=name, columns=model.columns, joins=joins) # A minted stage identity carries the reserved prefix the name validator rejects. return model.model_copy(update={ "name": name, "sql_table": name if sql is None else None, "default_time_dimension": default_time_dimension or schema.default_time_dimension, + "joins": joins, }).with_spelling(schema.display_name if schema.display_name != name else None) @@ -241,8 +255,7 @@ def stage_bundle_with_siblings( """ synths = [ model_from_stage_schema( - name=n, schema=s, data_source=data_source, - joins=(bundle.query_backed[n].joins if n in bundle.query_backed else []), + name=n, schema=s, data_source=data_source, joins=bundle.saved_joins(n), ) for n, s in sibling_schemas.items() ] diff --git a/tests/test_dev2073_join_edge_identity.py b/tests/test_dev2073_join_edge_identity.py index e14b2ca5e..0fbd6bc9b 100644 --- a/tests/test_dev2073_join_edge_identity.py +++ b/tests/test_dev2073_join_edge_identity.py @@ -6,7 +6,9 @@ import pytest -from slayer.core.join_edges import JoinEdgeRef, edge_reference, relationship_signature, resolve_join_ref +from slayer.core.enums import JoinCardinality, JoinType +from slayer.core.join_edges import JoinEdgeRef, edge_reference, relationship_signature, resolve_join_ref, same_edge +from slayer.core.models import ModelJoin from slayer.engine.query_engine import SlayerQueryEngine from tests._dev2073_fixtures import ( @@ -88,6 +90,24 @@ def test_differs_on_key_pairs(self) -> None: assert billing != shipping +class TestSameEdge: + def test_ignores_metadata_and_pair_order(self) -> None: + stored = ModelJoin(target_model="t", join_pairs=[["a", "x"], ["b", "y"]], description="stored", meta={"k": 1}) + repeat = ModelJoin(target_model="t", join_pairs=[["b", "y"], ["a", "x"]]) + assert same_edge(stored, repeat) + + @pytest.mark.parametrize("change", [ + {"name": "other"}, + {"join_type": JoinType.INNER}, + {"cardinality": JoinCardinality.MANY_TO_ONE}, + {"join_pairs": [["a", "y"]]}, + {"target_model": "u"}, + ]) + def test_any_semantic_field_differing_is_another_edge(self, change: dict) -> None: + stored = ModelJoin(target_model="t", join_pairs=[["a", "x"]]) + assert not same_edge(stored, stored.model_copy(update=change)) + + class TestEngineRemovalByExactReference: async def test_removes_exactly_the_referenced_join(self) -> None: async with model_store(addresses_model(), orders_with(*unnamed_pair())) as storage: diff --git a/tests/test_query_backed_declared_joins.py b/tests/test_query_backed_declared_joins.py index bba944117..94de7d720 100644 --- a/tests/test_query_backed_declared_joins.py +++ b/tests/test_query_backed_declared_joins.py @@ -4,7 +4,15 @@ import pytest -from tests._dev1902_fixtures import cells, make_exec_engine, renamed_graph +from slayer.core.errors import AmbiguousJoinPathError + +from tests._dev1902_fixtures import ( + AMOUNT_BY_CUSTOMER, + cells, + make_exec_engine, + orders_model, + renamed_graph, +) from tests.test_dev1902_join_key_spelling import _customer_totals, _query @@ -51,3 +59,24 @@ async def test_repeating_saved_join_in_extension_is_still_usable(saved_totals_en "Bob": 30.0, "Cid": 90.0, } + + +async def test_repeating_stored_join_in_table_model_extension_is_that_edge(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model={"source_name": "orders", "joins": [ + orders_model().joins[0].model_copy(update={"description": "repeated"}).model_dump()]}, + dimensions=["customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + )) + assert cells(response, dim_suffix="customers.name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_extension_join_differing_from_stored_one_stays_a_parallel_edge(saved_totals_engine): + query = _query( + source_model={"source_name": "orders", "joins": [ + {"target_model": "customers", "join_pairs": [["id", "id"]]}]}, + dimensions=["customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + ) + with pytest.raises(AmbiguousJoinPathError): + await saved_totals_engine.execute(query) diff --git a/tests/test_schema_drift_typed.py b/tests/test_schema_drift_typed.py index 0539b5c93..f2c5debd8 100644 --- a/tests/test_schema_drift_typed.py +++ b/tests/test_schema_drift_typed.py @@ -30,6 +30,7 @@ _build_stage_graph, _filter_refs_dsl, _measure_formula_refs, + _ref_stored_edges, ) from slayer.engine.syntax import ( AggCall, @@ -110,6 +111,19 @@ def test_parallel_extension_targets_unaddressable_by_bare_name(self) -> None: ref="customers.name", base_name="customers", graph=graph, ) is None + def test_extension_repeating_a_stored_join_is_that_stored_edge(self) -> None: + join = ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]]) + models = _ext_models() + models["orders"] = models["orders"].model_copy(update={"joins": [join]}) + graph = _build_stage_graph( + stage=SlayerQuery(source_model=ModelExtension(source_name="orders", joins=[join])), + stage_source_name="orders", models_by_name=models, + ) + assert _attribute_ref_to_base( + ref="customers.name", base_name="customers", graph=graph, + ) == "name" + assert [e.target_model for e in _ref_stored_edges(ref="customers.name", graph=graph)] == ["customers"] + class TestWalkParsedRefs: """The shared reference-node yielder over a ``ParsedExpr`` tree.""" From e6be7f7f4340e6436e1cbdef53844d320d8e186b Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Thu, 22 Oct 2026 15:02:56 +0200 Subject: [PATCH 4/6] Specify extension-join reuse of stored edges Replace design decision 5's saved-relation-only workaround with the one cross-model rule, and add the models/join-traversal delta for it. --- .../fix-query-backed-declared-joins/design.md | 2 +- .../fix-query-backed-declared-joins/proposal.md | 2 ++ .../specs/models/join-traversal/spec.md | 13 +++++++++++++ .../fix-query-backed-declared-joins/tasks.md | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md diff --git a/openspec/changes/fix-query-backed-declared-joins/design.md b/openspec/changes/fix-query-backed-declared-joins/design.md index 01cb99b6d..45cf62bbc 100644 --- a/openspec/changes/fix-query-backed-declared-joins/design.md +++ b/openspec/changes/fix-query-backed-declared-joins/design.md @@ -8,7 +8,7 @@ The public save path validates and persists joins after populating a query-backe 2. Reuse `model_from_stage_schema` to carry optional declared joins for saved final relation names in source, extension and sibling/target paths. Keep StageSchema unchanged. Validate joins against the saved relation's real identity so a legal target named `_stage` is not mistaken for a self-join. 3. Select ModelScope for a materialized saved relation by its identity in the query-backed model map, including relations with incoming reverse edges and no outgoing joins. Ordinary/private query stages retain flat StageSchema binding. 4. Keep demand-based splicing. Merely declaring or reaching an unread query-backed model must not plan it or manufacture a cycle. -5. Reuse the existing logical join-key and extension semantics. Test distinct/named extension joins. Preserve the existing workaround that repeats an identical stored join on a saved-relation extension by applying that exact edge once; avoid global join de-duplication or graph redesign. +5. Reuse the existing logical join-key and extension semantics. Test distinct/named extension joins. An extension join equal to one of its base model's stored joins in all but metadata and pair order is that stored edge, applied once, for every model kind; the runtime overlay and the schema-drift stage graph share this one rule. A join differing in target, name, join type, cardinality or pairs stays a separate edge. ## Validation diff --git a/openspec/changes/fix-query-backed-declared-joins/proposal.md b/openspec/changes/fix-query-backed-declared-joins/proposal.md index 8a3eba3c7..1fb6cec52 100644 --- a/openspec/changes/fix-query-backed-declared-joins/proposal.md +++ b/openspec/changes/fix-query-backed-declared-joins/proposal.md @@ -8,12 +8,14 @@ A saved query-backed model's declared joins disappear when its final stage becom - Bind saved relations as model scopes, keeping ordinary query-stage outputs flat. - Add executable SQLite/DuckDB tests for filtered totals, traversal and aggregate attribution. - Preserve existing demand splicing, private stage scopes, cycle errors and extension behavior. +- Treat an extension join repeating a stored join of its base as that edge, for every model kind. ## Capabilities ### Modified Capabilities - `queries/query-backed-inline`: consuming a saved relation retains its own declared relationships. +- `models/join-traversal`: an extension join repeating a stored edge is that edge. ## Impact diff --git a/openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md b/openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md new file mode 100644 index 000000000..4bf830856 --- /dev/null +++ b/openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: An extension join repeating a stored edge is that edge + +A query-level model extension's join that equals one of its base model's stored joins in every field except `description`, `meta` and pair order SHALL be that stored edge, applied once, for every model kind; an extension join differing in target, name, join type, cardinality or join pairs SHALL remain a separate edge. + +#### Scenario: Repeating a stored join in an extension +- **WHEN** an extension over a table-backed model repeats a stored join and a query reads through it +- **THEN** the query resolves over that single edge instead of failing as an ambiguous hop + +#### Scenario: A differing extension join stays parallel +- **WHEN** an extension adds an unnamed join to the same target with different join pairs +- **THEN** a bare hop to that target fails closed as ambiguous diff --git a/openspec/changes/fix-query-backed-declared-joins/tasks.md b/openspec/changes/fix-query-backed-declared-joins/tasks.md index ec0cc42b9..38b37d05c 100644 --- a/openspec/changes/fix-query-backed-declared-joins/tasks.md +++ b/openspec/changes/fix-query-backed-declared-joins/tasks.md @@ -9,6 +9,7 @@ - [x] 2.1 Preserve declared joins through saved-relation placeholder, root/extension and sibling/target materialization. - [x] 2.2 Verify logical-key traversal, aggregate attribution, extension compatibility, ordinary-stage isolation and cycle behavior. - [x] 2.3 Clarify saved-relation joins in the user models guide. +- [x] 2.4 Treat an extension join repeating a stored join as that edge for every model kind, in the runtime overlay and the schema-drift stage graph. ## 3. Validate and review From 57e6a8bd5a08aec7132c199e0b350b740e0a1500 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Thu, 22 Oct 2026 15:29:18 +0200 Subject: [PATCH 5/6] Skip the DuckDB httpfs notebook when the CDN file changes mid-read DuckDB reports an ETag change across CDN edges as 'this likely means the remote file has changed'; classify it as a transient outage of the named remote host so the notebook skips instead of failing. --- tests/integration/test_notebooks.py | 1 + tests/test_notebook_network_guard.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/integration/test_notebooks.py b/tests/integration/test_notebooks.py index 800a93257..fa7a92b6f 100644 --- a/tests/integration/test_notebooks.py +++ b/tests/integration/test_notebooks.py @@ -166,6 +166,7 @@ def _bootstrap_failure_is_transient(error_text: str) -> bool: r"broken pipe", r"could not establish", r"failed to (?:connect|download)", + r"remote file has changed", # CDN edges serving different ETags mid-read ) # httpfs transport errors DuckDB emits without naming the URL, but which can only diff --git a/tests/test_notebook_network_guard.py b/tests/test_notebook_network_guard.py index 9514be78a..5a1a505df 100644 --- a/tests/test_notebook_network_guard.py +++ b/tests/test_notebook_network_guard.py @@ -24,6 +24,8 @@ f"HTTP Error: HTTP GET error on '{_URL}' (HTTP 503 Service Unavailable)", f"IO Error: Could not resolve host: {_HOST}", "curl: (22) The requested URL returned error: 403 install.duckdb.org", + f'HTTP Error: ETag on reading file "{_URL}" was initially W/"a" and now it returned W/"b", ' + "this likely means the remote file has changed.", ], ) def test_external_host_outage_is_transient(text): @@ -55,6 +57,7 @@ def test_hostless_httpfs_transport_error_is_transient(text): "curl: (22) The requested URL returned error: 404 install.duckdb.org", # A transient status but no external host named — not a classifiable outage. "HTTP Error: HTTP GET error on 'https://example.internal/x' (HTTP 503)", + "IO Error: this likely means the remote file has changed (no external host named)", ], ) def test_real_bugs_are_not_transient(text): From 535efd3f8e1539eadd05da864c98a3def7b9390e Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Thu, 22 Oct 2026 15:46:26 +0200 Subject: [PATCH 6/6] Archive fix-query-backed-declared-joins OpenSpec change --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/models/join-traversal/spec.md | 0 .../specs/queries/query-backed-inline/spec.md | 0 .../tasks.md | 0 openspec/specs/models/join-traversal/spec.md | 12 +++++++ .../specs/queries/query-backed-inline/spec.md | 32 +++++++++++++++++++ 8 files changed, 44 insertions(+) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/.openspec.yaml (100%) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/design.md (100%) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/proposal.md (100%) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/specs/models/join-traversal/spec.md (100%) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/specs/queries/query-backed-inline/spec.md (100%) rename openspec/changes/{fix-query-backed-declared-joins => archive/2026-10-22-fix-query-backed-declared-joins}/tasks.md (100%) diff --git a/openspec/changes/fix-query-backed-declared-joins/.openspec.yaml b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/.openspec.yaml similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/.openspec.yaml rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/.openspec.yaml diff --git a/openspec/changes/fix-query-backed-declared-joins/design.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/design.md similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/design.md rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/design.md diff --git a/openspec/changes/fix-query-backed-declared-joins/proposal.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/proposal.md similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/proposal.md rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/proposal.md diff --git a/openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/models/join-traversal/spec.md similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/specs/models/join-traversal/spec.md rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/models/join-traversal/spec.md diff --git a/openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md diff --git a/openspec/changes/fix-query-backed-declared-joins/tasks.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/tasks.md similarity index 100% rename from openspec/changes/fix-query-backed-declared-joins/tasks.md rename to openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/tasks.md diff --git a/openspec/specs/models/join-traversal/spec.md b/openspec/specs/models/join-traversal/spec.md index e38ba9081..316c0ba0c 100644 --- a/openspec/specs/models/join-traversal/spec.md +++ b/openspec/specs/models/join-traversal/spec.md @@ -323,3 +323,15 @@ directed cycle SHALL fail closed when every candidate root is blocked by a fanni #### Scenario: Rootless query across a directed 3-cycle fails closed - **WHEN** a query without `source_model` selects `x.v`, `y.v` and `z.v` over `x → y → z → x` - **THEN** it fails with the no-viable-candidate error, and succeeds once `source_model` is given + +### Requirement: An extension join repeating a stored edge is that edge + +A query-level model extension's join that equals one of its base model's stored joins in every field except `description`, `meta` and pair order SHALL be that stored edge, applied once, for every model kind; an extension join differing in target, name, join type, cardinality or join pairs SHALL remain a separate edge. + +#### Scenario: Repeating a stored join in an extension +- **WHEN** an extension over a table-backed model repeats a stored join and a query reads through it +- **THEN** the query resolves over that single edge instead of failing as an ambiguous hop + +#### Scenario: A differing extension join stays parallel +- **WHEN** an extension adds an unnamed join to the same target with different join pairs +- **THEN** a bare hop to that target fails closed as ambiguous diff --git a/openspec/specs/queries/query-backed-inline/spec.md b/openspec/specs/queries/query-backed-inline/spec.md index c6ef3fadc..ee0a946ff 100644 --- a/openspec/specs/queries/query-backed-inline/spec.md +++ b/openspec/specs/queries/query-backed-inline/spec.md @@ -123,3 +123,35 @@ cycle. A planning failure unrelated to a cycle SHALL keep its own error. #### Scenario: Direct and transitive cycles - **WHEN** `A` consumes `A`, or `A` consumes `B` which consumes `A`, and a consumer of `A` executes, `A` runs by name, `A` is saved, or `A`'s column types are requested - **THEN** each raises the same cycle error naming the path + +### Requirement: Saved relation relationships survive splicing + +A stored query-backed model consumed as a relation SHALL retain that model's own declared joins, with keys resolved against its final-stage output columns. Those relationships SHALL remain available when the relation is a source, a join target or an extended source. Ordinary query-stage outputs SHALL remain flat and SHALL NOT inherit joins from their backing source. + +#### Scenario: Joined segment filter on a saved relation +- **WHEN** a saved customer-total relation declares a customer-key join and a query filters to gold customers +- **THEN** only the related gold customers contribute, with the same values as an equivalent relationship declared solely on a query extension, on SQLite and DuckDB + +#### Scenario: Joined dimensions and aggregate attribution +- **WHEN** a query reads a dimension or aggregate through a saved relation's declared join +- **THEN** the relationship determines the rows and attribution instead of being treated as an unrelated dataset + +#### Scenario: A saved relation consumed through another model +- **WHEN** another model joins a saved query-backed relation and a query traverses onward through that relation's own join +- **THEN** the declared relationships remain available with logical key spelling and correct values + +#### Scenario: Extending a saved relation +- **WHEN** a query extension adds a distinct named join to a saved relation +- **THEN** both its stored joins and the extension's joins remain available + +#### Scenario: Repeating an existing saved relationship +- **WHEN** an extension over a saved query-backed relation repeats an exactly identical declared join +- **THEN** that edge is applied once, without introducing an ambiguous parallel path + +#### Scenario: Legal join targets do not collide with temporary identities +- **WHEN** a saved relation declares a join to a legal model named `_stage` +- **THEN** both unjoined and joined consumers execute without a false self-join error + +#### Scenario: Ordinary stage isolation +- **WHEN** an ordinary query stage is consumed by a later stage +- **THEN** only its projected output schema is visible and its backing source's joins are not inherited