diff --git a/src/sqlbuild/compiler/contracts/_helpers/columns.py b/src/sqlbuild/compiler/contracts/_helpers/columns.py index 6efc7efed5..1e642b78f4 100644 --- a/src/sqlbuild/compiler/contracts/_helpers/columns.py +++ b/src/sqlbuild/compiler/contracts/_helpers/columns.py @@ -19,7 +19,6 @@ DiagnosticPhase, DiagnosticSeverity, ) -from sqlbuild.compiler.contracts.constants import NOT_NULL_AUDIT_NAME from sqlbuild.compiler.discovery.main._model_output_column_locations import ( extract_model_output_column_locations, ) @@ -175,9 +174,7 @@ def _nullability_diagnostics( def _declares_not_null(column: SchemaColumn) -> bool: - if column.nullable is False: - return True - return any(audit.definition_name == NOT_NULL_AUDIT_NAME for audit in column.audits) + return column.nullable is False def _missing_column_diagnostic( 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 fd1501e51a..f6165300d2 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py @@ -57,6 +57,16 @@ class SnowflakeCompileIntegrationTestCase: expected_query_fragment: str +@dataclass(frozen=True) +class ContractNullabilityCompileIntegrationTestCase: + """One contract nullability expectation through compile.""" + + description: str + column_sql: str + expected_exit_code: int + expected_diagnostics: tuple[str, ...] + + @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 c4b40a5e84..074b7c35fe 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_compile.py @@ -10,6 +10,7 @@ from sqlbuild.cli.commands.main.entrypoint.entry import main from tests.integration.src.sqlbuild.cli.commands.main._test_types import ( + ContractNullabilityCompileIntegrationTestCase, SnowflakeCompileIntegrationTestCase, ) @@ -67,3 +68,47 @@ def test_given_supported_snowflake_aggregation_when_compiling_then_project_succe assert exit_code == test_case.expected_exit_code assert tuple(item["code"] for item in diagnostics) == test_case.expected_diagnostics assert test_case.expected_query_fragment in cast(str, models[0]["query_sql"]) + + +@pytest.mark.parametrize( + "test_case", + ( + ContractNullabilityCompileIntegrationTestCase( + description="not null audit remains runtime only", + column_sql="order_id (type INTEGER, audits [not_null])", + expected_exit_code=0, + expected_diagnostics=(), + ), + ContractNullabilityCompileIntegrationTestCase( + description="nullable false remains a schema contract", + column_sql="order_id (type INTEGER, nullable false)", + expected_exit_code=1, + expected_diagnostics=("K004",), + ), + ), + ids=lambda case: case.description, +) +def test_given_nullable_output_when_compiling_contract_then_only_schema_nullability_fails( + test_case: ContractNullabilityCompileIntegrationTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / "sqlbuild_project.toml").write_text( + 'name = "orders"\nadapter = "duckdb"\n', encoding="utf-8" + ) + model: Path = tmp_path / "models" / "orders.sql" + model.parent.mkdir() + model.write_text( + "MODEL (\n" + " contract enforced,\n" + f" columns ({test_case.column_sql}),\n" + ");\n\n" + "SELECT CAST(NULL AS INTEGER) AS order_id\n", + encoding="utf-8", + ) + exit_code: int = main(["--project-dir", str(tmp_path), "compile", "--json", "--no-cache"]) + result: dict[str, object] = json.loads(capsys.readouterr().out) + diagnostics: list[dict[str, object]] = cast(list[dict[str, object]], result["diagnostics"]) + + assert exit_code == test_case.expected_exit_code + assert tuple(item["code"] for item in diagnostics) == test_case.expected_diagnostics diff --git a/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py b/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py index dabd75a97d..1c9a7ad6dc 100644 --- a/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/contracts/_test_types.py @@ -17,7 +17,7 @@ class ContractValidationTestCase: expected_messages: tuple[str, ...] contract: str | None = None column_contract_mode: str = "implicit" - declared_not_null_columns: tuple[str, ...] = () + not_null_audit_columns: tuple[str, ...] = () declared_nullable_by_column: dict[str, bool | None] | None = None inferred_nullability_by_column: dict[str, InferredNullability] | None = None fast_lineage_has_star: bool = False diff --git a/tests/unit/src/sqlbuild/compiler/contracts/helpers.py b/tests/unit/src/sqlbuild/compiler/contracts/helpers.py index c70fee4069..fc5730082b 100644 --- a/tests/unit/src/sqlbuild/compiler/contracts/helpers.py +++ b/tests/unit/src/sqlbuild/compiler/contracts/helpers.py @@ -32,7 +32,7 @@ def make_contract_project( column_contract_mode: str = "implicit", model_name: str = "orders", column_locations: dict[str, SourceLocation] | None = None, - declared_not_null_columns: tuple[str, ...] = (), + not_null_audit_columns: tuple[str, ...] = (), declared_nullable_by_column: dict[str, bool | None] | None = None, inferred_nullability_by_column: dict[str, InferredNullability] | None = None, fast_lineage_has_star: bool = False, @@ -78,7 +78,7 @@ def make_contract_project( type=column_type, audits=_column_audits( name=name, - declared_not_null_columns=declared_not_null_columns, + not_null_audit_columns=not_null_audit_columns, ), nullable=(declared_nullable_by_column or {}).get(name), location=(column_locations or {}).get(name), @@ -107,8 +107,6 @@ def make_contract_project( def _column_audits( - *, name: str, declared_not_null_columns: tuple[str, ...] + *, name: str, not_null_audit_columns: tuple[str, ...] ) -> tuple[SchemaAuditInstance, ...]: - return ((), (SchemaAuditInstance(definition_name="not_null"),))[ - name in declared_not_null_columns - ] + return ((), (SchemaAuditInstance(definition_name="not_null"),))[name in not_null_audit_columns] diff --git a/tests/unit/src/sqlbuild/compiler/contracts/test_validate.py b/tests/unit/src/sqlbuild/compiler/contracts/test_validate.py index 3e8bf05726..3b9573efa9 100644 --- a/tests/unit/src/sqlbuild/compiler/contracts/test_validate.py +++ b/tests/unit/src/sqlbuild/compiler/contracts/test_validate.py @@ -206,9 +206,9 @@ ), ), ContractValidationTestCase( - description="declared not null ignores unknown inferred nullability", + description="not null audit ignores unknown inferred nullability", declared_columns=(("order_id", None),), - declared_not_null_columns=("order_id",), + not_null_audit_columns=("order_id",), inferred_columns=(("order_id", None),), inferred_nullability_by_column={"order_id": InferredNullability.UNKNOWN}, type_enforcement=None, @@ -217,15 +217,15 @@ expected_messages=(), ), ContractValidationTestCase( - description="declared not null fails when output is proven nullable", + description="not null audit does not declare schema nullability", declared_columns=(("customer_name", None),), - declared_not_null_columns=("customer_name",), + not_null_audit_columns=("customer_name",), inferred_columns=(("customer_name", None),), inferred_nullability_by_column={"customer_name": InferredNullability.NULLABLE}, type_enforcement=None, - expected_codes=("K004",), - expected_severities=("error",), - expected_messages=("column 'customer_name' is declared non-null but may be nullable",), + expected_codes=(), + expected_severities=(), + expected_messages=(), ), ContractValidationTestCase( description="declared nullable false fails when output is proven nullable", @@ -260,7 +260,7 @@ def test_given_compiled_project_when_validating_contracts_then_returns_expected_ declared_columns=test_case.declared_columns, inferred_columns=test_case.inferred_columns, type_enforcement=test_case.type_enforcement, - declared_not_null_columns=test_case.declared_not_null_columns, + not_null_audit_columns=test_case.not_null_audit_columns, declared_nullable_by_column=test_case.declared_nullable_by_column, inferred_nullability_by_column=test_case.inferred_nullability_by_column, fast_lineage_has_star=test_case.fast_lineage_has_star,