diff --git a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py index 045a4ca00c..ad9886d8b1 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py @@ -54,7 +54,9 @@ def dispatch_cli_command(*, args: CliNamespace, handlers: CliEntrypointHandlers) -> int: """Route a parsed CLI namespace to its command handler and return its exit code.""" - project_dir: Path | None = None if args.project_dir is None else Path(args.project_dir) + project_dir: Path | None = ( + None if args.project_dir is None else Path(args.project_dir).resolve() + ) effective_project_dir: Path = project_dir if project_dir is not None else Path.cwd() selector_inputs: SelectorInputs = read_selector_file_inputs(args.select_file) select: tuple[str, ...] = (*tuple(args.select), *selector_inputs.selectors) diff --git a/src/sqlbuild/compiler/contract_adoption/_helpers/repository_edits.py b/src/sqlbuild/compiler/contract_adoption/_helpers/repository_edits.py index 044b131417..8d4fe04605 100644 --- a/src/sqlbuild/compiler/contract_adoption/_helpers/repository_edits.py +++ b/src/sqlbuild/compiler/contract_adoption/_helpers/repository_edits.py @@ -283,17 +283,30 @@ def edit_source( insertion_lines.extend(_yaml_column_lines(column=column, indent=source_indent + 4)) lines[source_end:source_end] = insertion_lines return "".join(lines), None + columns_indent: int = _indent(lines[columns_index]) + detected_column_indent: int | None = _yaml_sequence_indent( + lines=lines, + start=columns_index + 1, + end=source_end, + key_indent=columns_indent, + ) + column_indent: int = ( + detected_column_indent if detected_column_indent is not None else columns_indent + 2 + ) columns_end: int = columns_index + 1 while columns_end < source_end: stripped: str = lines[columns_end].strip() - if ( - stripped - and not stripped.startswith("#") - and _indent(lines[columns_end]) <= source_indent + 2 - ): - break + if stripped and not stripped.startswith("#"): + line_indent: int = _indent(lines[columns_end]) + if line_indent < columns_indent or ( + line_indent == columns_indent + and not ( + column_indent == columns_indent + and _yaml_list_name(lines[columns_end]) is not None + ) + ): + break columns_end += 1 - column_indent: int = source_indent + 4 column_blocks: dict[str, tuple[int, int]] = {} index: int = columns_index + 1 while index < columns_end: @@ -400,6 +413,19 @@ def _yaml_key(line: str) -> str | None: return stripped.split(_YAML_KEY_SEPARATOR, maxsplit=1)[0].strip() +def _yaml_sequence_indent(*, lines: list[str], start: int, end: int, key_indent: int) -> int | None: + for index in range(start, end): + stripped: str = lines[index].strip() + if not stripped or stripped.startswith("#"): + continue + line_indent: int = _indent(lines[index]) + if _yaml_list_name(lines[index]) is not None and line_indent >= key_indent: + return line_indent + if line_indent <= key_indent: + return None + return None + + def _yaml_comment(line: str) -> str: marker: int = line.find(" #") if marker < 0: 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 f6165300d2..88aa107dbd 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py @@ -67,6 +67,14 @@ class ContractNullabilityCompileIntegrationTestCase: expected_diagnostics: tuple[str, ...] +@dataclass(frozen=True) +class ProjectDirectoryCompileIntegrationTestCase: + """One project-directory normalization expectation through compile.""" + + description: str + expected_exit_code: int + + @dataclass(frozen=True) class FormatCompileIntegrationTestCase: """One format-to-compile compatibility expectation.""" diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py b/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py index 074b7c35fe..16da8c4479 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py @@ -11,6 +11,7 @@ from sqlbuild.cli.commands.main.entrypoint.entry import main from tests.integration.src.sqlbuild.cli.commands.main._test_types import ( ContractNullabilityCompileIntegrationTestCase, + ProjectDirectoryCompileIntegrationTestCase, SnowflakeCompileIntegrationTestCase, ) @@ -112,3 +113,46 @@ def test_given_nullable_output_when_compiling_contract_then_only_schema_nullabil assert exit_code == test_case.expected_exit_code assert tuple(item["code"] for item in diagnostics) == test_case.expected_diagnostics + + +@pytest.mark.parametrize( + "test_case", + ( + ProjectDirectoryCompileIntegrationTestCase( + description="relative project directory preserves private macro scope", + expected_exit_code=0, + ), + ), + ids=lambda case: case.description, +) +def test_given_relative_project_directory_when_compiling_then_private_macro_is_visible( + test_case: ProjectDirectoryCompileIntegrationTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + project_dir: Path = tmp_path / "project" + (project_dir / "models" / "orders" / "_sqlbuild" / "_macros").mkdir(parents=True) + (project_dir / "sqlbuild_project.toml").write_text( + 'name = "orders"\nadapter = "duckdb"\n', encoding="utf-8" + ) + (project_dir / "models" / "orders" / "_sqlbuild" / "_macros" / "order_id.py").write_text( + 'def order_id() -> str:\n return "CAST(1 AS INTEGER)"\n', encoding="utf-8" + ) + (project_dir / "models" / "orders" / "orders.sql").write_text( + "MODEL (materialized table);\nSELECT @order_id() AS order_id\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + exit_code: int = main(["--project-dir", project_dir.name, "compile", "--json", "--no-cache"]) + result: dict[str, object] = json.loads(capsys.readouterr().out) + summary: dict[str, object] = cast(dict[str, object], result["summary"]) + + assert exit_code == test_case.expected_exit_code + assert summary["errors"] == 0 + assert summary["models"] == 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_contract_command.py b/tests/integration/src/sqlbuild/cli/commands/main/test_contract_command.py index 17ecfc1474..bbd203d5ec 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/test_contract_command.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_contract_command.py @@ -534,5 +534,113 @@ def test_given_unresolved_additive_type_conflict_when_writing_then_file_is_not_r assert model_path.read_text(encoding="utf-8") == original +@pytest.mark.parametrize( + "test_case", + [ + ContractCommandIntegrationTestCase( + description="relative project directory preserves private macro scope", + expected_exit_code=1, + ) + ], + ids=lambda case: case.description, +) +def test_given_relative_project_directory_when_diffing_contract_then_private_macro_is_visible( + test_case: ContractCommandIntegrationTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + project_dir: Path = tmp_path / "project" + project_dir.mkdir() + _ = prepare_contract_project(project_dir) + root_model_path: Path = project_dir / "models" / "orders.sql" + root_model_path.unlink() + model_path: Path = project_dir / "models" / "orders" / "orders.sql" + model_path.parent.mkdir() + _ = model_path.write_text( + "MODEL (materialized table);\nSELECT @order_id() AS id, CAST('a' AS VARCHAR) AS name\n", + encoding="utf-8", + ) + macro_path: Path = model_path.parent / "_sqlbuild" / "_macros" / "order_id.py" + macro_path.parent.mkdir(parents=True) + _ = macro_path.write_text( + 'def order_id() -> str:\n return "CAST(1 AS INTEGER)"\n', encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + exit_code: int = main( + [ + "--no-color", + "--project-dir", + project_dir.name, + "contract", + "diff", + "--from", + "prod", + "--select", + "orders", + ] + ) + + captured: CaptureResult[str] = capsys.readouterr() + assert exit_code == test_case.expected_exit_code + assert "model:orders" in captured.out + assert "inaccessible" not in captured.err + + +@pytest.mark.parametrize( + "test_case", + [ + ContractCommandIntegrationTestCase( + description="indentless source column sequence receives physical types", + expected_exit_code=0, + ) + ], + ids=lambda case: case.description, +) +def test_given_indentless_source_columns_when_generating_then_preserves_metadata_and_adds_types( + test_case: ContractCommandIntegrationTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _ = prepare_contract_project(tmp_path) + source_path: Path = tmp_path / "sources" / "raw.yml" + _ = source_path.write_text( + """sources: + - name: raw_orders + schema: raw + table: orders + description: keep source + columns: + - name: id + description: identifier +""", + encoding="utf-8", + ) + + exit_code: int = main( + [ + "--no-color", + "--project-dir", + str(tmp_path), + "contract", + "generate", + "--from", + "prod", + "--select", + "source:raw_orders", + "--write", + ] + ) + + source_yaml: str = source_path.read_text(encoding="utf-8") + assert exit_code == test_case.expected_exit_code + assert "0 contract difference(s)" in capsys.readouterr().out + assert "description: keep source" in source_yaml + assert "description: identifier" in source_yaml + assert " - name: id\n type: 'BIGINT'" in source_yaml + assert " - name: status\n type: 'VARCHAR'" in source_yaml + + if __name__ == "__main__": pytest.main([__file__, "-vv"])