Skip to content

Commit 6be7c8d

Browse files
committed
feat: require types on enforced contract columns
1 parent 355d5e9 commit 6be7c8d

10 files changed

Lines changed: 192 additions & 1 deletion

File tree

‎crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,78 @@ fn given_repeated_native_evaluation_when_faulting_then_returns_deterministic_com
4747
Ok(())
4848
}
4949

50+
#[test]
51+
fn given_enforced_contract_when_column_type_is_missing_then_typed_contract_rule_faults()
52+
-> Result<(), String> {
53+
let test_cases = [
54+
test_types::TypedContractColumnTestCase {
55+
description: "enforced contract has one untyped column",
56+
contract: "enforced",
57+
columns: json!([
58+
{"name": "order_id", "type": "INTEGER", "type_proven": true},
59+
{"name": "status", "type": "", "type_proven": false}
60+
]),
61+
expected_fault_count: 1,
62+
expected_messages: &["contract column \"status\" has no declared type"],
63+
},
64+
test_types::TypedContractColumnTestCase {
65+
description: "enforced contract has only typed columns",
66+
contract: "enforced",
67+
columns: json!([
68+
{"name": "order_id", "type": "INTEGER", "type_proven": true}
69+
]),
70+
expected_fault_count: 0,
71+
expected_messages: &[],
72+
},
73+
test_types::TypedContractColumnTestCase {
74+
description: "unenforced column metadata remains outside this rule",
75+
contract: "none",
76+
columns: json!([
77+
{"name": "status", "type": "", "type_proven": false}
78+
]),
79+
expected_fault_count: 0,
80+
expected_messages: &[],
81+
},
82+
];
83+
84+
for test_case in test_cases {
85+
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
86+
let mut request: Value = serde_json::from_str(&helpers::request(
87+
&project_dir,
88+
&json!({
89+
"select": ["SQBRCONTRACT106"],
90+
"cache": {"enabled": false}
91+
}),
92+
))
93+
.map_err(|error| error.to_string())?;
94+
request["models"][0]["config"] = json!({"contract": test_case.contract});
95+
request["models"][0]["columns"] = test_case.columns;
96+
97+
let result: Value = serde_json::from_str(&evaluate_json(&request.to_string())?)
98+
.map_err(|error| error.to_string())?;
99+
let faults = result["faults"]
100+
.as_array()
101+
.ok_or_else(|| "faults must be an array".to_string())?;
102+
let messages = faults
103+
.iter()
104+
.filter_map(|fault| fault["message"].as_str())
105+
.collect::<Vec<_>>();
106+
107+
assert_eq!(
108+
faults.len(),
109+
test_case.expected_fault_count,
110+
"{}",
111+
test_case.description
112+
);
113+
assert_eq!(
114+
messages, test_case.expected_messages,
115+
"{}",
116+
test_case.description
117+
);
118+
}
119+
Ok(())
120+
}
121+
50122
#[test]
51123
fn given_scoped_suppression_when_evaluating_native_fault_then_returns_no_faults()
52124
-> Result<(), String> {

‎crates/sqlbuild-rules-native/src/engine/tests/test_types.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ pub(crate) struct ExplicitOutputTypeTestCase {
2929
pub(crate) expected_message_fragment: &'static str,
3030
}
3131

32+
pub(crate) struct TypedContractColumnTestCase {
33+
pub(crate) description: &'static str,
34+
pub(crate) contract: &'static str,
35+
pub(crate) columns: Value,
36+
pub(crate) expected_fault_count: usize,
37+
pub(crate) expected_messages: &'static [&'static str],
38+
}
39+
3240
pub(crate) struct NormalizationTestCase {
3341
pub(crate) description: &'static str,
3442
pub(crate) dialect: &'static str,

‎crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ pub(crate) fn catalogue() -> Vec<RuleMetadata> {
173173
"contract outputs must establish their declared types explicitly",
174174
"Pass through a column whose exact type is proven, or wrap the complete output expression in an explicit CAST to its declared contract type.",
175175
),
176+
rule!(
177+
"SQBRCONTRACT106",
178+
"contracts",
179+
"typed-contract-columns",
180+
"enforced contract columns must declare types",
181+
"Declare an adapter type for every column in the enforced contract; fix upstream type inference or cast the SQL output before choosing the type.",
182+
),
176183
rule!(
177184
"SQBRDECLARATION101",
178185
"declarations",

‎crates/sqlbuild-rules-native/src/rules/_helpers/evaluation.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::constants::{
55
use crate::models::{Declaration, EvaluateRequest, Fault, Model, RuleMetadata, RulesConfig};
66
use crate::rules::_helpers::authored_literals::numeric_literal_tokens;
77
use crate::rules::_helpers::domain_layout::folder_layer_details;
8-
use crate::rules::_helpers::explicit_output_types;
8+
use crate::rules::_helpers::{explicit_output_types, typed_contract_columns};
99
use crate::rules::models::{
1010
FaultCollector, ModelEvaluationRequest, ProjectEvaluationRequest, ResolvedThresholdOverride,
1111
};
@@ -176,6 +176,9 @@ fn evaluate_model_inner(request: ModelEvaluationRequest<'_>) -> Result<Vec<Fault
176176
if let Some(rule) = metadata("SQBRCONTRACT101") {
177177
contract_required(&parsed, rule, &faults);
178178
}
179+
if let Some(rule) = metadata("SQBRCONTRACT106") {
180+
typed_contract_columns::evaluate(parsed.model, rule, &faults);
181+
}
179182
if let Some(rule) = metadata("SQBRCONTRACT105") {
180183
explicit_output_types::evaluate(&parsed.query, parsed.model, rule, &faults);
181184
}

‎crates/sqlbuild-rules-native/src/rules/_helpers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub(crate) mod domain_layout;
44
pub(crate) mod evaluation;
55
pub(crate) mod explicit_output_types;
66
pub(crate) mod sql_test_rules;
7+
pub(crate) mod typed_contract_columns;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use crate::constants::ENFORCED_CONTRACT;
2+
use crate::models::{Fault, Model, RuleMetadata};
3+
use crate::rules::models::FaultCollector;
4+
5+
pub(crate) fn evaluate(model: &Model, rule: &RuleMetadata, faults: &FaultCollector) {
6+
if model
7+
.config
8+
.get("contract")
9+
.and_then(serde_json::Value::as_str)
10+
!= Some(ENFORCED_CONTRACT)
11+
{
12+
return;
13+
}
14+
for column in &model.columns {
15+
if column.data_type.trim().is_empty() {
16+
faults.push(Fault {
17+
code: rule.code.clone(),
18+
path: model.relative_path.clone(),
19+
line: 1,
20+
column: 1,
21+
message: format!("contract column {:?} has no declared type", column.name),
22+
remediation: rule.remediation.clone(),
23+
});
24+
}
25+
}
26+
}

‎docs/contract-adoption.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ Contract type validation distinguishes evidence from uncertainty:
8383
- An output whose type cannot be proven reports `K003` when type enforcement is active. This remains
8484
a warning because incomplete analysis is not proof that otherwise valid SQL violates its contract.
8585

86+
`SQBRCONTRACT106` requires every column in an enforced contract to declare an adapter type. Use it
87+
with `SQBRCONTRACT101` so every model has an enforced contract and no contract can remain a
88+
name-only shape. Resolve unknown types at authoritative sources or explicit SQL output boundaries;
89+
do not choose a type solely to silence the Rule.
90+
8691
Projects that require every contract boundary to be statically explicit can select
8792
`SQBRCONTRACT105`. Selected Rule findings are enforced. The Rule accepts a direct column only when
8893
its exact declared type is proven, and requires an outer `CAST` around literals, calculations,

‎tests/integration/src/sqlbuild/cli/commands/main/_test_types.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ class ExplicitContractOutputRuleIntegrationTestCase:
3434
columns_sql: str = "order_id (type INTEGER)"
3535

3636

37+
@dataclass(frozen=True)
38+
class TypedContractRuleIntegrationTestCase:
39+
"""One fully typed contract Rule expectation through compile."""
40+
41+
description: str
42+
columns_sql: str
43+
expected_exit_code: int
44+
expected_contract_101_findings: int
45+
expected_contract_105_findings: int
46+
expected_contract_106_findings: int
47+
48+
3749
@dataclass(frozen=True)
3850
class FormatCompileIntegrationTestCase:
3951
"""One format-to-compile compatibility expectation."""

‎tests/integration/src/sqlbuild/cli/commands/main/test_rules.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ExplicitContractOutputRuleIntegrationTestCase,
1515
RulePassIntegrationTestCase,
1616
RulesIntegrationTestCase,
17+
TypedContractRuleIntegrationTestCase,
1718
)
1819

1920

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

242243

244+
@pytest.mark.parametrize(
245+
"test_case",
246+
(
247+
TypedContractRuleIntegrationTestCase(
248+
description="enforced contract with an untyped column fails only the typed-column rule",
249+
columns_sql='order_id (type INTEGER), status (description "Current status")',
250+
expected_exit_code=1,
251+
expected_contract_101_findings=0,
252+
expected_contract_105_findings=0,
253+
expected_contract_106_findings=1,
254+
),
255+
TypedContractRuleIntegrationTestCase(
256+
description="enforced contract with fully typed explicit outputs passes",
257+
columns_sql="order_id (type INTEGER), status (type VARCHAR)",
258+
expected_exit_code=0,
259+
expected_contract_101_findings=0,
260+
expected_contract_105_findings=0,
261+
expected_contract_106_findings=0,
262+
),
263+
),
264+
ids=lambda case: case.description,
265+
)
266+
def test_given_contract_rules_when_compiling_then_contract_is_enforced_typed_and_explicit(
267+
test_case: TypedContractRuleIntegrationTestCase,
268+
tmp_path: Path,
269+
capsys: pytest.CaptureFixture[str],
270+
) -> None:
271+
(tmp_path / "sqlbuild_project.toml").write_text(
272+
'name = "orders"\nadapter = "duckdb"\n\n[rules]\n'
273+
'select = ["SQBRCONTRACT101", "SQBRCONTRACT105", "SQBRCONTRACT106"]\n',
274+
encoding="utf-8",
275+
)
276+
model: Path = tmp_path / "models" / "orders.sql"
277+
model.parent.mkdir()
278+
model.write_text(
279+
"MODEL (\n"
280+
" contract enforced,\n"
281+
f" columns ({test_case.columns_sql}),\n"
282+
");\n\n"
283+
"SELECT CAST(1 AS INTEGER) AS order_id, CAST('ready' AS VARCHAR) AS status\n",
284+
encoding="utf-8",
285+
)
286+
287+
exit_code: int = main(["--project-dir", str(tmp_path), "compile", "--json", "--no-cache"])
288+
result: dict[str, object] = json.loads(capsys.readouterr().out)
289+
diagnostics: list[dict[str, object]] = cast(list[dict[str, object]], result["diagnostics"])
290+
diagnostic_codes: tuple[object, ...] = tuple(item["code"] for item in diagnostics)
291+
292+
assert exit_code == test_case.expected_exit_code
293+
assert diagnostic_codes.count("SQBRCONTRACT101") == (test_case.expected_contract_101_findings)
294+
assert diagnostic_codes.count("SQBRCONTRACT105") == (test_case.expected_contract_105_findings)
295+
assert diagnostic_codes.count("SQBRCONTRACT106") == (test_case.expected_contract_106_findings)
296+
297+
243298
@pytest.mark.parametrize(
244299
"test_case",
245300
[RulesIntegrationTestCase("selected SQL rule blocks artifacts", 1, "SQBRSQL004")],

‎tests/unit/src/sqlbuild/rule_engine/_helpers/engine/test_catalogue.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"SQBRCONTRACT103",
3131
"SQBRCONTRACT104",
3232
"SQBRCONTRACT105",
33+
"SQBRCONTRACT106",
3334
"SQBRDECLARATION101",
3435
"SQBRDECLARATION102",
3536
"SQBRDECLARATION201",
@@ -86,6 +87,7 @@
8687
"SQBRCONTRACT103",
8788
"SQBRCONTRACT104",
8889
"SQBRCONTRACT105",
90+
"SQBRCONTRACT106",
8991
"SQBRDECLARATION101",
9092
"SQBRDECLARATION102",
9193
"SQBRDECLARATION201",

0 commit comments

Comments
 (0)