Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

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> {
Expand Down
8 changes: 8 additions & 0 deletions crates/sqlbuild-rules-native/src/engine/tests/test_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ pub(crate) fn catalogue() -> Vec<RuleMetadata> {
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -176,6 +176,9 @@ fn evaluate_model_inner(request: ModelEvaluationRequest<'_>) -> Result<Vec<Fault
if let Some(rule) = metadata("SQBRCONTRACT101") {
contract_required(&parsed, rule, &faults);
}
if let Some(rule) = metadata("SQBRCONTRACT106") {
typed_contract_columns::evaluate(parsed.model, rule, &faults);
}
if let Some(rule) = metadata("SQBRCONTRACT105") {
explicit_output_types::evaluate(&parsed.query, parsed.model, rule, &faults);
}
Expand Down
1 change: 1 addition & 0 deletions crates/sqlbuild-rules-native/src/rules/_helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub(crate) mod domain_layout;
pub(crate) mod evaluation;
pub(crate) mod explicit_output_types;
pub(crate) mod sql_test_rules;
pub(crate) mod typed_contract_columns;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use crate::constants::ENFORCED_CONTRACT;
use crate::models::{Fault, Model, RuleMetadata};
use crate::rules::models::FaultCollector;

pub(crate) fn evaluate(model: &Model, rule: &RuleMetadata, faults: &FaultCollector) {
if model
.config
.get("contract")
.and_then(serde_json::Value::as_str)
!= Some(ENFORCED_CONTRACT)
{
return;
}
for column in &model.columns {
if column.data_type.trim().is_empty() {
faults.push(Fault {
code: rule.code.clone(),
path: model.relative_path.clone(),
line: 1,
column: 1,
message: format!("contract column {:?} has no declared type", column.name),
remediation: rule.remediation.clone(),
});
}
}
}
5 changes: 5 additions & 0 deletions docs/contract-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ Contract type validation distinguishes evidence from uncertainty:
- An output whose type cannot be proven reports `K003` when type enforcement is active. This remains
a warning because incomplete analysis is not proof that otherwise valid SQL violates its contract.

`SQBRCONTRACT106` requires every column in an enforced contract to declare an adapter type. Use it
with `SQBRCONTRACT101` so every model has an enforced contract and no contract can remain a
name-only shape. Resolve unknown types at authoritative sources or explicit SQL output boundaries;
do not choose a type solely to silence the Rule.

Projects that require every contract boundary to be statically explicit can select
`SQBRCONTRACT105`. Selected Rule findings are enforced. The Rule accepts a direct column only when
its exact declared type is proven, and requires an outer `CAST` around literals, calculations,
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/src/sqlbuild/cli/commands/main/_test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ class ExplicitContractOutputRuleIntegrationTestCase:
columns_sql: str = "order_id (type INTEGER)"


@dataclass(frozen=True)
class TypedContractRuleIntegrationTestCase:
"""One fully typed contract Rule expectation through compile."""

description: str
columns_sql: str
expected_exit_code: int
expected_contract_101_findings: int
expected_contract_105_findings: int
expected_contract_106_findings: int


@dataclass(frozen=True)
class FormatCompileIntegrationTestCase:
"""One format-to-compile compatibility expectation."""
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/src/sqlbuild/cli/commands/main/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ExplicitContractOutputRuleIntegrationTestCase,
RulePassIntegrationTestCase,
RulesIntegrationTestCase,
TypedContractRuleIntegrationTestCase,
)


Expand Down Expand Up @@ -240,6 +241,60 @@ def test_given_enforced_contract_when_compiling_explicit_output_rule_then_enforc
assert diagnostic_codes.count("SQBRCONTRACT105") == test_case.expected_rule_findings


@pytest.mark.parametrize(
"test_case",
(
TypedContractRuleIntegrationTestCase(
description="enforced contract with an untyped column fails only the typed-column rule",
columns_sql='order_id (type INTEGER), status (description "Current status")',
expected_exit_code=1,
expected_contract_101_findings=0,
expected_contract_105_findings=0,
expected_contract_106_findings=1,
),
TypedContractRuleIntegrationTestCase(
description="enforced contract with fully typed explicit outputs passes",
columns_sql="order_id (type INTEGER), status (type VARCHAR)",
expected_exit_code=0,
expected_contract_101_findings=0,
expected_contract_105_findings=0,
expected_contract_106_findings=0,
),
),
ids=lambda case: case.description,
)
def test_given_contract_rules_when_compiling_then_contract_is_enforced_typed_and_explicit(
test_case: TypedContractRuleIntegrationTestCase,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> 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")],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"SQBRCONTRACT103",
"SQBRCONTRACT104",
"SQBRCONTRACT105",
"SQBRCONTRACT106",
"SQBRDECLARATION101",
"SQBRDECLARATION102",
"SQBRDECLARATION201",
Expand Down Expand Up @@ -86,6 +87,7 @@
"SQBRCONTRACT103",
"SQBRCONTRACT104",
"SQBRCONTRACT105",
"SQBRCONTRACT106",
"SQBRDECLARATION101",
"SQBRDECLARATION102",
"SQBRDECLARATION201",
Expand Down
Loading