diff --git a/src/sqlbuild/cli/commands/_helpers/build_planning/planning.py b/src/sqlbuild/cli/commands/_helpers/build_planning/planning.py index 4379a83e96..6d00103e64 100644 --- a/src/sqlbuild/cli/commands/_helpers/build_planning/planning.py +++ b/src/sqlbuild/cli/commands/_helpers/build_planning/planning.py @@ -50,6 +50,7 @@ def compile_build_plan( resolve_python_run_selectors=(request.include_python or invocation.should_load_sources), max_microbatches=request.max_microbatches, selection_diagnostics=request.selection_diagnostics, + plan_sql_tests=request.run_tests, ), hooks=ConnectionHooks( on_progress=invocation.planning_progress.on_progress, diff --git a/src/sqlbuild/compiler/pipeline/main/compile.py b/src/sqlbuild/compiler/pipeline/main/compile.py index c9a02990ab..14feaead0a 100644 --- a/src/sqlbuild/compiler/pipeline/main/compile.py +++ b/src/sqlbuild/compiler/pipeline/main/compile.py @@ -232,6 +232,7 @@ def _build_result( policies=PlannerPolicies( auto_load_sources=options.auto_load_sources, selection_diagnostics=options.selection_diagnostics, + plan_sql_tests=options.plan_sql_tests, ), on_progress=on_progress, project_config=discovered_inputs.project_config, diff --git a/src/sqlbuild/compiler/pipeline/models.py b/src/sqlbuild/compiler/pipeline/models.py index f817449de2..6198d29e43 100644 --- a/src/sqlbuild/compiler/pipeline/models.py +++ b/src/sqlbuild/compiler/pipeline/models.py @@ -60,6 +60,7 @@ class CompilePipelineOptions: no_cache: bool = False max_microbatches: int | None = None selection_diagnostics: bool = False + plan_sql_tests: bool = True @dataclass(frozen=True) diff --git a/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py b/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py index 6d9b7422bf..c6eeb4a366 100644 --- a/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py +++ b/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py @@ -47,6 +47,7 @@ FunctionChangeResult, FunctionPlanEntry, ModelPlanEntry, + PlannedSqlTests, PlannerChangeResults, PlannerModelEntryResults, PlannerRelationsContext, @@ -133,13 +134,13 @@ def build_plan_output( relations=relations, model_materializations=model_materializations, ) - test_entries: list[SqlTestPlanEntry] - test_warnings: list[PlanWarning] - test_entries, test_warnings = build_selected_test_entries( - project=project, - adapter=adapter, - selected_keys=scope.selected_keys, - ) + planned_tests: PlannedSqlTests | None = resolved_extras.planned_sql_tests + if planned_tests is None or planned_tests.selected_keys != scope.selected_keys: + planned_tests = plan_selected_sql_tests( + project=project, adapter=adapter, selected_keys=scope.selected_keys + ) + test_entries: list[SqlTestPlanEntry] = list(planned_tests.entries) + test_warnings: list[PlanWarning] = list(planned_tests.warnings) selected_test_keys: frozenset[CompiledObjectKey] = frozenset( entry.key for entry in test_entries ) @@ -439,6 +440,24 @@ def build_selected_audit_entries( return entries +def plan_selected_sql_tests( + *, + project: CompiledProject, + adapter: BaseAdapter, + selected_keys: frozenset[CompiledObjectKey], +) -> PlannedSqlTests: + """Plan SQL tests overlapping one selection into a reusable result.""" + + entries: list[SqlTestPlanEntry] + warnings: list[PlanWarning] + entries, warnings = build_selected_test_entries( + project=project, adapter=adapter, selected_keys=selected_keys + ) + return PlannedSqlTests( + selected_keys=selected_keys, entries=tuple(entries), warnings=tuple(warnings) + ) + + def build_selected_test_entries( *, project: CompiledProject, diff --git a/src/sqlbuild/compiler/planner/_helpers/planning/output_assembly.py b/src/sqlbuild/compiler/planner/_helpers/planning/output_assembly.py index b18331c4bc..f179e64aca 100644 --- a/src/sqlbuild/compiler/planner/_helpers/planning/output_assembly.py +++ b/src/sqlbuild/compiler/planner/_helpers/planning/output_assembly.py @@ -6,10 +6,12 @@ from sqlbuild.compiler.compile.types import CompiledResourceType from sqlbuild.compiler.planner._helpers.output.plan_output import build_plan_output +from sqlbuild.compiler.planner._helpers.planning.retention import plan_retention, plan_table_types from sqlbuild.compiler.planner._helpers.pruning.selection_staleness import ( build_stale_out_of_selection_warnings, ) from sqlbuild.compiler.planner.models import ( + PlannedSqlTests, PlannerChangeReconciliation, PlannerChangeResults, PlannerEntryResults, @@ -39,6 +41,7 @@ def assemble_base_plan_output( reconciliation: PlannerChangeReconciliation, entries: PlannerEntryResults, source_freshness: DirectSourceFreshnessPlanningResult, + planned_sql_tests: PlannedSqlTests | None = None, ) -> PlanOutput: """Assemble the base plan output with freshness and pruning metadata attached.""" @@ -54,6 +57,7 @@ def assemble_base_plan_output( extras=PlanOutputExtras( seed_version_hashes=identities.version_identities.seed_version_hashes, seed_metadata_jsons=identities.version_identities.seed_metadata_jsons, + planned_sql_tests=planned_sql_tests, ), ) plan_output = replace(plan_output, source_freshness=source_freshness) @@ -68,6 +72,26 @@ def assemble_base_plan_output( return plan_output +def with_storage_policies( + *, + plan_output: PlanOutput, + runtime: PlannerRuntime, + warehouse: PlannerWarehouseState, + scopes: PlannerScopeResolution, +) -> PlanOutput: + """Attach selected Snowflake table-type and retention work to the plan.""" + + return replace( + plan_output, + table_type_entries=plan_table_types( + runtime=runtime, warehouse=warehouse, scope=scopes.selected_scope + ), + retention_entries=plan_retention( + runtime=runtime, warehouse=warehouse, scope=scopes.selected_scope + ), + ) + + def with_plan_warnings( *, runtime: PlannerRuntime, diff --git a/src/sqlbuild/compiler/planner/classes/background_sql_test_planning.py b/src/sqlbuild/compiler/planner/classes/background_sql_test_planning.py new file mode 100644 index 0000000000..c66c3d28ab --- /dev/null +++ b/src/sqlbuild/compiler/planner/classes/background_sql_test_planning.py @@ -0,0 +1,76 @@ +"""Plan SQL tests on a worker thread while the planner waits on the warehouse.""" + +from __future__ import annotations + +import threading +from concurrent.futures import Future +from types import TracebackType + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.compile.models import CompiledObjectKey, CompiledProject +from sqlbuild.compiler.planner._helpers.output.plan_output import plan_selected_sql_tests +from sqlbuild.compiler.planner.models import PlannedSqlTests + + +class BackgroundSqlTestPlanning: + """Start warehouse-independent SQL test planning early and join it before assembly.""" + + def __init__( + self, + *, + project: CompiledProject, + adapter: BaseAdapter, + selected_keys: frozenset[CompiledObjectKey], + enabled: bool, + ) -> None: + self._future: Future[PlannedSqlTests] | None = None + if enabled: + future: Future[PlannedSqlTests] = Future() + self._future = future + threading.Thread( + target=_plan_into, + kwargs={ + "future": future, + "project": project, + "adapter": adapter, + "selected_keys": selected_keys, + }, + name="sqlbuild-test-planning", + daemon=True, + ).start() + self._selected_keys: frozenset[CompiledObjectKey] = selected_keys + + def __enter__(self) -> BackgroundSqlTestPlanning: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exception_type, exception, traceback + + def result(self) -> PlannedSqlTests: + """Return planned tests, raising any planning error at the original assembly point.""" + + if self._future is None: + return PlannedSqlTests(selected_keys=self._selected_keys) + return self._future.result() + + +def _plan_into( + *, + future: Future[PlannedSqlTests], + project: CompiledProject, + adapter: BaseAdapter, + selected_keys: frozenset[CompiledObjectKey], +) -> None: + """Run planning on a daemon thread so a failed plan never waits for it at exit.""" + + try: + future.set_result( + plan_selected_sql_tests(project=project, adapter=adapter, selected_keys=selected_keys) + ) + except BaseException as error: # noqa: BLE001 - re-raised on the joining thread + future.set_exception(error) diff --git a/src/sqlbuild/compiler/planner/main/execution/execution.py b/src/sqlbuild/compiler/planner/main/execution/execution.py index d5e9304ba6..70fe0cb496 100644 --- a/src/sqlbuild/compiler/planner/main/execution/execution.py +++ b/src/sqlbuild/compiler/planner/main/execution/execution.py @@ -4,7 +4,6 @@ import time from collections.abc import Callable -from dataclasses import replace from typing import Any from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter @@ -22,9 +21,9 @@ assemble_base_plan_output, with_plan_metadata, with_plan_warnings, + with_storage_policies, ) from sqlbuild.compiler.planner._helpers.planning.reconciliation import reconcile_execution_changes -from sqlbuild.compiler.planner._helpers.planning.retention import plan_retention, plan_table_types from sqlbuild.compiler.planner._helpers.planning.scope_pruning import prune_planner_execution_scope from sqlbuild.compiler.planner._helpers.planning.scopes import resolve_planner_scopes from sqlbuild.compiler.planner._helpers.planning.warehouse_state import ( @@ -34,8 +33,12 @@ from sqlbuild.compiler.planner._helpers.warehouse.source_freshness import ( build_planner_source_freshness_result, ) +from sqlbuild.compiler.planner.classes.background_sql_test_planning import ( + BackgroundSqlTestPlanning, +) from sqlbuild.compiler.planner.models import ( DeferralInputs, + PlannedSqlTests, PlannerChangeReconciliation, PlannerChangeResults, PlannerEntryResults, @@ -81,106 +84,110 @@ def build_execution_plan( selection=selection, policies=policies, ) - warehouse: PlannerWarehouseState = gather_planner_warehouse_state( - runtime=runtime, - scopes=scopes, - overrides=overrides, - deferral=deferral, - ) - plan_start: float = time.monotonic() - identities: PlannerIdentityContext = build_planner_identity_context( - project=project, - scopes=scopes, - include_stale_warning_identities=policies.selection_diagnostics, - ) - check_selected_scope_buildability( - project=project, - scopes=scopes, - snapshot=warehouse.snapshot, - deferral=deferral, - ) - changes: PlannerChangeResults - stale_warning_changes: PlannerChangeResults - changes, stale_warning_changes = _detect_planner_change_results( - project=project, - scopes=scopes, - snapshot=warehouse.snapshot, - identities=identities, - overrides=overrides, - policies=policies, - ) - resolved_actions: PlannerResolvedActions = resolve_cascades( - scope=scopes.inspection_scope, - changes=changes, - ) - source_freshness: DirectSourceFreshnessPlanningResult = build_planner_source_freshness_result( + with BackgroundSqlTestPlanning( project=project, adapter=adapter, - connection=connection, - scope=scopes.inspection_scope, - relations=warehouse.inspection_relations, - freshness_state_schemas=warehouse.snapshot.source_freshness_state_schemas, - ) - pruning: PlannerScopePruningResult = prune_planner_execution_scope( - scopes=scopes, - resolved_actions=resolved_actions, - ) - reconciliation: PlannerChangeReconciliation = reconcile_execution_changes( - warehouse=warehouse, - identities=identities, - pruning=pruning, - changes=changes, - ) - entries: PlannerEntryResults = build_planner_entry_results( - runtime=runtime, - warehouse=warehouse, - identities=identities, - overrides=overrides, - policies=policies, - deferral=deferral, - pruning=pruning, - reconciliation=reconciliation, - source_freshness=source_freshness, - ) - plan_output: PlanOutput = assemble_base_plan_output( - runtime=runtime, - warehouse=warehouse, - identities=identities, - overrides=overrides, - pruning=pruning, - reconciliation=reconciliation, - entries=entries, - source_freshness=source_freshness, - ) - plan_output = replace( - plan_output, - table_type_entries=plan_table_types( - runtime=runtime, warehouse=warehouse, scope=scopes.selected_scope - ), - retention_entries=plan_retention( - runtime=runtime, warehouse=warehouse, scope=scopes.selected_scope - ), - ) - plan_output = with_plan_warnings( - runtime=runtime, - scopes=scopes, - warehouse=warehouse, - identities=identities, - stale_warning_changes=stale_warning_changes, - pruning=pruning, - source_freshness=source_freshness, - plan_output=plan_output, - policies=policies, - ) - plan_output = with_plan_metadata( - plan_output=plan_output, - pruning=pruning, - source_freshness=source_freshness, - policies=policies, - ) - if on_progress is not None: - on_progress(f"Generated plan. ({time.monotonic() - plan_start:.2f}s)") - return plan_output + selected_keys=scopes.inspection_scope.selected_keys, + enabled=policies.plan_sql_tests, + ) as test_planning: + warehouse: PlannerWarehouseState = gather_planner_warehouse_state( + runtime=runtime, + scopes=scopes, + overrides=overrides, + deferral=deferral, + ) + plan_start: float = time.monotonic() + identities: PlannerIdentityContext = build_planner_identity_context( + project=project, + scopes=scopes, + include_stale_warning_identities=policies.selection_diagnostics, + ) + check_selected_scope_buildability( + project=project, + scopes=scopes, + snapshot=warehouse.snapshot, + deferral=deferral, + ) + changes: PlannerChangeResults + stale_warning_changes: PlannerChangeResults + changes, stale_warning_changes = _detect_planner_change_results( + project=project, + scopes=scopes, + snapshot=warehouse.snapshot, + identities=identities, + overrides=overrides, + policies=policies, + ) + resolved_actions: PlannerResolvedActions = resolve_cascades( + scope=scopes.inspection_scope, + changes=changes, + ) + source_freshness: DirectSourceFreshnessPlanningResult = ( + build_planner_source_freshness_result( + project=project, + adapter=adapter, + connection=connection, + scope=scopes.inspection_scope, + relations=warehouse.inspection_relations, + freshness_state_schemas=warehouse.snapshot.source_freshness_state_schemas, + ) + ) + pruning: PlannerScopePruningResult = prune_planner_execution_scope( + scopes=scopes, + resolved_actions=resolved_actions, + ) + reconciliation: PlannerChangeReconciliation = reconcile_execution_changes( + warehouse=warehouse, + identities=identities, + pruning=pruning, + changes=changes, + ) + entries: PlannerEntryResults = build_planner_entry_results( + runtime=runtime, + warehouse=warehouse, + identities=identities, + overrides=overrides, + policies=policies, + deferral=deferral, + pruning=pruning, + reconciliation=reconciliation, + source_freshness=source_freshness, + ) + planned_sql_tests: PlannedSqlTests = test_planning.result() + plan_output: PlanOutput = assemble_base_plan_output( + runtime=runtime, + warehouse=warehouse, + identities=identities, + overrides=overrides, + pruning=pruning, + reconciliation=reconciliation, + entries=entries, + source_freshness=source_freshness, + planned_sql_tests=planned_sql_tests, + ) + plan_output = with_storage_policies( + plan_output=plan_output, runtime=runtime, warehouse=warehouse, scopes=scopes + ) + plan_output = with_plan_warnings( + runtime=runtime, + scopes=scopes, + warehouse=warehouse, + identities=identities, + stale_warning_changes=stale_warning_changes, + pruning=pruning, + source_freshness=source_freshness, + plan_output=plan_output, + policies=policies, + ) + plan_output = with_plan_metadata( + plan_output=plan_output, + pruning=pruning, + source_freshness=source_freshness, + policies=policies, + ) + if on_progress is not None: + on_progress(f"Generated plan. ({time.monotonic() - plan_start:.2f}s)") + return plan_output def _detect_planner_change_results( diff --git a/src/sqlbuild/compiler/planner/main/execution/plan_entry.py b/src/sqlbuild/compiler/planner/main/execution/plan_entry.py index 275c23dcb0..93e63bf95f 100644 --- a/src/sqlbuild/compiler/planner/main/execution/plan_entry.py +++ b/src/sqlbuild/compiler/planner/main/execution/plan_entry.py @@ -19,6 +19,7 @@ DeferralInputs, ModelChangesPlanInputs, PlanEntryBuildInputs, + PlannedSqlTests, PlannerChangeResults, PlannerModelEntryResults, PlannerRelationsContext, @@ -115,5 +116,10 @@ def build_plan_output_from_model_changes_phase( seed_version_hashes=resolved.seed_version_hashes, seed_metadata_jsons=resolved.seed_metadata_jsons, seed_plan_reasons=resolved.seed_plan_reasons, + planned_sql_tests=( + None + if resolved.plan_sql_tests + else PlannedSqlTests(selected_keys=scope.selected_keys) + ), ), ) diff --git a/src/sqlbuild/compiler/planner/models.py b/src/sqlbuild/compiler/planner/models.py index 3b4ad820f5..6f20ebad53 100644 --- a/src/sqlbuild/compiler/planner/models.py +++ b/src/sqlbuild/compiler/planner/models.py @@ -848,6 +848,7 @@ class ModelChangesPlanInputs: seed_metadata_jsons: dict[str, str] | None = None seed_plan_reasons: dict[str, PlanReason] | None = None max_microbatches: int | None = None + plan_sql_tests: bool = True @dataclass(frozen=True) @@ -980,11 +981,21 @@ class TableTypePlanEntry: @dataclass(frozen=True) class PlanOutputExtras: - """Optional supplemental seed fingerprints for plan output assembly.""" + """Optional supplemental seed fingerprints and precomputed SQL tests for plan assembly.""" seed_version_hashes: dict[str, str] | None = None seed_metadata_jsons: dict[str, str] | None = None seed_plan_reasons: dict[str, PlanReason] | None = None + planned_sql_tests: PlannedSqlTests | None = None + + +@dataclass(frozen=True) +class PlannedSqlTests: + """SQL test entries planned for one selection; reusable only for the same selected keys.""" + + selected_keys: frozenset[CompiledObjectKey] + entries: tuple[SqlTestPlanEntry, ...] = () + warnings: tuple[PlanWarning, ...] = () @dataclass(frozen=True) @@ -1418,6 +1429,7 @@ class PlannerPolicies: auto_load_sources: bool = False selection_diagnostics: bool = False + plan_sql_tests: bool = True @dataclass(frozen=True) diff --git a/src/sqlbuild/virtual/executor/_helpers/build.py b/src/sqlbuild/virtual/executor/_helpers/build.py index 4cf80b276a..cd893bc423 100644 --- a/src/sqlbuild/virtual/executor/_helpers/build.py +++ b/src/sqlbuild/virtual/executor/_helpers/build.py @@ -936,6 +936,7 @@ def _plan_virtual_build( seed_metadata_jsons=reads.semantics.seed_identity_metadata_jsons, seed_plan_reasons=reads.semantics.seed_plan_reasons, max_microbatches=options.planning.max_microbatches, + plan_sql_tests=options.run_tests, ), ) finally: 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 36f29da476..ad41066a02 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py @@ -593,3 +593,11 @@ class TargetMaterializationRetentionTestCase: description: str target_lines: tuple[str, ...] expected_requested_days: frozenset[int] + + +@dataclass(frozen=True) +class BuildTestPlanningTestCase: + description: str + build_flags: tuple[str, ...] + expected_exit_code: int + expected_fragment: str diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_build_test_planning.py b/tests/integration/src/sqlbuild/cli/commands/main/test_build_test_planning.py new file mode 100644 index 0000000000..b6f7e6f962 --- /dev/null +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_build_test_planning.py @@ -0,0 +1,129 @@ +"""Integration coverage for SQL test planning during real builds.""" + +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 BuildTestPlanningTestCase + +_PROJECT_FILES: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "demo"\nadapter = "duckdb"\n\n[connection]\ndatabase = "demo.duckdb"\n' + ), + "models/orders.sql": ( + "MODEL (\n" + " materialized table,\n" + " contract enforced,\n" + " columns (\n" + " order_id (type INTEGER, nullable false),\n" + " amount (type INTEGER),\n" + " ),\n" + ");\n\n" + "SELECT 1 AS order_id, 5 AS amount\n" + ), + "models/order_totals.sql": ( + 'MODEL (materialized table);\n\nSELECT order_id, amount * 2 AS doubled FROM __ref("orders")\n' + ), + "tests/unit/test_order_totals.sql": ( + "TEST ();\nWITH\n" + "__ref__orders AS (SELECT 1 AS order_idd, 5 AS amount),\n" + "__expected__order_totals AS (SELECT 1 AS order_id, 10 AS doubled)\n" + "SELECT 1\n" + ), +} + +_VIRTUAL_PROJECT_TOML: str = ( + 'name = "demo"\nadapter = "duckdb"\ndefault_target = "dev"\n\n' + "[settings]\nvirtual_environments = true\n\n" + '[connection]\ndatabase = "warehouse.duckdb"\n\n' + '[targets.dev]\nschema = "dev"\n\n' + '[targets.dev.state]\nbackend = "duckdb"\nschema = "sqlbuild_state"\n\n' + '[targets.dev.state.connection]\ndatabase = "state.duckdb"\n' +) + + +@pytest.mark.parametrize( + "test_case", + [ + BuildTestPlanningTestCase( + description="build with tests rejects an invalid SQL test fixture", + build_flags=(), + expected_exit_code=1, + expected_fragment="Invalid SQL test fixtures", + ), + BuildTestPlanningTestCase( + description="build without tests does not plan SQL tests", + build_flags=("--no-tests",), + expected_exit_code=0, + expected_fragment="Completed successfully", + ), + ], + ids=lambda case: case.description, +) +def test_given_invalid_test_fixture_when_building_then_only_test_runs_plan_sql_tests( + test_case: BuildTestPlanningTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + for relative_path, contents in _PROJECT_FILES.items(): + destination: Path = tmp_path / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents, encoding="utf-8") + + exit_code: int = main( + ["--project-dir", str(tmp_path), "--no-color", "build", *test_case.build_flags] + ) + captured: str = "".join(capsys.readouterr()) + + assert exit_code == test_case.expected_exit_code, captured + assert test_case.expected_fragment in captured + + +@pytest.mark.parametrize( + "test_case", + [ + BuildTestPlanningTestCase( + description="virtual build with tests rejects an invalid SQL test fixture", + build_flags=("--virtual-env", "feature"), + expected_exit_code=1, + expected_fragment="Invalid SQL test fixtures", + ), + BuildTestPlanningTestCase( + description="virtual build without tests does not plan SQL tests", + build_flags=("--virtual-env", "feature", "--no-tests"), + expected_exit_code=0, + expected_fragment="Completed successfully", + ), + ], + ids=lambda case: case.description, +) +def test_given_invalid_test_fixture_when_building_virtually_then_only_test_runs_plan_sql_tests( + test_case: BuildTestPlanningTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + project_files: dict[str, str] = { + **_PROJECT_FILES, + "sqlbuild_project.toml": _VIRTUAL_PROJECT_TOML, + } + for relative_path, contents in project_files.items(): + destination: Path = tmp_path / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents, encoding="utf-8") + assert main(["--project-dir", str(tmp_path), "--no-color", "state", "init"]) == 0 + _ = capsys.readouterr() + + exit_code: int = main( + ["--project-dir", str(tmp_path), "--no-color", "build", *test_case.build_flags] + ) + 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/integration/src/sqlbuild/executor/build/test_table_type_routing.py b/tests/integration/src/sqlbuild/executor/build/test_table_type_routing.py index 8a2d05cb8d..625fb41eb0 100644 --- a/tests/integration/src/sqlbuild/executor/build/test_table_type_routing.py +++ b/tests/integration/src/sqlbuild/executor/build/test_table_type_routing.py @@ -8,7 +8,7 @@ import duckdb import pytest -import sqlbuild.compiler.planner.main.execution.execution as planner_execution_module +import sqlbuild.compiler.planner._helpers.planning.output_assembly as planner_assembly_module import sqlbuild.executor.build.classes.build_scheduler as build_scheduler_module from tests.integration.src.sqlbuild.executor.build._test_types import TableTypeRoutingTestCase from tests.integration.src.sqlbuild.executor.build.helpers import ( @@ -74,7 +74,7 @@ def test_given_table_type_drift_when_building_then_only_carried_over_tables_conv setup.execute(_NEW_ORDER_SQL) conversions: list[tuple[str, int]] = [] monkeypatch.setattr( - planner_execution_module, + planner_assembly_module, "plan_table_types", drift_for_models(model_names=("customer_totals", "orders")), ) @@ -122,7 +122,7 @@ def test_given_failing_table_type_conversion_when_building_then_model_fails_with setup.execute(_NEW_ORDER_SQL) conversions: list[tuple[str, int]] = [] monkeypatch.setattr( - planner_execution_module, + planner_assembly_module, "plan_table_types", drift_for_models(model_names=("customer_totals", "orders")), ) diff --git a/tests/unit/src/sqlbuild/compiler/planner/classes/__init__.py b/tests/unit/src/sqlbuild/compiler/planner/classes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/src/sqlbuild/compiler/planner/classes/_test_types.py b/tests/unit/src/sqlbuild/compiler/planner/classes/_test_types.py new file mode 100644 index 0000000000..a6f6f5f8f1 --- /dev/null +++ b/tests/unit/src/sqlbuild/compiler/planner/classes/_test_types.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BackgroundSqlTestPlanningTestCase: + description: str + enabled: bool + expected_planner_calls: int + expected_error: str | None = None diff --git a/tests/unit/src/sqlbuild/compiler/planner/classes/test_background_sql_test_planning.py b/tests/unit/src/sqlbuild/compiler/planner/classes/test_background_sql_test_planning.py new file mode 100644 index 0000000000..09fb602611 --- /dev/null +++ b/tests/unit/src/sqlbuild/compiler/planner/classes/test_background_sql_test_planning.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import threading + +import pytest + +import sqlbuild.compiler.planner.classes.background_sql_test_planning as background_module +from sqlbuild.compiler.compile.models import CompiledObjectKey +from sqlbuild.compiler.compile.types import CompiledResourceType +from sqlbuild.compiler.planner.classes.background_sql_test_planning import ( + BackgroundSqlTestPlanning, +) +from sqlbuild.compiler.planner.exceptions import PlannerInputError +from sqlbuild.compiler.planner.models import PlannedSqlTests, PlanWarning +from sqlbuild.compiler.planner.types import WarningSeverity +from tests.unit.src.sqlbuild.compiler.planner.classes._test_types import ( + BackgroundSqlTestPlanningTestCase, +) + +_KEYS: frozenset[CompiledObjectKey] = frozenset( + {CompiledObjectKey(resource_type=CompiledResourceType.MODEL, name="orders")} +) +_PLANNED: PlannedSqlTests = PlannedSqlTests( + selected_keys=_KEYS, + warnings=( + PlanWarning(model_name="orders", severity=WarningSeverity.WARNING, message="planned"), + ), +) + + +@pytest.mark.parametrize( + "test_case", + [ + BackgroundSqlTestPlanningTestCase( + description="enabled planning returns the background result", + enabled=True, + expected_planner_calls=1, + ), + ], + ids=lambda case: case.description, +) +def test_given_enabled_planning_when_joining_then_returns_planned_tests( + test_case: BackgroundSqlTestPlanningTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[frozenset[CompiledObjectKey]] = [] + + def plan( + *, project: object, adapter: object, selected_keys: frozenset[CompiledObjectKey] + ) -> PlannedSqlTests: + del project, adapter + calls.append(selected_keys) + return _PLANNED + + monkeypatch.setattr(background_module, "plan_selected_sql_tests", plan) + + with BackgroundSqlTestPlanning( + project=object(), # ty: ignore[invalid-argument-type] + adapter=object(), # ty: ignore[invalid-argument-type] + selected_keys=_KEYS, + enabled=test_case.enabled, + ) as planning: + result: PlannedSqlTests = planning.result() + + assert result == _PLANNED + assert calls == [_KEYS] * test_case.expected_planner_calls + + +@pytest.mark.parametrize( + "test_case", + [ + BackgroundSqlTestPlanningTestCase( + description="disabled planning never plans and returns no tests", + enabled=False, + expected_planner_calls=0, + ), + ], + ids=lambda case: case.description, +) +def test_given_disabled_planning_when_joining_then_returns_empty_tests( + test_case: BackgroundSqlTestPlanningTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[frozenset[CompiledObjectKey]] = [] + + def plan( + *, project: object, adapter: object, selected_keys: frozenset[CompiledObjectKey] + ) -> PlannedSqlTests: + del project, adapter + calls.append(selected_keys) + return _PLANNED + + monkeypatch.setattr(background_module, "plan_selected_sql_tests", plan) + + with BackgroundSqlTestPlanning( + project=object(), # ty: ignore[invalid-argument-type] + adapter=object(), # ty: ignore[invalid-argument-type] + selected_keys=_KEYS, + enabled=test_case.enabled, + ) as planning: + result: PlannedSqlTests = planning.result() + + assert result == PlannedSqlTests(selected_keys=_KEYS) + assert len(calls) == test_case.expected_planner_calls + + +@pytest.mark.parametrize( + "test_case", + [ + BackgroundSqlTestPlanningTestCase( + description="planning errors surface when joining", + enabled=True, + expected_planner_calls=1, + expected_error="Invalid SQL test fixtures", + ), + ], + ids=lambda case: case.description, +) +def test_given_failing_planning_when_joining_then_raises_original_error( + test_case: BackgroundSqlTestPlanningTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + def plan( + *, project: object, adapter: object, selected_keys: frozenset[CompiledObjectKey] + ) -> PlannedSqlTests: + del project, adapter, selected_keys + raise PlannerInputError(str(test_case.expected_error)) + + monkeypatch.setattr(background_module, "plan_selected_sql_tests", plan) + + with BackgroundSqlTestPlanning( + project=object(), # ty: ignore[invalid-argument-type] + adapter=object(), # ty: ignore[invalid-argument-type] + selected_keys=_KEYS, + enabled=test_case.enabled, + ) as planning: + with pytest.raises(PlannerInputError, match=str(test_case.expected_error)): + planning.result() + + +@pytest.mark.parametrize( + "test_case", + [ + BackgroundSqlTestPlanningTestCase( + description="running planning never keeps the process alive", + enabled=True, + expected_planner_calls=1, + ), + ], + ids=lambda case: case.description, +) +def test_given_running_planning_when_planner_fails_then_worker_is_daemon( + test_case: BackgroundSqlTestPlanningTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + started: threading.Event = threading.Event() + release: threading.Event = threading.Event() + + def plan( + *, project: object, adapter: object, selected_keys: frozenset[CompiledObjectKey] + ) -> PlannedSqlTests: + del project, adapter + started.set() + release.wait(timeout=5) + return PlannedSqlTests(selected_keys=selected_keys) + + monkeypatch.setattr(background_module, "plan_selected_sql_tests", plan) + + with BackgroundSqlTestPlanning( + project=object(), # ty: ignore[invalid-argument-type] + adapter=object(), # ty: ignore[invalid-argument-type] + selected_keys=_KEYS, + enabled=test_case.enabled, + ): + assert started.wait(timeout=5) + daemon_by_name: dict[str, bool] = { + thread.name: thread.daemon for thread in threading.enumerate() + } + release.set() + + assert daemon_by_name["sqlbuild-test-planning"] is True + assert test_case.expected_planner_calls == 1