diff --git a/src/sqlbuild/cli/commands/_helpers/refactor/command.py b/src/sqlbuild/cli/commands/_helpers/refactor/command.py index bb37351b9a..34e0a57b8d 100644 --- a/src/sqlbuild/cli/commands/_helpers/refactor/command.py +++ b/src/sqlbuild/cli/commands/_helpers/refactor/command.py @@ -32,6 +32,7 @@ from sqlbuild.compiler.refactoring.models import RefactorPlan, RefactorRequest from sqlbuild.compiler.refactoring.types import RefactorOperation, RefactorStatus from sqlbuild.presentation.classes.transient_status_reporter import TransientStatusReporter +from sqlbuild.presentation.main.count_noun import format_count_noun from sqlbuild.presentation.main.supports_color import supports_color _STAGING_PREFIX: str = "sqb-refactor-" @@ -75,7 +76,7 @@ def _run( if refactor_request.operation == RefactorOperation.RENAME_COLUMN else plan_model_refactor(project=before.project, request=refactor_request) ) - status.complete(message=f"Planned edits to {len(plan.changes)} files.") + status.complete(message=f"Planned edits to {_files(plan)}.") if plan.blocking or plan.manual: status.error("Refused: some references cannot be rewritten safely.") return _finish( @@ -135,9 +136,9 @@ def _run( diagnostics=(), use_color=use_color, ) - status.start(f"Writing {len(plan.changes)} files...") + status.start(f"Writing {_files(plan)}...") commit_refactor_plan(project_dir=project_dir, originals=originals, plan=plan) - status.complete(message=f"Wrote {len(plan.changes)} files.") + status.complete(message=f"Wrote {_files(plan)}.") return _finish( request=request, plan=plan, @@ -166,6 +167,10 @@ def _finish( return 0 if status in {RefactorStatus.APPLIED, RefactorStatus.DRY_RUN} else 1 +def _files(plan: RefactorPlan) -> str: + return format_count_noun(count=len(plan.changes), singular="file") + + def _refactor_request(*, request: RefactorCommandRequest) -> RefactorRequest: target: str = request.target.strip() if request.command == CliCommand.MV: diff --git a/src/sqlbuild/cli/commands/_helpers/refactor/output.py b/src/sqlbuild/cli/commands/_helpers/refactor/output.py index 7c3a5317c7..74ae474591 100644 --- a/src/sqlbuild/cli/commands/_helpers/refactor/output.py +++ b/src/sqlbuild/cli/commands/_helpers/refactor/output.py @@ -15,6 +15,7 @@ ) from sqlbuild.compiler.refactoring.types import RefactorOperation, RefactorStatus from sqlbuild.presentation.classes.cli_style import CliStyle +from sqlbuild.presentation.main.count_noun import format_count_noun from sqlbuild.presentation.main.tree_connector import tree_connector _CONTINUE: str = "\u2502 " @@ -197,8 +198,7 @@ def _diagnostic_lines(*, style: CliStyle, diagnostics: tuple[CompilerDiagnostic, def _status_line(*, style: CliStyle, plan: RefactorPlan, status: RefactorStatus) -> str: - count: int = len(plan.changes) - files: str = f"{count} file{'s' if count != 1 else ''}" + files: str = format_count_noun(count=len(plan.changes), singular="file") if status == RefactorStatus.APPLIED: return style.success_strong(f"Compiled: ok, {files} changed") if status == RefactorStatus.DRY_RUN: diff --git a/src/sqlbuild/cli/output/_helpers/cursor_plan.py b/src/sqlbuild/cli/output/_helpers/cursor_plan.py index 927176cc72..a3629ef093 100644 --- a/src/sqlbuild/cli/output/_helpers/cursor_plan.py +++ b/src/sqlbuild/cli/output/_helpers/cursor_plan.py @@ -12,7 +12,12 @@ from sqlbuild.compiler.planner.main.execution.effective_microbatch_batch_size import ( resolve_effective_microbatch_batch_size, ) -from sqlbuild.compiler.planner.models import CursorBounds, Duration, ModelPlanEntry +from sqlbuild.compiler.planner.models import ( + CursorBounds, + CursorInputRelation, + Duration, + ModelPlanEntry, +) from sqlbuild.compiler.planner.types import ( CursorGrain, CursorType, @@ -136,6 +141,27 @@ def _count_batches(*, bounds: CursorBounds, batch_size: str, cursor_type: str) - return None +def runtime_cursor_inputs_clause(*, entry: ModelPlanEntry) -> str: + """Name the cursor inputs this run brings up to date before the model's range is known.""" + + labels: list[str] = [] + relation: CursorInputRelation + for relation in entry.cursor_input_relations: + if not relation.is_runtime_owned: + continue + name: str = relation.input_name or relation.relation + label: str = ( + f"{relation.input_kind.value} {name}" if relation.input_kind is not None else name + ) + if label not in labels: + labels.append(label) + if not labels: + return "once its inputs are up to date" + if len(labels) == 1: + return f"once {labels[0]} is up to date" + return f"once {', '.join(labels[:-1])} and {labels[-1]} are up to date" + + def append_microbatch_plan_detail( *, lines: list[str], details: CursorPlanDetails, entry: ModelPlanEntry ) -> list[str]: @@ -160,7 +186,9 @@ def append_microbatch_plan_detail( if details.planned_batch_count is not None and details.effective_batch_size is not None: lines.append(f" batches: {details.planned_batch_count} x {details.effective_batch_size}") if details.resolution_status == CursorResolutionStatus.DEFERRED: - lines.append(" batches: resolved at runtime after upstream models complete") + lines.append( + f" batches: counted at run time, {runtime_cursor_inputs_clause(entry=entry)}" + ) lines.append(f" batch concurrency: {entry.batch_concurrency}") if entry.unaccounted_partition_policy is not None: lines.append(f" unaccounted partition policy: {entry.unaccounted_partition_policy}") diff --git a/src/sqlbuild/cli/output/_helpers/plan_text.py b/src/sqlbuild/cli/output/_helpers/plan_text.py index f26cda310d..0b1fd548a5 100644 --- a/src/sqlbuild/cli/output/_helpers/plan_text.py +++ b/src/sqlbuild/cli/output/_helpers/plan_text.py @@ -11,6 +11,7 @@ from sqlbuild.cli.output._helpers.cursor_plan import ( append_microbatch_plan_detail, build_cursor_plan_details, + runtime_cursor_inputs_clause, ) from sqlbuild.cli.output._helpers.selection_diagnostics import direct_selection_diagnostics_enabled from sqlbuild.cli.output.models import CursorPlanDetails @@ -65,7 +66,6 @@ _LEAF_KEY_SEPARATOR: str = ": " _LEAF_ROW_PREFIX: str = " " _NESTED_ROW_PREFIX: str = " " -_RUNTIME_PROMINENT_MARKERS: tuple[str, ...] = ("runtime", "deferred") _STALE_INPUT_WARNING_TITLE: str = "Stale inputs detected" _TREE_EXEMPT_CHARS: frozenset[str] = frozenset({" ", "."}) @@ -949,7 +949,9 @@ def _append_cursor_detail( if entry.incremental_mode == IncrementalMode.MICROBATCH: lines = append_microbatch_plan_detail(lines=lines, details=details, entry=entry) if details.bounds_owner == CursorBoundsOwner.RUNTIME: - lines.append(" bounds: runtime-owned (model-backed cursor input)") + lines.append( + f" bounds: computed at run time, {runtime_cursor_inputs_clause(entry=entry)}" + ) elif details.resolution_status == CursorResolutionStatus.RESOLVED: lines.append(" bounds: planner-resolved") return lines @@ -1645,10 +1647,7 @@ def _treeify_plan_lines(lines: list[str]) -> list[str]: ) key, separator, value = content.partition(_LEAF_KEY_SEPARATOR) if separator: - rendered_value: str = value - if any(marker in value for marker in _RUNTIME_PROMINENT_MARKERS): - rendered_value = style.section(value) - out.append(f"{_LEAF_ROW_PREFIX}{connector} {style.muted(key)} {rendered_value}") + out.append(f"{_LEAF_ROW_PREFIX}{connector} {style.muted(key)} {value}") elif content.endswith(":"): out.append(f"{_LEAF_ROW_PREFIX}{connector} {style.muted(content)}") else: diff --git a/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py b/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py index b91db81652..c59e7180ba 100644 --- a/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py +++ b/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py @@ -125,6 +125,7 @@ ContractPolicy, CursorGrain, CursorType, + GraphResourceKind, IncrementalMode, IncrementalStrategy, MaterializationType, @@ -1582,6 +1583,8 @@ def _build_cursor_input_relations( ref.ref_kind == SqlReferenceKind.REF and ref.ref_name in model_locations ), is_runtime_produced=ref.ref_name in runtime_cursor_producer_names, + input_name=ref.ref_name, + input_kind=_cursor_input_kind(ref=ref, model_locations=model_locations), terminal_cursor_start=( parse( raw=get_config_cursor_bound( @@ -1764,6 +1767,22 @@ def _resolve_cursor_input_relation( return None +def _cursor_input_kind( + *, ref: CompileSqlReference, model_locations: dict[str, CompiledRelationLocation] +) -> GraphResourceKind | None: + """Return the resource kind a resolved cursor input reference reads.""" + + if ref.ref_kind == SqlReferenceKind.REF: + return ( + GraphResourceKind.MODEL if ref.ref_name in model_locations else GraphResourceKind.SEED + ) + if ref.ref_kind == SqlReferenceKind.SEED: + return GraphResourceKind.SEED + if ref.ref_kind == SqlReferenceKind.SOURCE: + return GraphResourceKind.SOURCE + return None + + def _resolve_cursor_input_grain( *, ref: CompileSqlReference, diff --git a/src/sqlbuild/compiler/planner/models.py b/src/sqlbuild/compiler/planner/models.py index 6151b42ce1..e1e1411c6b 100644 --- a/src/sqlbuild/compiler/planner/models.py +++ b/src/sqlbuild/compiler/planner/models.py @@ -550,6 +550,8 @@ class CursorInputRelation: is_runtime_produced: bool = False terminal_cursor_start: CursorScalar | None = None terminal_cursor_end: CursorScalar | None = None + input_name: str | None = None + input_kind: GraphResourceKind | None = None def __init__( self, @@ -561,6 +563,8 @@ def __init__( is_runtime_produced: bool = False, terminal_cursor_start: CursorScalar | str | None = None, terminal_cursor_end: CursorScalar | str | None = None, + input_name: str | None = None, + input_kind: GraphResourceKind | None = None, ) -> None: object.__setattr__(self, "relation", relation) object.__setattr__(self, "cursor_column", cursor_column) @@ -569,6 +573,8 @@ def __init__( object.__setattr__(self, "is_runtime_produced", is_runtime_produced) object.__setattr__(self, "terminal_cursor_start", terminal_cursor_start) object.__setattr__(self, "terminal_cursor_end", terminal_cursor_end) + object.__setattr__(self, "input_name", input_name) + object.__setattr__(self, "input_kind", input_kind) self.__post_init__() def __post_init__(self) -> None: diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py index f47dc5c487..2375b9384a 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py @@ -72,6 +72,7 @@ class SeedWatermarkBuildE2ETestCase: changed_seed: str expected_initial_rows: tuple[tuple[object, ...], ...] expected_changed_rows: tuple[tuple[object, ...], ...] + expected_plan_bounds: str @dataclass(frozen=True) @@ -81,6 +82,7 @@ class LoaderWatermarkBuildE2ETestCase: changed_maximum: int expected_initial_rows: tuple[tuple[object, ...], ...] expected_changed_rows: tuple[tuple[object, ...], ...] + expected_plan_bounds: str @dataclass(frozen=True) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py index a21a982ccb..5e81489ccf 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_loader_watermark_build.py @@ -27,6 +27,9 @@ changed_maximum=3, expected_initial_rows=((1, 100), (2, 200)), expected_changed_rows=((1, 100), (2, 200), (3, 300)), + expected_plan_bounds=( + "bounds computed at run time, once source raw_orders is up to date" + ), ) ], ids=lambda case: case.description, @@ -105,6 +108,13 @@ def test_given_managed_loader_watermark_when_reloaded_then_incremental_consumes_ sql="SELECT id, amount FROM main.raw_orders_incremental ORDER BY id", ) == list(test_case.expected_initial_rows) + plan_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "plan", "--select", "+raw_orders_incremental"), + project_dir=project_dir, + ) + assert plan_result.returncode == 0, plan_result.stdout + plan_result.stderr + assert test_case.expected_plan_bounds in plan_result.stdout, plan_result.stdout + maximum_path.write_text(str(test_case.changed_maximum), encoding="utf-8") changed_result: subprocess.CompletedProcess[str] = run_sqb( command=("--no-color", "build", "--reload", "--select", "+raw_orders_incremental"), diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py index 741056fb97..a59402a92c 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py @@ -191,7 +191,7 @@ rebuild_command=(), expected_plan_fragments=( "full rebuild", - "bounds runtime-owned (model-backed cursor input)", + "bounds computed at run time, once model stg_orders is up to date", "policy replay_on_change=full", ), ), diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py index 871ed4c383..f8fda505f0 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py @@ -27,6 +27,9 @@ changed_seed="id,amount\n1,100\n2,200\n3,300\n", expected_initial_rows=((1, 100), (2, 200)), expected_changed_rows=((1, 100), (2, 200), (3, 300)), + expected_plan_bounds=( + "bounds computed at run time, once seed order_events is up to date" + ), ) ], ids=lambda case: case.description, @@ -92,6 +95,12 @@ def test_given_seed_watermark_when_seed_changes_then_incremental_consumes_new_hi sql="SELECT id, amount FROM main.order_events_incremental ORDER BY id", ) == list(test_case.expected_initial_rows) + plan_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "plan"), project_dir=project_dir + ) + assert plan_result.returncode == 0, plan_result.stdout + plan_result.stderr + assert test_case.expected_plan_bounds in plan_result.stdout, plan_result.stdout + (project_dir / "seeds" / "order_events.csv").write_text( test_case.changed_seed, encoding="utf-8" ) diff --git a/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py new file mode 100644 index 0000000000..ba024040b7 --- /dev/null +++ b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py @@ -0,0 +1 @@ +"""Refactor command integration tests.""" diff --git a/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py new file mode 100644 index 0000000000..eca9d7224f --- /dev/null +++ b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py @@ -0,0 +1,15 @@ +"""Test case types for refactor command integration tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RefactorStatusMessagesTestCase: + description: str + command: str + target: str + new_name: str | None + destination: str | None + expected_lines: tuple[str, ...] diff --git a/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/test_command.py b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/test_command.py new file mode 100644 index 0000000000..d754295ac9 --- /dev/null +++ b/tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/test_command.py @@ -0,0 +1,117 @@ +"""Progress messages of `sqb rename` and `sqb mv` against a real offline compile.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sqlbuild.cli.commands._helpers.refactor.command import run_refactor_command +from sqlbuild.cli.commands.models import RefactorCommandRequest +from sqlbuild.cli.commands.types import CliCommand +from tests.integration.src.sqlbuild.cli.commands._helpers.refactor._test_types import ( + RefactorStatusMessagesTestCase, +) + +_PROJECT_FILES: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "orders_project"\nadapter = "duckdb"\ndefault_target = "dev"\n\n' + '[connection]\ndatabase = "orders.duckdb"\n\n' + '[targets.dev]\nschema = "analytics"\n' + ), + "seeds/orders.csv": "order_id,amount\n1,25\n2,40\n", + "seeds/orders.yml": ( + "seeds:\n" + " - name: orders\n description: Test seed orders.\n" + " columns:\n" + " - name: order_id\n type: INTEGER\n" + " - name: amount\n type: INTEGER\n" + ), + "models/staging/stg_orders.sql": ( + "MODEL (description 'Test model.',\n materialized view,\n);\n\n" + 'SELECT\n order_id,\n amount\nFROM __seed("orders")\n' + ), + "models/marts/fact_orders.sql": ( + "MODEL (description 'Test model.',\n materialized table,\n);\n\n" + 'SELECT\n order_id,\n amount\nFROM __ref("stg_orders")\n' + ), +} + + +@pytest.mark.parametrize( + "test_case", + [ + RefactorStatusMessagesTestCase( + description="moving a model nothing else names edits one file", + command=CliCommand.MV, + target="model:fact_orders", + new_name=None, + destination="models/finance/fact_orders.sql", + expected_lines=( + "Planned edits to 1 file.", + "Writing 1 file...", + "Wrote 1 file.", + "Compiled: ok, 1 file changed", + ), + ), + RefactorStatusMessagesTestCase( + description="renaming a column nothing downstream reads edits one file", + command=CliCommand.RENAME, + target="column:fact_orders.amount", + new_name="order_amount", + destination=None, + expected_lines=( + "Planned edits to 1 file.", + "Writing 1 file...", + "Wrote 1 file.", + "Compiled: ok, 1 file changed", + ), + ), + RefactorStatusMessagesTestCase( + description="renaming a model another model reads edits two files", + command=CliCommand.RENAME, + target="model:stg_orders", + new_name="stg_order_lines", + destination=None, + expected_lines=( + "Planned edits to 2 files.", + "Writing 2 files...", + "Wrote 2 files.", + "Compiled: ok, 2 files changed", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_refactor_when_running_then_progress_messages_agree_in_number( + test_case: RefactorStatusMessagesTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + relative_path: str + content: str + for relative_path, content in _PROJECT_FILES.items(): + path: Path = tmp_path / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + exit_code: int = run_refactor_command( + request=RefactorCommandRequest( + command=test_case.command, + target=test_case.target, + new_name=test_case.new_name, + destination=test_case.destination, + project_dir=tmp_path, + no_color=True, + ) + ) + + lines: list[str] = capsys.readouterr().out.splitlines() + assert exit_code == 0, lines + expected: str + for expected in test_case.expected_lines: + assert expected in lines, lines + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py new file mode 100644 index 0000000000..05bdafb38c --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py @@ -0,0 +1 @@ +"""Refactor command output tests.""" diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py new file mode 100644 index 0000000000..b0a7df1bd0 --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py @@ -0,0 +1,15 @@ +"""Test case types for refactor output tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlbuild.compiler.refactoring.types import RefactorStatus + + +@dataclass(frozen=True) +class RefactorStatusLineTestCase: + description: str + status: RefactorStatus + changed_files: int + expected_line: str diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/helpers.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/helpers.py new file mode 100644 index 0000000000..bbd38d2a64 --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/helpers.py @@ -0,0 +1,25 @@ +"""Refactor output test builders.""" + +from __future__ import annotations + +from sqlbuild.compiler.refactoring.models import FileChange, RefactorPlan, RefactorRequest +from sqlbuild.compiler.refactoring.types import RefactorOperation + + +def build_move_plan(*, changed_files: int) -> RefactorPlan: + """Return a move plan that changes the given number of files.""" + + return RefactorPlan( + request=RefactorRequest( + operation=RefactorOperation.MOVE_MODEL, + model_name="orders", + new_name="", + destination="models/marts/orders.sql", + ), + changes=tuple( + FileChange( + path=f"models/orders_{index}.sql", original_path=f"models/orders_{index}.sql" + ) + for index in range(changed_files) + ), + ) diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/test_output.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/test_output.py new file mode 100644 index 0000000000..d868f9b4d4 --- /dev/null +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/test_output.py @@ -0,0 +1,59 @@ +"""Terminal status line of `sqb rename` and `sqb mv` text output.""" + +from __future__ import annotations + +import pytest + +from sqlbuild.cli.commands._helpers.refactor.output import render_refactor_text +from sqlbuild.compiler.refactoring.types import RefactorStatus +from tests.unit.src.sqlbuild.cli.commands._helpers.refactor._test_types import ( + RefactorStatusLineTestCase, +) +from tests.unit.src.sqlbuild.cli.commands._helpers.refactor.helpers import build_move_plan + + +@pytest.mark.parametrize( + "test_case", + [ + RefactorStatusLineTestCase( + description="applied one file is singular", + status=RefactorStatus.APPLIED, + changed_files=1, + expected_line="Compiled: ok, 1 file changed", + ), + RefactorStatusLineTestCase( + description="applied several files is plural", + status=RefactorStatus.APPLIED, + changed_files=3, + expected_line="Compiled: ok, 3 files changed", + ), + RefactorStatusLineTestCase( + description="dry run one file is singular", + status=RefactorStatus.DRY_RUN, + changed_files=1, + expected_line="Dry run: compiles, 1 file would change; nothing written", + ), + RefactorStatusLineTestCase( + description="dry run several files is plural", + status=RefactorStatus.DRY_RUN, + changed_files=2, + expected_line="Dry run: compiles, 2 files would change; nothing written", + ), + ], + ids=lambda case: case.description, +) +def test_given_changed_file_count_when_rendering_refactor_text_then_status_line_agrees_in_number( + test_case: RefactorStatusLineTestCase, +) -> None: + output: str = render_refactor_text( + plan=build_move_plan(changed_files=test_case.changed_files), + status=test_case.status, + diagnostics=(), + use_color=False, + ) + + assert output.splitlines()[-1] == test_case.expected_line + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/cli/output/main/plan/_test_types.py b/tests/unit/src/sqlbuild/cli/output/main/plan/_test_types.py index 364b24e8a6..c6400b32f1 100644 --- a/tests/unit/src/sqlbuild/cli/output/main/plan/_test_types.py +++ b/tests/unit/src/sqlbuild/cli/output/main/plan/_test_types.py @@ -6,6 +6,7 @@ from sqlbuild.compiler.pipeline.models import PythonPlanEntry from sqlbuild.compiler.planner.models import ( + CursorInputRelation, PlanOutput, ) from sqlbuild.presentation.models import DisplayOptions @@ -30,3 +31,10 @@ class FormatPlanColorTestCase: plan_output: PlanOutput expected_fragments: tuple[str, ...] python_plan_entries: tuple[PythonPlanEntry, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class RuntimeBoundsLineTestCase: + description: str + cursor_input_relations: tuple[CursorInputRelation, ...] + expected_bounds_value: str diff --git a/tests/unit/src/sqlbuild/cli/output/main/plan/test_plan_text.py b/tests/unit/src/sqlbuild/cli/output/main/plan/test_plan_text.py index b692c775ff..88c8e2872c 100644 --- a/tests/unit/src/sqlbuild/cli/output/main/plan/test_plan_text.py +++ b/tests/unit/src/sqlbuild/cli/output/main/plan/test_plan_text.py @@ -13,9 +13,11 @@ ColumnRenameHint, CursorBounds, CursorInputRelation, + PlanOutput, ) from sqlbuild.compiler.planner.types import ( BackfillAction, + GraphResourceKind, MaterializationType, PlanAction, PlanReason, @@ -32,6 +34,7 @@ from tests.unit.src.sqlbuild.cli.output.main.plan._test_types import ( FormatPlanColorTestCase, FormatPlanTestCase, + RuntimeBoundsLineTestCase, ) from tests.unit.src.sqlbuild.cli.output.main.plan.helpers import ( build_discovered_provider_usage, @@ -213,7 +216,7 @@ "batches 3 x 1mo", "bounds planner-resolved", ), - unexpected_fragments=("resolved at runtime",), + unexpected_fragments=("at run time",), ), FormatPlanTestCase( description="runtime-owned microbatch marks range and count as deferred", @@ -237,6 +240,8 @@ cursor_grain="month", is_model_backed=True, is_runtime_produced=True, + input_name="stg_events", + input_kind=GraphResourceKind.MODEL, ), ), ), @@ -245,8 +250,8 @@ expected_fragments=( "grain day -> month (effective)", "batch size effective -> 1mo", - "batches resolved at runtime after upstream models complete", - "bounds runtime-owned (model-backed cursor input)", + "batches counted at run time, once model stg_events is up to date", + "bounds computed at run time, once model stg_events is up to date", ), unexpected_fragments=("batches 3 x", "bounds planner-resolved"), ), @@ -1418,6 +1423,23 @@ def test_given_plan_output_when_formatting_then_contains_expected_fragments( "\033[34mrun partition_tracked\033[0m", ), ), + FormatPlanColorTestCase( + description="leaf values mentioning runtime keep the plain value style", + plan_output=build_plan_output( + model_entries=( + build_model_entry( + name="session_events", + action=PlanAction.INCREMENTAL_DELETE_INSERT, + reason=PlanReason.NORMAL_INCREMENTAL, + materialization_type=MaterializationType.INCREMENTAL, + incremental_strategy="delete_insert", + cursor_column="runtime_started_at", + cursor_type="timestamp", + ), + ), + ), + expected_fragments=("\033[2mcursor\033[0m runtime_started_at (timestamp)",), + ), FormatPlanColorTestCase( description="styles python dependency diff headers as metadata", plan_output=build_plan_output(), @@ -1475,3 +1497,114 @@ def test_given_plan_output_when_formatting_with_color_then_styles_semantic_parts for fragment in test_case.expected_fragments: assert fragment in result, f"Expected '{fragment}' in output:\n{result}" + + +@pytest.mark.parametrize( + "test_case", + [ + RuntimeBoundsLineTestCase( + description="model cursor input", + cursor_input_relations=( + CursorInputRelation( + relation="analytics.stg_payments", + cursor_column="paid_at", + is_model_backed=True, + is_runtime_produced=True, + input_name="stg_payments", + input_kind=GraphResourceKind.MODEL, + ), + ), + expected_bounds_value="computed at run time, once model stg_payments is up to date", + ), + RuntimeBoundsLineTestCase( + description="seed cursor input", + cursor_input_relations=( + CursorInputRelation( + relation="analytics.payments", + cursor_column="paid_at", + is_runtime_produced=True, + input_name="payments", + input_kind=GraphResourceKind.SEED, + ), + ), + expected_bounds_value="computed at run time, once seed payments is up to date", + ), + RuntimeBoundsLineTestCase( + description="source cursor input", + cursor_input_relations=( + CursorInputRelation( + relation="raw.payments", + cursor_column="paid_at", + is_runtime_produced=True, + input_name="raw_payments", + input_kind=GraphResourceKind.SOURCE, + ), + ), + expected_bounds_value="computed at run time, once source raw_payments is up to date", + ), + RuntimeBoundsLineTestCase( + description="several cursor inputs name only those refreshed in this run", + cursor_input_relations=( + CursorInputRelation( + relation="analytics.payments", + cursor_column="paid_at", + is_runtime_produced=True, + input_name="payments", + input_kind=GraphResourceKind.SEED, + ), + CursorInputRelation( + relation="raw.refunds", + cursor_column="refunded_at", + is_runtime_produced=False, + input_name="raw_refunds", + input_kind=GraphResourceKind.SOURCE, + ), + CursorInputRelation( + relation="analytics.stg_orders", + cursor_column="ordered_at", + is_model_backed=True, + is_runtime_produced=True, + input_name="stg_orders", + input_kind=GraphResourceKind.MODEL, + ), + ), + expected_bounds_value=( + "computed at run time, once seed payments and model stg_orders are up to date" + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_cursor_input_refreshed_in_run_when_formatting_plan_then_bounds_line_names_input( + test_case: RuntimeBoundsLineTestCase, +) -> None: + plan_output: PlanOutput = build_plan_output( + model_entries=( + build_model_entry( + name="daily_revenue", + action=PlanAction.INCREMENTAL_DELETE_INSERT, + reason=PlanReason.NORMAL_INCREMENTAL, + materialization_type=MaterializationType.INCREMENTAL, + incremental_strategy="delete_insert", + cursor_column="revenue_date", + cursor_type="timestamp", + cursor_input_relations=test_case.cursor_input_relations, + ), + ), + ) + + plain: str = format_plan(plan=plan_output, use_color=False) + colored: str = format_plan(plan=plan_output, use_color=True) + + assert f"bounds {test_case.expected_bounds_value}" in plain, plain + assert "runtime-owned" not in plain + assert "model-backed" not in plain + colored_lines: list[str] = colored.splitlines() + assert ( + f" \033[2m\u2514\u2500\u2500\033[0m \033[2mbounds\033[0m {test_case.expected_bounds_value}" + in colored_lines + ), colored + assert ( + " \033[2m\u251c\u2500\u2500\033[0m \033[2mcursor\033[0m revenue_date (timestamp)" + in colored_lines + ), colored