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
21 changes: 21 additions & 0 deletions crates/sqlbuild-rules-native/src/configuration/_helpers/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn validate_raw(value: &toml::Value) -> Result<(), String> {
"threshold_overrides",
"rule_options",
"rule_exceptions",
"graph_edge_exceptions",
"rule_ignores",
"select_star_allow",
"domains",
Expand All @@ -69,6 +70,10 @@ fn validate_raw(value: &toml::Value) -> Result<(), String> {
}
for (key, fields) in [
("rule_exceptions", ["rule", "path", "reason"].as_slice()),
(
"graph_edge_exceptions",
["consumer", "dependency", "reason"].as_slice(),
),
("rule_ignores", ["rules", "reason"].as_slice()),
("select_star_allow", ["paths", "reason"].as_slice()),
(
Expand Down Expand Up @@ -197,6 +202,22 @@ pub(crate) fn validate(config: &RulesConfig) -> Result<(), String> {
return Err(format!("invalid rule code: {}", exception.rule));
}
}
let mut graph_edges: std::collections::BTreeSet<(&str, &str)> =
std::collections::BTreeSet::new();
for exception in &config.graph_edge_exceptions {
if exception.consumer.trim().is_empty()
|| exception.dependency.trim().is_empty()
|| exception.reason.trim().is_empty()
{
return Err("rules.graph_edge_exceptions fields must be non-empty strings".into());
}
if !graph_edges.insert((&exception.consumer, &exception.dependency)) {
return Err(format!(
"duplicate graph edge exception: {} -> {}",
exception.consumer, exception.dependency
));
}
}
for ignore in &config.rule_ignores {
if ignore.rules.is_empty()
|| (ignore.paths.is_empty() && ignore.selectors.is_empty())
Expand Down
6 changes: 6 additions & 0 deletions crates/sqlbuild-rules-native/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub(crate) const GENERIC_CASE_NAME: &str = "case";
pub(crate) const CUSTOM_RULE_NAMESPACE: &str = "XSQBR";
pub(crate) const TARGET_DIRECTORY: &str = "target";
pub(crate) const REFERENCE_KIND: &str = "ref";
pub(crate) const MODEL_SCHEMA_CONFIG_KEY: &str = "schema";
pub(crate) const STAGING_LAYER_DIRECTORY: &str = "staging";
pub(crate) const INTERMEDIATE_LAYER_DIRECTORY: &str = "intermediate";
pub(crate) const MART_LAYER_DIRECTORY: &str = "mart";
pub(crate) const SOURCE_REFERENCE_KIND: &str = "source";
pub(crate) const DBT_REFERENCE_KIND: &str = "dbt_ref";
pub(crate) const TABLE_FUNCTION_REFERENCE_KIND: &str = "table_function";
Expand Down Expand Up @@ -37,6 +41,8 @@ pub(crate) const INLINE_SQL_HOOK: &str = "inline_sql";
pub(crate) const MACRO_TEST_MODE: &str = "macro";
pub(crate) const OPEN_PAREN: &str = "(";
pub(crate) const CLOSE_PAREN: &str = ")";
pub(crate) const EMPTY_FIXTURE_QUERY_TOKEN_COUNT: usize = 6;
pub(crate) const ENCLOSING_CTE_TOKEN_COUNT: usize = 3;
pub(crate) const OUTSIDE_KEY: &str = "outside";
pub(crate) const SQL_HOOK: &str = "sql";
pub(crate) const TABLE_FUNCTION_TEST_MODE: &str = "table_fn";
Expand Down
152 changes: 152 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 @@ -6,6 +6,158 @@ use crate::engine::tests::helpers;
use crate::engine::tests::test_types;
use crate::rules::_helpers::evaluation::normalize_rules_sql;

#[test]
fn given_conflicting_model_identity_when_evaluating_layer_rules_then_reports_independent_findings()
-> Result<(), String> {
let test_cases = [test_types::ModelLayerRulesTestCase {
description: "conflicting model metadata reports every independent boundary",
expected_codes: &[
"SQBRMODEL103",
"SQBRMODEL104",
"SQBRPROJECT101",
"SQBRPROJECT104",
"SQBRPROJECT105",
"SQBRPROJECT106",
],
}];
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
let config = json!({
"select": [
"SQBRMODEL103",
"SQBRMODEL104",
"SQBRPROJECT101",
"SQBRPROJECT104",
"SQBRPROJECT105",
"SQBRPROJECT106"
],
"cache": {"enabled": false}
});
let mut request: Value = serde_json::from_str(&helpers::request(&project_dir, &config))
.map_err(|error| error.to_string())?;
request["models"][0] = json!({
"name": "commerce__mart_v__int_v_orders",
"relative_path": "models/commerce/intermediate/enriched/orders/commerce__mart_v__int_v_orders.sql",
"query_sql": "SELECT 1 AS order_id",
"authored_sql": "SELECT 1 AS order_id",
"config": {"materialized": "table", "schema": "staging"},
"authored_config_keys": ["materialized", "schema"],
"logical_schema": "staging",
"references": [{"ref_kind": "ref", "ref_name": "commerce__mart__stg_orders"}]
});

for test_case in test_cases {
let result: Value = serde_json::from_str(&evaluate_json(&request.to_string())?)
.map_err(|error| error.to_string())?;
let codes: Vec<&str> = result["faults"]
.as_array()
.into_iter()
.flatten()
.filter_map(|fault| fault["code"].as_str())
.collect();

assert_eq!(codes, test_case.expected_codes, "{}", test_case.description);
}
Ok(())
}

#[test]
fn given_internal_dependency_edges_when_evaluating_graph_rule_then_only_exact_live_exceptions_apply()
-> Result<(), String> {
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
let models = json!([
{
"name": "commerce__stg__orders",
"relative_path": "models/commerce/staging/commerce__stg__orders.sql",
"query_sql": "SELECT 1",
"authored_sql": "SELECT 1",
"references": [
{"ref_kind": "ref", "ref_name": "commerce__int_enriched__orders"},
{"ref_kind": "ref", "ref_name": "commerce__mart__customers"}
]
},
{
"name": "commerce__int_clean__orders",
"relative_path": "models/commerce/intermediate/clean/commerce__int_clean__orders.sql",
"query_sql": "SELECT 1",
"authored_sql": "SELECT 1",
"references": [{"ref_kind": "ref", "ref_name": "commerce__int_enriched__customers"}]
},
{
"name": "commerce__mart__summary",
"relative_path": "models/commerce/mart/commerce__mart__summary.sql",
"query_sql": "SELECT 1",
"authored_sql": "SELECT 1",
"references": [{"ref_kind": "ref", "ref_name": "commerce__int_enriched__orders"}]
}
]);
let test_cases = [
test_types::GraphRuleTestCase {
description: "unexcluded internal inversions",
exceptions: json!([]),
expected_fault_count: 2,
expected_message_fragments: &[],
},
test_types::GraphRuleTestCase {
description: "exact live exceptions",
exceptions: json!([
{
"consumer": "commerce__stg__orders",
"dependency": "commerce__int_enriched__orders",
"reason": "Tracked cleanup"
},
{
"consumer": "commerce__int_clean__orders",
"dependency": "commerce__int_enriched__customers",
"reason": "Tracked cleanup"
}
]),
expected_fault_count: 0,
expected_message_fragments: &[],
},
test_types::GraphRuleTestCase {
description: "stale exact exception",
exceptions: json!([{
"consumer": "commerce__stg__orders",
"dependency": "commerce__int_clean__missing",
"reason": "Resolved cleanup"
}]),
expected_fault_count: 3,
expected_message_fragments: &["graph edge exception is stale"],
},
];
for test_case in test_cases {
let config = json!({
"select": ["SQBRGRAPH101"],
"graph_edge_exceptions": test_case.exceptions,
"cache": {"enabled": false}
});
let mut request: Value = serde_json::from_str(&helpers::request(&project_dir, &config))
.map_err(|error| error.to_string())?;
request["models"] = models.clone();
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("faults must be an array")?;
assert_eq!(
faults.len(),
test_case.expected_fault_count,
"{}",
test_case.description
);
for fragment in test_case.expected_message_fragments {
assert!(
faults.iter().any(|fault| fault["message"]
.as_str()
.is_some_and(|message| message.contains(fragment))),
"{}",
test_case.description
);
}
}
Ok(())
}

#[test]
fn given_repeated_native_evaluation_when_faulting_then_returns_deterministic_complete_facts()
-> Result<(), String> {
Expand Down
12 changes: 12 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 @@ -6,6 +6,18 @@ pub(crate) struct NativeEvaluationTestCase {
pub(crate) expected_faults: Value,
}

pub(crate) struct ModelLayerRulesTestCase {
pub(crate) description: &'static str,
pub(crate) expected_codes: &'static [&'static str],
}

pub(crate) struct GraphRuleTestCase {
pub(crate) description: &'static str,
pub(crate) exceptions: Value,
pub(crate) expected_fault_count: usize,
pub(crate) expected_message_fragments: &'static [&'static str],
}

pub(crate) struct DynamicPivotEvaluationTestCase {
pub(crate) description: &'static str,
pub(crate) expected_faults: Value,
Expand Down
12 changes: 12 additions & 0 deletions crates/sqlbuild-rules-native/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ pub(crate) struct RuleException {
pub reason: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, Eq, Ord, PartialEq, PartialOrd)]
#[serde(deny_unknown_fields)]
pub(crate) struct GraphEdgeException {
pub consumer: String,
pub dependency: String,
pub reason: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RuleIgnore {
Expand Down Expand Up @@ -135,6 +143,7 @@ pub(crate) struct RulesConfig {
pub threshold_overrides: Vec<ThresholdOverride>,
pub rule_options: BTreeMap<String, BTreeMap<String, Value>>,
pub rule_exceptions: Vec<RuleException>,
pub graph_edge_exceptions: Vec<GraphEdgeException>,
pub rule_ignores: Vec<RuleIgnore>,
pub select_star_allow: Vec<SelectStarAllow>,
pub domains: Vec<String>,
Expand All @@ -154,6 +163,7 @@ impl Default for RulesConfig {
threshold_overrides: vec![],
rule_options: BTreeMap::new(),
rule_exceptions: vec![],
graph_edge_exceptions: vec![],
rule_ignores: vec![],
select_star_allow: vec![],
domains: vec![],
Expand Down Expand Up @@ -508,6 +518,8 @@ pub(crate) struct Model {
pub query_sql: String,
pub authored_sql: String,
pub config: BTreeMap<String, Value>,
pub authored_config_keys: Vec<String>,
pub logical_schema: Option<String>,
pub references: Vec<Reference>,
pub columns: Vec<Column>,
pub dynamic_columns: Vec<DynamicColumnFamily>,
Expand Down
24 changes: 23 additions & 1 deletion crates/sqlbuild-rules-native/src/rules/_helpers/catalogue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ macro_rules! rule {
| "SQBRPROJECT202"
| "SQBRPROJECT203"
| "SQBRPROJECT204"
| "SQBRGRAPH101"
| "SQBRTEST101"
| "SQBRTEST102"
| "SQBRTEST103"
Expand Down Expand Up @@ -66,7 +67,14 @@ pub(crate) fn catalogue() -> Vec<RuleMetadata> {
"structure",
"view-marker",
"view materialization and model v marker must agree",
"Use stg_v/int_v/mart_v for a view, or change the materialization to match the non-view layer name.",
"Use stg_v/int_v/mart_v for a view, or change the materialization to match the non-view layer name. Keep _v only in the canonical layer segment.",
),
rule!(
"SQBRMODEL104",
"structure",
"central-model-schema",
"model schemas must come from central path defaults",
"Place the model under the folder matching its semantic layer, then remove the schema declaration. Configure intentional schema differences centrally in sqlbuild_project.toml.",
),
rule!(
"SQBRGRAPH101",
Expand Down Expand Up @@ -110,6 +118,20 @@ pub(crate) fn catalogue() -> Vec<RuleMetadata> {
"referenced model identifiers must follow rule naming grammar",
"Rename the referenced model and this __ref to the rule model grammar.",
),
rule!(
"SQBRPROJECT105",
"repository",
"name-folder-alignment",
"model name layer must match its canonical folder",
"Make the model name and canonical layer folder agree. Choose the layer from the model's semantic responsibility, not from its dependencies.",
),
rule!(
"SQBRPROJECT106",
"repository",
"name-schema-alignment",
"model name layer must match its effective logical schema",
"Correct the central path default or move the model to its intended semantic-layer folder. Do not add a model-local schema declaration.",
),
rule!(
"SQBRCONTRACT101",
"contracts",
Expand Down
Loading
Loading