diff --git a/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs b/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs index cc183887f2..4da3dcae3b 100644 --- a/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs +++ b/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs @@ -47,6 +47,78 @@ fn given_repeated_native_evaluation_when_faulting_then_returns_deterministic_com Ok(()) } +#[test] +fn given_enforced_contract_when_column_type_is_missing_then_typed_contract_rule_faults() +-> Result<(), String> { + let test_cases = [ + test_types::TypedContractColumnTestCase { + description: "enforced contract has one untyped column", + contract: "enforced", + columns: json!([ + {"name": "order_id", "type": "INTEGER", "type_proven": true}, + {"name": "status", "type": "", "type_proven": false} + ]), + expected_fault_count: 1, + expected_messages: &["contract column \"status\" has no declared type"], + }, + test_types::TypedContractColumnTestCase { + description: "enforced contract has only typed columns", + contract: "enforced", + columns: json!([ + {"name": "order_id", "type": "INTEGER", "type_proven": true} + ]), + expected_fault_count: 0, + expected_messages: &[], + }, + test_types::TypedContractColumnTestCase { + description: "unenforced column metadata remains outside this rule", + contract: "none", + columns: json!([ + {"name": "status", "type": "", "type_proven": false} + ]), + expected_fault_count: 0, + expected_messages: &[], + }, + ]; + + for test_case in test_cases { + let project_dir = TempDir::new().map_err(|error| error.to_string())?; + let mut request: Value = serde_json::from_str(&helpers::request( + &project_dir, + &json!({ + "select": ["SQBRCONTRACT106"], + "cache": {"enabled": false} + }), + )) + .map_err(|error| error.to_string())?; + request["models"][0]["config"] = json!({"contract": test_case.contract}); + request["models"][0]["columns"] = test_case.columns; + + let result: Value = serde_json::from_str(&evaluate_json(&request.to_string())?) + .map_err(|error| error.to_string())?; + let faults = result["faults"] + .as_array() + .ok_or_else(|| "faults must be an array".to_string())?; + let messages = faults + .iter() + .filter_map(|fault| fault["message"].as_str()) + .collect::>(); + + assert_eq!( + faults.len(), + test_case.expected_fault_count, + "{}", + test_case.description + ); + assert_eq!( + messages, test_case.expected_messages, + "{}", + test_case.description + ); + } + Ok(()) +} + #[test] fn given_scoped_suppression_when_evaluating_native_fault_then_returns_no_faults() -> Result<(), String> { diff --git a/crates/sqlbuild-rules-native/src/engine/tests/test_types.rs b/crates/sqlbuild-rules-native/src/engine/tests/test_types.rs index 3f4974f832..ecc1ba3289 100644 --- a/crates/sqlbuild-rules-native/src/engine/tests/test_types.rs +++ b/crates/sqlbuild-rules-native/src/engine/tests/test_types.rs @@ -29,6 +29,14 @@ pub(crate) struct ExplicitOutputTypeTestCase { pub(crate) expected_message_fragment: &'static str, } +pub(crate) struct TypedContractColumnTestCase { + pub(crate) description: &'static str, + pub(crate) contract: &'static str, + pub(crate) columns: Value, + pub(crate) expected_fault_count: usize, + pub(crate) expected_messages: &'static [&'static str], +} + pub(crate) struct NormalizationTestCase { pub(crate) description: &'static str, pub(crate) dialect: &'static str, diff --git a/crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs b/crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs index 57e575f92d..ab85fd4158 100644 --- a/crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs +++ b/crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs @@ -173,6 +173,13 @@ pub(crate) fn catalogue() -> Vec { "contract outputs must establish their declared types explicitly", "Pass through a column whose exact type is proven, or wrap the complete output expression in an explicit CAST to its declared contract type.", ), + rule!( + "SQBRCONTRACT106", + "contracts", + "typed-contract-columns", + "enforced contract columns must declare types", + "Declare an adapter type for every column in the enforced contract; fix upstream type inference or cast the SQL output before choosing the type.", + ), rule!( "SQBRDECLARATION101", "declarations", diff --git a/crates/sqlbuild-rules-native/src/rules/_helpers/evaluation.rs b/crates/sqlbuild-rules-native/src/rules/_helpers/evaluation.rs index 48f1079892..f36161f5d7 100644 --- a/crates/sqlbuild-rules-native/src/rules/_helpers/evaluation.rs +++ b/crates/sqlbuild-rules-native/src/rules/_helpers/evaluation.rs @@ -5,7 +5,7 @@ use crate::constants::{ use crate::models::{Declaration, EvaluateRequest, Fault, Model, RuleMetadata, RulesConfig}; use crate::rules::_helpers::authored_literals::numeric_literal_tokens; use crate::rules::_helpers::domain_layout::folder_layer_details; -use crate::rules::_helpers::explicit_output_types; +use crate::rules::_helpers::{explicit_output_types, typed_contract_columns}; use crate::rules::models::{ FaultCollector, ModelEvaluationRequest, ProjectEvaluationRequest, ResolvedThresholdOverride, }; @@ -176,6 +176,9 @@ fn evaluate_model_inner(request: ModelEvaluationRequest<'_>) -> Result None: + (tmp_path / "sqlbuild_project.toml").write_text( + 'name = "orders"\nadapter = "duckdb"\n\n[rules]\n' + 'select = ["SQBRCONTRACT101", "SQBRCONTRACT105", "SQBRCONTRACT106"]\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.columns_sql}),\n" + ");\n\n" + "SELECT CAST(1 AS INTEGER) AS order_id, CAST('ready' AS VARCHAR) AS status\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"]) + diagnostic_codes: tuple[object, ...] = tuple(item["code"] for item in diagnostics) + + assert exit_code == test_case.expected_exit_code + assert diagnostic_codes.count("SQBRCONTRACT101") == (test_case.expected_contract_101_findings) + assert diagnostic_codes.count("SQBRCONTRACT105") == (test_case.expected_contract_105_findings) + assert diagnostic_codes.count("SQBRCONTRACT106") == (test_case.expected_contract_106_findings) + + @pytest.mark.parametrize( "test_case", [RulesIntegrationTestCase("selected SQL rule blocks artifacts", 1, "SQBRSQL004")], diff --git a/tests/unit/src/sqlbuild/rule_engine/_helpers/engine/test_catalogue.py b/tests/unit/src/sqlbuild/rule_engine/_helpers/engine/test_catalogue.py index 7e043e52f8..86731f001a 100644 --- a/tests/unit/src/sqlbuild/rule_engine/_helpers/engine/test_catalogue.py +++ b/tests/unit/src/sqlbuild/rule_engine/_helpers/engine/test_catalogue.py @@ -30,6 +30,7 @@ "SQBRCONTRACT103", "SQBRCONTRACT104", "SQBRCONTRACT105", + "SQBRCONTRACT106", "SQBRDECLARATION101", "SQBRDECLARATION102", "SQBRDECLARATION201", @@ -86,6 +87,7 @@ "SQBRCONTRACT103", "SQBRCONTRACT104", "SQBRCONTRACT105", + "SQBRCONTRACT106", "SQBRDECLARATION101", "SQBRDECLARATION102", "SQBRDECLARATION201",