From d04a01384010bf69da1d4a908b9a5a01ad0d4343 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:39:53 +0100 Subject: [PATCH] fix: singular file counts in sqb mv and sqb rename, plain cursor bounds wording in sqb plan sqb mv and sqb rename printed "Planned edits to 1 files.", "Writing 1 files..." and "Wrote 1 files."; they now use the shared count helper. sqb plan described a model whose cursor range is found during the run as "bounds runtime-owned (model-backed cursor input)", even when that input was a seed or a source, and showed it in bold. It now names the input and its kind, for example "bounds computed at run time, once seed payments is up to date", with the same styling as the other property values. The deferred microbatch count line uses the same wording instead of "after upstream models complete". Plan JSON is unchanged. --- .../cli/commands/_helpers/refactor/command.py | 11 +- .../cli/commands/_helpers/refactor/output.py | 4 +- .../cli/output/_helpers/cursor_plan.py | 32 +++- src/sqlbuild/cli/output/_helpers/plan_text.py | 11 +- .../planner/_helpers/output/plan_entry.py | 19 +++ src/sqlbuild/compiler/planner/models.py | 6 + .../cli/commands/main/build/_test_types.py | 2 + .../main/build/test_loader_watermark_build.py | 10 ++ .../build/test_replay_on_change_full_build.py | 2 +- .../main/build/test_seed_watermark_build.py | 9 ++ .../commands/_helpers/refactor/__init__.py | 1 + .../commands/_helpers/refactor/_test_types.py | 15 ++ .../_helpers/refactor/test_command.py | 117 +++++++++++++++ .../commands/_helpers/refactor/__init__.py | 1 + .../commands/_helpers/refactor/_test_types.py | 15 ++ .../cli/commands/_helpers/refactor/helpers.py | 25 ++++ .../commands/_helpers/refactor/test_output.py | 59 ++++++++ .../cli/output/main/plan/_test_types.py | 8 + .../cli/output/main/plan/test_plan_text.py | 139 +++++++++++++++++- 19 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py create mode 100644 tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py create mode 100644 tests/integration/src/sqlbuild/cli/commands/_helpers/refactor/test_command.py create mode 100644 tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/__init__.py create mode 100644 tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/_test_types.py create mode 100644 tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/helpers.py create mode 100644 tests/unit/src/sqlbuild/cli/commands/_helpers/refactor/test_output.py 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