Skip to content

Commit f971f4a

Browse files
committed
feat: configure authoritative contract name types
1 parent c30934a commit f971f4a

9 files changed

Lines changed: 286 additions & 54 deletions

File tree

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,81 @@ fn given_enforced_contract_outputs_when_evaluating_explicit_type_rule_then_retur
544544
Ok(())
545545
}
546546

547+
#[test]
548+
fn given_contract_name_type_options_when_evaluating_then_accepts_configured_interfaces()
549+
-> Result<(), String> {
550+
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
551+
let test_cases = [
552+
test_types::ContractNameTypeOptionsTestCase {
553+
description: "configured compatibility types are accepted",
554+
config: json!({
555+
"select": ["SQBRCONTRACT102", "SQBRCONTRACT103", "SQBRCONTRACT104"],
556+
"rule_options": {
557+
"SQBRCONTRACT102": {"allow_numeric_indicators": true},
558+
"SQBRCONTRACT103": {
559+
"allow_date_for_at": true,
560+
"allow_numeric_epoch": true,
561+
"allow_encoded_values": true
562+
},
563+
"SQBRCONTRACT104": {
564+
"allow_timestamps": true,
565+
"allow_encoded_values": true
566+
}
567+
},
568+
"cache": {"enabled": false}
569+
}),
570+
expected_fault_codes: &[],
571+
},
572+
test_types::ContractNameTypeOptionsTestCase {
573+
description: "compatibility types remain findings by default",
574+
config: json!({
575+
"select": ["SQBRCONTRACT102", "SQBRCONTRACT103", "SQBRCONTRACT104"],
576+
"cache": {"enabled": false}
577+
}),
578+
expected_fault_codes: &[
579+
"SQBRCONTRACT102",
580+
"SQBRCONTRACT103",
581+
"SQBRCONTRACT103",
582+
"SQBRCONTRACT103",
583+
"SQBRCONTRACT104",
584+
"SQBRCONTRACT104",
585+
],
586+
},
587+
];
588+
589+
for test_case in test_cases {
590+
let mut request: Value =
591+
serde_json::from_str(&helpers::request(&project_dir, &test_case.config))
592+
.map_err(|error| error.to_string())?;
593+
request["models"][0]["query_sql"] = json!(
594+
"SELECT is_ready, first_seen_at, source_timestamp, opaque_ts, raw_date, observed_date FROM orders"
595+
);
596+
request["models"][0]["columns"] = json!([
597+
{"name": "is_ready", "type": "INTEGER"},
598+
{"name": "first_seen_at", "type": "DATE"},
599+
{"name": "source_timestamp", "type": "NUMBER(38,0)"},
600+
{"name": "opaque_ts", "type": "VARIANT"},
601+
{"name": "raw_date", "type": "VARCHAR"},
602+
{"name": "observed_date", "type": "TIMESTAMP_NTZ"}
603+
]);
604+
605+
let result: Value = serde_json::from_str(&evaluate_json(&request.to_string())?)
606+
.map_err(|error| error.to_string())?;
607+
let fault_codes: Vec<&str> = result["faults"]
608+
.as_array()
609+
.into_iter()
610+
.flatten()
611+
.filter_map(|fault| fault["code"].as_str())
612+
.collect();
613+
assert_eq!(
614+
fault_codes, test_case.expected_fault_codes,
615+
"{}",
616+
test_case.description
617+
);
618+
}
619+
Ok(())
620+
}
621+
547622
#[test]
548623
fn given_snowflake_parser_extensions_when_normalizing_then_preserves_source_positions() {
549624
let test_cases = [test_types::NormalizationTestCase {

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

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

32+
pub(crate) struct ContractNameTypeOptionsTestCase {
33+
pub(crate) description: &'static str,
34+
pub(crate) config: Value,
35+
pub(crate) expected_fault_codes: &'static [&'static str],
36+
}
37+
3238
pub(crate) struct TypedContractColumnTestCase {
3339
pub(crate) description: &'static str,
3440
pub(crate) contract: &'static str,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use crate::constants::{BOOLEAN_TYPE, DATE_TYPE, TIMESTAMP_TYPE};
2+
use crate::models::{Column, RulesConfig};
3+
4+
const BOOLEAN_COLUMN_RULE: &str = "SQBRCONTRACT102";
5+
const TIMESTAMP_COLUMN_RULE: &str = "SQBRCONTRACT103";
6+
const DATE_COLUMN_RULE: &str = "SQBRCONTRACT104";
7+
8+
pub(crate) fn finding(column: &Column, config: &RulesConfig) -> Option<(&'static str, String)> {
9+
let data_type = column.data_type.to_ascii_uppercase();
10+
if column.name.starts_with("is_")
11+
|| column.name.starts_with("has_")
12+
|| column.name.starts_with("can_")
13+
{
14+
let allowed_numeric =
15+
rule_option_enabled(config, BOOLEAN_COLUMN_RULE, "allow_numeric_indicators")
16+
&& numeric_type(&data_type);
17+
return (data_type != BOOLEAN_TYPE && !allowed_numeric).then(|| {
18+
(
19+
BOOLEAN_COLUMN_RULE,
20+
format!(
21+
"column {:?} implies BOOLEAN but is typed {data_type}",
22+
column.name
23+
),
24+
)
25+
});
26+
}
27+
if column.name.ends_with("_at")
28+
|| column.name.ends_with("_ts")
29+
|| column.name.ends_with("_timestamp")
30+
{
31+
let allowed_date = column.name.ends_with("_at")
32+
&& data_type == DATE_TYPE
33+
&& rule_option_enabled(config, TIMESTAMP_COLUMN_RULE, "allow_date_for_at");
34+
let allowed_epoch = numeric_type(&data_type)
35+
&& rule_option_enabled(config, TIMESTAMP_COLUMN_RULE, "allow_numeric_epoch");
36+
let allowed_encoded = encoded_temporal_type(&data_type)
37+
&& rule_option_enabled(config, TIMESTAMP_COLUMN_RULE, "allow_encoded_values");
38+
return (!data_type.contains(TIMESTAMP_TYPE)
39+
&& !allowed_date
40+
&& !allowed_epoch
41+
&& !allowed_encoded)
42+
.then(|| {
43+
(
44+
TIMESTAMP_COLUMN_RULE,
45+
format!(
46+
"column {:?} implies a timestamp but is typed {data_type}",
47+
column.name
48+
),
49+
)
50+
});
51+
}
52+
if column.name.ends_with("_date") {
53+
let allowed_timestamp = data_type.contains(TIMESTAMP_TYPE)
54+
&& rule_option_enabled(config, DATE_COLUMN_RULE, "allow_timestamps");
55+
let allowed_encoded = encoded_temporal_type(&data_type)
56+
&& rule_option_enabled(config, DATE_COLUMN_RULE, "allow_encoded_values");
57+
return (data_type != DATE_TYPE && !allowed_timestamp && !allowed_encoded).then(|| {
58+
(
59+
DATE_COLUMN_RULE,
60+
format!(
61+
"column {:?} implies DATE but is typed {data_type}",
62+
column.name
63+
),
64+
)
65+
});
66+
}
67+
None
68+
}
69+
70+
fn rule_option_enabled(config: &RulesConfig, code: &str, option: &str) -> bool {
71+
config
72+
.rule_options
73+
.get(code)
74+
.and_then(|options| options.get(option))
75+
.and_then(serde_json::Value::as_bool)
76+
.unwrap_or(false)
77+
}
78+
79+
fn numeric_type(data_type: &str) -> bool {
80+
[
81+
"BIGINT", "DECIMAL", "DOUBLE", "FLOAT", "INT", "NUMBER", "NUMERIC", "REAL", "SMALLINT",
82+
]
83+
.iter()
84+
.any(|prefix| data_type.starts_with(prefix))
85+
}
86+
87+
fn encoded_temporal_type(data_type: &str) -> bool {
88+
["CHAR", "STRING", "TEXT", "VARCHAR", "VARIANT"]
89+
.iter()
90+
.any(|prefix| data_type.starts_with(prefix))
91+
}

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

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use crate::constants::{
2-
BOOLEAN_TYPE, DATE_TYPE, DECLARATION_DOMAIN_COMPONENTS, ENFORCED_CONTRACT, NEGATION_OPERATOR,
3-
REFERENCE_KIND, SOURCE_REFERENCE_KIND, TIMESTAMP_TYPE, VIEW_MATERIALIZATION,
2+
DECLARATION_DOMAIN_COMPONENTS, ENFORCED_CONTRACT, NEGATION_OPERATOR, REFERENCE_KIND,
3+
SOURCE_REFERENCE_KIND, VIEW_MATERIALIZATION,
44
};
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, typed_contract_columns};
8+
use crate::rules::_helpers::{contract_name_types, explicit_output_types, typed_contract_columns};
99
use crate::rules::models::{
1010
FaultCollector, ModelEvaluationRequest, ProjectEvaluationRequest, ResolvedThresholdOverride,
1111
};
@@ -182,7 +182,7 @@ fn evaluate_model_inner(request: ModelEvaluationRequest<'_>) -> Result<Vec<Fault
182182
if let Some(rule) = metadata("SQBRCONTRACT105") {
183183
explicit_output_types::evaluate(&parsed.query, parsed.model, rule, &faults);
184184
}
185-
evaluate_naming_rules(&parsed, selected, &faults);
185+
evaluate_naming_rules(&parsed, config, selected, &faults);
186186
evaluate_literal_rules(&parsed, selected, &faults);
187187
evaluate_test_rules(TestRuleEvaluation {
188188
parsed: &parsed,
@@ -1317,51 +1317,12 @@ fn contract_required(parsed: &ParsedModel<'_>, rule: &RuleMetadata, faults: &Fau
13171317

13181318
fn evaluate_naming_rules(
13191319
parsed: &ParsedModel<'_>,
1320+
config: &RulesConfig,
13201321
selected: &BTreeMap<String, &RuleMetadata>,
13211322
faults: &FaultCollector,
13221323
) {
13231324
for column in &parsed.model.columns {
1324-
let data_type = column.data_type.to_ascii_uppercase();
1325-
let rule_and_message = if column.name.starts_with("is_")
1326-
|| column.name.starts_with("has_")
1327-
|| column.name.starts_with("can_")
1328-
{
1329-
(data_type != BOOLEAN_TYPE).then(|| {
1330-
(
1331-
"SQBRCONTRACT102",
1332-
format!(
1333-
"column {:?} implies BOOLEAN but is typed {data_type}",
1334-
column.name
1335-
),
1336-
)
1337-
})
1338-
} else if column.name.ends_with("_at")
1339-
|| column.name.ends_with("_ts")
1340-
|| column.name.ends_with("_timestamp")
1341-
{
1342-
(!data_type.contains(TIMESTAMP_TYPE)).then(|| {
1343-
(
1344-
"SQBRCONTRACT103",
1345-
format!(
1346-
"column {:?} implies a timestamp but is typed {data_type}",
1347-
column.name
1348-
),
1349-
)
1350-
})
1351-
} else if column.name.ends_with("_date") {
1352-
(data_type != DATE_TYPE).then(|| {
1353-
(
1354-
"SQBRCONTRACT104",
1355-
format!(
1356-
"column {:?} implies DATE but is typed {data_type}",
1357-
column.name
1358-
),
1359-
)
1360-
})
1361-
} else {
1362-
None
1363-
};
1364-
if let Some((code, message)) = rule_and_message
1325+
if let Some((code, message)) = contract_name_types::finding(column, config)
13651326
&& let Some(rule) = selected.get(code)
13661327
{
13671328
faults.push(custom_fault!(parsed.model, rule, None, message, None));

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub(crate) mod authored_literals;
22
pub(crate) mod catalogue;
3+
pub(crate) mod contract_name_types;
34
pub(crate) mod domain_layout;
45
pub(crate) mod evaluation;
56
pub(crate) mod explicit_output_types;

‎src/sqlbuild/compiler/compile/_helpers/analysis/cte_facts.py‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,14 +624,10 @@ def _polyglot_top_level_ctes(root: Any) -> tuple[tuple[str, Any, bool], ...]:
624624
)
625625
if not isinstance(raw_ctes, list):
626626
return ()
627-
cte_bodies: tuple[Any, ...] = tuple(
628-
child
629-
for child in root.children()
630-
if str(getattr(child, "kind", ""))
631-
in {_POLYGLOT_KIND_SELECT, *_POLYGLOT_SET_OPERATION_KINDS}
632-
)
633-
if len(cte_bodies) != len(raw_ctes):
627+
children: tuple[Any, ...] = tuple(root.children())
628+
if len(children) < len(raw_ctes):
634629
return ()
630+
cte_bodies: tuple[Any, ...] = children[-len(raw_ctes) :]
635631
ctes: list[tuple[str, Any, bool]] = []
636632
for raw_cte, body in zip(raw_ctes, cte_bodies, strict=True):
637633
if not isinstance(raw_cte, dict):

‎src/sqlbuild/rule_engine/_helpers/engine/catalogue.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@
99
from importlib.machinery import ModuleSpec
1010
from pathlib import Path
1111
from types import ModuleType
12+
from typing import cast
1213

1314
from sqlbuild.rule_engine._helpers.engine.definition import (
1415
resolve_rule_signature,
1516
rule_from_value,
1617
)
1718
from sqlbuild.rule_engine._helpers.engine.native import native_catalogue, native_selected_codes
18-
from sqlbuild.rule_engine.constants import INIT_MODULE_NAME, RULE_DECORATOR_TOKEN
19+
from sqlbuild.rule_engine.constants import (
20+
CONTRACT_BOOLEAN_NAME_CODE,
21+
CONTRACT_DATE_NAME_CODE,
22+
CONTRACT_TIMESTAMP_NAME_CODE,
23+
INIT_MODULE_NAME,
24+
RULE_DECORATOR_TOKEN,
25+
)
1926
from sqlbuild.rule_engine.exceptions import RulesError
2027
from sqlbuild.rule_engine.models import (
2128
Finding,
@@ -65,6 +72,7 @@ def _builtins() -> tuple[Rule, ...]:
6572
message=str(item["message"]),
6673
remediation=str(item["remediation"]),
6774
check=_native_builtin,
75+
options=_builtin_options(str(item["code"])),
6876
enabled_by_default=bool(item["enabled_by_default"]),
6977
subject=(RuleSubject.PROJECT if bool(item["project_wide"]) else RuleSubject.MODEL),
7078
guidance=_guidance(item.get("guidance")),
@@ -73,6 +81,67 @@ def _builtins() -> tuple[Rule, ...]:
7381
)
7482

7583

84+
def _builtin_options(code: str) -> tuple[RuleOption[object], ...]:
85+
if code == CONTRACT_BOOLEAN_NAME_CODE:
86+
return (
87+
cast(
88+
RuleOption[object],
89+
RuleOption.boolean(
90+
name="allow_numeric_indicators",
91+
default=False,
92+
description="Accept numeric is_/has_/can_ indicator columns.",
93+
),
94+
),
95+
)
96+
if code == CONTRACT_TIMESTAMP_NAME_CODE:
97+
return (
98+
cast(
99+
RuleOption[object],
100+
RuleOption.boolean(
101+
name="allow_date_for_at",
102+
default=False,
103+
description="Accept DATE for columns ending in _at.",
104+
),
105+
),
106+
cast(
107+
RuleOption[object],
108+
RuleOption.boolean(
109+
name="allow_numeric_epoch",
110+
default=False,
111+
description="Accept numeric epoch values for timestamp-named columns.",
112+
),
113+
),
114+
cast(
115+
RuleOption[object],
116+
RuleOption.boolean(
117+
name="allow_encoded_values",
118+
default=False,
119+
description="Accept string or variant encoded timestamp values.",
120+
),
121+
),
122+
)
123+
if code == CONTRACT_DATE_NAME_CODE:
124+
return (
125+
cast(
126+
RuleOption[object],
127+
RuleOption.boolean(
128+
name="allow_timestamps",
129+
default=False,
130+
description="Accept timestamp types for columns ending in _date.",
131+
),
132+
),
133+
cast(
134+
RuleOption[object],
135+
RuleOption.boolean(
136+
name="allow_encoded_values",
137+
default=False,
138+
description="Accept string or variant encoded date values.",
139+
),
140+
),
141+
)
142+
return ()
143+
144+
76145
def _guidance(value: object) -> RuleGuidance | None:
77146
if not isinstance(value, dict):
78147
return None

‎src/sqlbuild/rule_engine/constants.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
DEPENDENCY_FUNCTIONS: frozenset[str] = frozenset({"__ref", "__source"})
2727
CROSS_JOIN_KIND: str = "CROSS"
2828
CONTRACT_ENFORCED: str = "enforced"
29+
CONTRACT_BOOLEAN_NAME_CODE: str = "SQBRCONTRACT102"
30+
CONTRACT_TIMESTAMP_NAME_CODE: str = "SQBRCONTRACT103"
31+
CONTRACT_DATE_NAME_CODE: str = "SQBRCONTRACT104"
2932
MATERIALIZED_VIEW: str = "view"
3033
REFERENCE_KIND_REF: str = "ref"
3134
LAYER_INT_CLEAN: str = "int_clean"

0 commit comments

Comments
 (0)