Skip to content

Commit d261efa

Browse files
committed
fix: evaluate authored numeric decisions structurally
1 parent 8cf6272 commit d261efa

9 files changed

Lines changed: 223 additions & 113 deletions

File tree

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,85 @@ fn given_contract_name_type_options_when_evaluating_then_accepts_configured_inte
619619
Ok(())
620620
}
621621

622+
#[test]
623+
fn given_numeric_comparisons_when_evaluating_named_decisions_then_only_authored_decisions_fault()
624+
-> Result<(), String> {
625+
let project_dir = TempDir::new().map_err(|error| error.to_string())?;
626+
let test_cases = [
627+
test_types::NumericDecisionTestCase {
628+
description: "authored threshold is a named decision finding",
629+
query_sql: "SELECT amount FROM orders WHERE amount > 5",
630+
authored_sql: "SELECT amount FROM orders WHERE amount > 5",
631+
expected_fault_count: 1,
632+
},
633+
test_types::NumericDecisionTestCase {
634+
description: "expanded named constant is not an authored literal finding",
635+
query_sql: "SELECT amount FROM orders WHERE amount > 5",
636+
authored_sql: "SELECT amount FROM orders WHERE amount > @const(\"_minimum_amount\")",
637+
expected_fault_count: 0,
638+
},
639+
test_types::NumericDecisionTestCase {
640+
description: "numeric comparison shown only in a comment is not an authored finding",
641+
query_sql: "SELECT amount FROM orders WHERE amount > 5",
642+
authored_sql: concat!(
643+
"-- The expanded predicate is amount > 5.\n",
644+
"SELECT amount FROM orders WHERE amount > @const(\"_minimum_amount\")"
645+
),
646+
expected_fault_count: 0,
647+
},
648+
test_types::NumericDecisionTestCase {
649+
description: "fixed output bucket suffix makes equality structural",
650+
query_sql: concat!(
651+
"SELECT MAX(CASE WHEN offset_seconds = 30 THEN amount END) AS amount_30 ",
652+
"FROM orders"
653+
),
654+
authored_sql: concat!(
655+
"SELECT MAX(CASE WHEN offset_seconds = 30 THEN amount END) AS amount_30 ",
656+
"FROM orders"
657+
),
658+
expected_fault_count: 0,
659+
},
660+
test_types::NumericDecisionTestCase {
661+
description: "output suffix does not exempt a threshold comparison",
662+
query_sql: ("SELECT CASE WHEN amount > 30 THEN 'large' END AS amount_30 FROM orders"),
663+
authored_sql: ("SELECT CASE WHEN amount > 30 THEN 'large' END AS amount_30 FROM orders"),
664+
expected_fault_count: 1,
665+
},
666+
test_types::NumericDecisionTestCase {
667+
description: "nested comparison reports only its leaf decision",
668+
query_sql: ("SELECT CASE WHEN amount > 5 THEN status END = 'ready' AS selected FROM orders"),
669+
authored_sql: ("SELECT CASE WHEN amount > 5 THEN status END = 'ready' AS selected FROM orders"),
670+
expected_fault_count: 1,
671+
},
672+
];
673+
674+
for test_case in test_cases {
675+
let mut request: Value = serde_json::from_str(&helpers::request(
676+
&project_dir,
677+
&json!({
678+
"select": ["SQBRDECLARATION102"],
679+
"cache": {"enabled": false}
680+
}),
681+
))
682+
.map_err(|error| error.to_string())?;
683+
request["models"][0]["query_sql"] = json!(test_case.query_sql);
684+
request["models"][0]["authored_sql"] = json!(test_case.authored_sql);
685+
686+
let result: Value = serde_json::from_str(&evaluate_json(&request.to_string())?)
687+
.map_err(|error| error.to_string())?;
688+
let faults = result["faults"]
689+
.as_array()
690+
.ok_or_else(|| "faults must be an array".to_string())?;
691+
assert_eq!(
692+
faults.len(),
693+
test_case.expected_fault_count,
694+
"{}",
695+
test_case.description
696+
);
697+
}
698+
Ok(())
699+
}
700+
622701
#[test]
623702
fn given_snowflake_parser_extensions_when_normalizing_then_preserves_source_positions() {
624703
let test_cases = [test_types::NormalizationTestCase {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ pub(crate) struct ContractNameTypeOptionsTestCase {
3535
pub(crate) expected_fault_codes: &'static [&'static str],
3636
}
3737

38+
pub(crate) struct NumericDecisionTestCase {
39+
pub(crate) description: &'static str,
40+
pub(crate) query_sql: &'static str,
41+
pub(crate) authored_sql: &'static str,
42+
pub(crate) expected_fault_count: usize,
43+
}
44+
3845
pub(crate) struct TypedContractColumnTestCase {
3946
pub(crate) description: &'static str,
4047
pub(crate) contract: &'static str,

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

Lines changed: 0 additions & 88 deletions
This file was deleted.

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

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ use crate::constants::{
33
SOURCE_REFERENCE_KIND, VIEW_MATERIALIZATION,
44
};
55
use crate::models::{Declaration, EvaluateRequest, Fault, Model, RuleMetadata, RulesConfig};
6-
use crate::rules::_helpers::authored_literals::numeric_literal_tokens;
76
use crate::rules::_helpers::domain_layout::folder_layer_details;
8-
use crate::rules::_helpers::{contract_name_types, explicit_output_types, typed_contract_columns};
7+
use crate::rules::_helpers::{
8+
contract_name_types, explicit_output_types, numeric_decisions, typed_contract_columns,
9+
};
910
use crate::rules::models::{
1011
FaultCollector, ModelEvaluationRequest, ProjectEvaluationRequest, ResolvedThresholdOverride,
1112
};
@@ -35,6 +36,9 @@ struct Position {
3536
#[derive(Default)]
3637
struct ComparisonFact {
3738
position: Option<Position>,
39+
sql: String,
40+
equality: bool,
41+
output_name: Option<String>,
3842
columns: BTreeSet<String>,
3943
string_literals: Vec<String>,
4044
numeric_literals: Vec<String>,
@@ -64,7 +68,7 @@ struct ParsedModel<'a> {
6468
query: Query,
6569
model: &'a Model,
6670
classification: ModelClassification,
67-
authored_numeric_literals: BTreeSet<String>,
71+
authored_compact_sql: String,
6872
}
6973

7074
struct ModelClassification {
@@ -137,14 +141,11 @@ fn evaluate_model_inner(request: ModelEvaluationRequest<'_>) -> Result<Vec<Fault
137141
};
138142
let query = *query;
139143
let classification = classify_model(&query, model, &request.dialect)?;
140-
let authored_dialect = rules_dialect(&request.dialect);
141-
let authored_numeric_literals =
142-
numeric_literal_tokens(&model.authored_sql, authored_dialect.as_ref())?;
143144
let parsed = ParsedModel {
144145
query,
145146
model,
146147
classification,
147-
authored_numeric_literals,
148+
authored_compact_sql: numeric_decisions::compact_sql(&model.authored_sql),
148149
};
149150
if let Some(rule) = metadata("SQBRMODEL101") {
150151
import_ctes(&parsed, rule, &faults);
@@ -1361,11 +1362,22 @@ fn evaluate_literal_rules(
13611362
}
13621363
if let Some(rule) = selected.get("SQBRDECLARATION102") {
13631364
let magic = comparison.numeric_literals.iter().any(|literal| {
1364-
!matches!(literal.as_str(), "-1" | "0" | "1")
1365-
&& parsed.authored_numeric_literals.contains(literal)
1365+
numeric_decisions::is_authored_decision(numeric_decisions::AuthoredDecision {
1366+
authored_compact_sql: &parsed.authored_compact_sql,
1367+
comparison_sql: &comparison.sql,
1368+
equality: comparison.equality,
1369+
output_name: comparison.output_name.as_deref(),
1370+
literal,
1371+
})
13661372
});
13671373
if magic {
1368-
faults.push(fault(parsed.model, rule, comparison.position.as_ref()));
1374+
faults.push(custom_fault!(
1375+
parsed.model,
1376+
rule,
1377+
comparison.position.as_ref(),
1378+
format!("non-canonical numeric comparison: {}", comparison.sql),
1379+
None,
1380+
));
13691381
}
13701382
}
13711383
}
@@ -1633,11 +1645,24 @@ fn select_facts(query: &Query) -> Vec<SelectFacts> {
16331645
}
16341646
for item in &select.projection {
16351647
match item {
1636-
SelectItem::UnnamedExpr(expression)
1637-
| SelectItem::ExprWithAlias {
1638-
expr: expression, ..
1648+
SelectItem::UnnamedExpr(expression) => {
1649+
case_comparison_facts(
1650+
expression,
1651+
None,
1652+
&source_context,
1653+
&mut result.comparisons,
1654+
);
1655+
}
1656+
SelectItem::ExprWithAlias {
1657+
expr: expression,
1658+
alias,
16391659
} => {
1640-
case_comparison_facts(expression, &source_context, &mut result.comparisons);
1660+
case_comparison_facts(
1661+
expression,
1662+
Some(&alias.value),
1663+
&source_context,
1664+
&mut result.comparisons,
1665+
);
16411666
}
16421667
SelectItem::QualifiedWildcard(_, _) | SelectItem::Wildcard(_) => {}
16431668
}
@@ -1738,7 +1763,7 @@ fn join_constraint(operator: &JoinOperator) -> Option<&JoinConstraint> {
17381763
}
17391764

17401765
fn numeric_value(expression: &Expr) -> Option<String> {
1741-
match expression {
1766+
match unwrap_nested(expression) {
17421767
Expr::Value(value) => match &value.value {
17431768
Value::Number(value, _) => Some(value.to_string()),
17441769
_ => None,
@@ -1784,6 +1809,7 @@ fn comparison_facts(root: &Expr, source_context: &SourceContext, output: &mut Ve
17841809

17851810
fn case_comparison_facts(
17861811
root: &Expr,
1812+
output_name: Option<&str>,
17871813
source_context: &SourceContext,
17881814
output: &mut Vec<ComparisonFact>,
17891815
) {
@@ -1815,7 +1841,11 @@ fn case_comparison_facts(
18151841
source_context,
18161842
output,
18171843
};
1844+
let start = visitor.output.len();
18181845
let _ = root.visit(&mut visitor);
1846+
for fact in &mut visitor.output[start..] {
1847+
fact.output_name = output_name.map(str::to_owned);
1848+
}
18191849
}
18201850

18211851
fn comparison_fact(root: &Expr, source_context: &SourceContext) -> ComparisonFact {
@@ -1835,9 +1865,6 @@ fn comparison_fact(root: &Expr, source_context: &SourceContext) -> ComparisonFac
18351865
}
18361866
}
18371867
Expr::Value(value) => match &value.value {
1838-
Value::Number(number, _) => {
1839-
self.fact.numeric_literals.push(number.to_string());
1840-
}
18411868
Value::SingleQuotedString(_)
18421869
| Value::DoubleQuotedString(_)
18431870
| Value::TripleSingleQuotedString(_)
@@ -1849,11 +1876,6 @@ fn comparison_fact(root: &Expr, source_context: &SourceContext) -> ComparisonFac
18491876
}
18501877
_ => {}
18511878
},
1852-
Expr::UnaryOp { op, expr } if op.to_string() == NEGATION_OPERATOR => {
1853-
if let Some(number) = numeric_value(expr) {
1854-
self.fact.numeric_literals.push(format!("-{number}"));
1855-
}
1856-
}
18571879
_ => {}
18581880
}
18591881
ControlFlow::Continue(())
@@ -1862,13 +1884,24 @@ fn comparison_fact(root: &Expr, source_context: &SourceContext) -> ComparisonFac
18621884
let mut visitor = Values {
18631885
fact: ComparisonFact {
18641886
position: Some(position(root)),
1887+
sql: root.to_string(),
1888+
equality: matches!(
1889+
root,
1890+
Expr::BinaryOp {
1891+
op: BinaryOperator::Eq,
1892+
..
1893+
}
1894+
),
18651895
source_context: source_context.clone(),
18661896
..ComparisonFact::default()
18671897
},
18681898
};
18691899
let _ = root.visit(&mut visitor);
18701900
if let Expr::BinaryOp { left, right, .. } = root {
18711901
for operand in [left.as_ref(), right.as_ref()] {
1902+
if let Some(number) = numeric_value(operand) {
1903+
visitor.fact.numeric_literals.push(number);
1904+
}
18721905
if direct_column(operand).is_none() {
18731906
visitor.fact.modified_columns.extend(column_facts(operand));
18741907
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
pub(crate) mod authored_literals;
21
pub(crate) mod catalogue;
32
pub(crate) mod contract_name_types;
43
pub(crate) mod domain_layout;
54
pub(crate) mod evaluation;
65
pub(crate) mod explicit_output_types;
6+
pub(crate) mod numeric_decisions;
77
pub(crate) mod sql_test_rules;
88
pub(crate) mod typed_contract_columns;

0 commit comments

Comments
 (0)