diff --git a/src/sqlbuild/compiler/compile/_helpers/config/retention.py b/src/sqlbuild/compiler/compile/_helpers/config/retention.py index ebd2862754..8ab610ebaf 100644 --- a/src/sqlbuild/compiler/compile/_helpers/config/retention.py +++ b/src/sqlbuild/compiler/compile/_helpers/config/retention.py @@ -29,15 +29,16 @@ def resolve_time_travel_retention( ) -> ResolvedTimeTravelRetention: """Resolve target, materialization, and model retention precedence.""" + table_backed: bool = isinstance(materialized, str) and MaterializationType.is_table_backed( + materialized=materialized + ) policy: AuthoredTimeTravelRetention | None = ( - target_config.time_travel_retention if target_config is not None else None + target_config.time_travel_retention if table_backed and target_config is not None else None ) source: TimeTravelRetentionSource | None = ( TimeTravelRetentionSource.TARGET if policy is not None else None ) - if isinstance(materialized, str) and MaterializationType.is_table_backed( - materialized=materialized - ): + if table_backed and isinstance(materialized, str): materialization_policy: AuthoredTimeTravelRetention | None = getattr( materialization_defaults, materialized ).time_travel_retention diff --git a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py index bb5568f98a..d8066b1362 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py @@ -569,3 +569,11 @@ class DenseCompileFixtureTestCase: model_count: int expected_rule_misses: int expected_tail_lineage_edges: int + + +@dataclass(frozen=True) +class TargetRetentionViewsTestCase: + description: str + view_header: str + expected_exit_code: int + expected_fragment: str diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_target_retention_views.py b/tests/integration/src/sqlbuild/cli/commands/main/test_target_retention_views.py new file mode 100644 index 0000000000..c9a273d56a --- /dev/null +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_target_retention_views.py @@ -0,0 +1,61 @@ +"""Integration coverage for target-level retention defaults with views.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sqlbuild.cli.commands.main.entrypoint.entry import main +from tests.integration.src.sqlbuild.cli.commands.main._test_types import ( + TargetRetentionViewsTestCase, +) + +_PROJECT_TOML: str = ( + 'name = "demo"\nadapter = "duckdb"\ndefault_target = "prod"\n\n' + '[connection]\ndatabase = "demo.duckdb"\n\n' + '[targets.prod]\ntime_travel_retention = "90d"\n' +) + + +@pytest.mark.parametrize( + "test_case", + [ + TargetRetentionViewsTestCase( + description="view skips inherited target retention", + view_header="MODEL (materialized view);", + expected_exit_code=0, + expected_fragment="Project compiled", + ), + TargetRetentionViewsTestCase( + description="explicit view retention is still rejected", + view_header="MODEL (materialized view, time_travel_retention 7d);", + expected_exit_code=1, + expected_fragment="managed time_travel_retention is not valid for views", + ), + ], + ids=lambda case: case.description, +) +def test_given_target_retention_default_when_compiling_views_then_only_explicit_retention_fails( + test_case: TargetRetentionViewsTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / "models").mkdir() + (tmp_path / "sqlbuild_project.toml").write_text(_PROJECT_TOML, encoding="utf-8") + (tmp_path / "models" / "orders.sql").write_text( + "MODEL (materialized table);\n\nSELECT 1 AS order_id\n", encoding="utf-8" + ) + (tmp_path / "models" / "order_view.sql").write_text( + f'{test_case.view_header}\n\nSELECT order_id FROM __ref("orders")\n', encoding="utf-8" + ) + + exit_code: int = main(["--project-dir", str(tmp_path), "--no-color", "compile"]) + captured: str = "".join(capsys.readouterr()) + + assert exit_code == test_case.expected_exit_code, captured + assert test_case.expected_fragment in captured + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_test_types.py b/tests/unit/src/sqlbuild/compiler/compile/_test_types.py index f7a072a9ca..6372dc5b17 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_test_types.py @@ -92,7 +92,7 @@ class BuildModelRetentionConfigTestCase: materialization_defaults: MaterializationDefaultsConfig expected_desired_days: int | None expected_unmanaged: bool - expected_source: TimeTravelRetentionSource + expected_source: TimeTravelRetentionSource | None @dataclass(frozen=True) diff --git a/tests/unit/src/sqlbuild/compiler/compile/test_main.py b/tests/unit/src/sqlbuild/compiler/compile/test_main.py index 80b7aec68e..24130d0675 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/test_main.py +++ b/tests/unit/src/sqlbuild/compiler/compile/test_main.py @@ -4596,6 +4596,30 @@ def test_given_typed_hook_defaults_when_building_config_then_layers_values( expected_unmanaged=True, expected_source=TimeTravelRetentionSource.MODEL, ), + BuildModelRetentionConfigTestCase( + description="incremental model inherits target retention", + defaults=DefaultsConfig(materialized="incremental"), + model_header_values={}, + target_config=TargetConfig( + time_travel_retention=AuthoredTimeTravelRetention(desired_days=90) + ), + materialization_defaults=MaterializationDefaultsConfig(), + expected_desired_days=90, + expected_unmanaged=False, + expected_source=TimeTravelRetentionSource.TARGET, + ), + BuildModelRetentionConfigTestCase( + description="view does not inherit target retention", + defaults=DefaultsConfig(materialized="view"), + model_header_values={}, + target_config=TargetConfig( + time_travel_retention=AuthoredTimeTravelRetention(desired_days=90) + ), + materialization_defaults=MaterializationDefaultsConfig(), + expected_desired_days=None, + expected_unmanaged=True, + expected_source=None, + ), ], ids=lambda case: case.description, )