diff --git a/crates/sqlbuild-rules-native/src/rules/_helpers/authored_literals.rs b/crates/sqlbuild-rules-native/src/rules/_helpers/authored_literals.rs index ba31300ace..59539d1b8e 100644 --- a/crates/sqlbuild-rules-native/src/rules/_helpers/authored_literals.rs +++ b/crates/sqlbuild-rules-native/src/rules/_helpers/authored_literals.rs @@ -2,6 +2,12 @@ use sqlparser::dialect::Dialect; use sqlparser::tokenizer::{Token, Tokenizer}; use std::collections::BTreeSet; +#[derive(Clone, Copy)] +struct CastScope { + depth: usize, + in_type: bool, +} + pub(super) fn numeric_literal_tokens( source: &str, dialect: &dyn Dialect, @@ -31,13 +37,51 @@ pub(super) fn numeric_literal_tokens( 0 }; let mut literals: BTreeSet = BTreeSet::new(); + let mut cast_scopes: Vec = Vec::new(); + let mut depth = 0_usize; for (index, token) in tokens[query_start..].iter().enumerate() { - let Token::Number(number, _) = token else { - continue; - }; - literals.insert(number.to_string()); - if index > 0 && matches!(tokens[query_start + index - 1], Token::Minus) { - literals.insert(format!("-{number}")); + match token { + Token::LParen => { + depth += 1; + if index > 0 + && matches!( + tokens[query_start + index - 1], + Token::Word(word) + if word.quote_style.is_none() + && matches!( + word.value.to_ascii_uppercase().as_str(), + "CAST" | "TRY_CAST" + ) + ) + { + cast_scopes.push(CastScope { + depth, + in_type: false, + }); + } + } + Token::RParen => { + if cast_scopes.last().is_some_and(|scope| scope.depth == depth) { + cast_scopes.pop(); + } + depth = depth.saturating_sub(1); + } + Token::Word(word) + if word.quote_style.is_none() + && word.value.eq_ignore_ascii_case("as") + && cast_scopes.last().is_some_and(|scope| scope.depth == depth) => + { + if let Some(scope) = cast_scopes.last_mut() { + scope.in_type = true; + } + } + Token::Number(number, _) if !cast_scopes.last().is_some_and(|scope| scope.in_type) => { + literals.insert(number.to_string()); + if index > 0 && matches!(tokens[query_start + index - 1], Token::Minus) { + literals.insert(format!("-{number}")); + } + } + _ => {} } } Ok(literals) diff --git a/tests/unit/src/sqlbuild/rule_engine/main/evaluate/test_evaluate.py b/tests/unit/src/sqlbuild/rule_engine/main/evaluate/test_evaluate.py index 44849389f6..f3e97417cc 100644 --- a/tests/unit/src/sqlbuild/rule_engine/main/evaluate/test_evaluate.py +++ b/tests/unit/src/sqlbuild/rule_engine/main/evaluate/test_evaluate.py @@ -257,6 +257,34 @@ select=("SQBRDECLARATION102",), expected_codes=(), ), + PolicyEvaluationTestCase( + description="cast precision does not authorize expanded constant value", + model_name="commerce__mart__orders", + relative_path="models/mart/commerce__mart__orders.sql", + sql=( + "SELECT CAST(CASE WHEN item_count > 38 THEN 1 ELSE 0 END " + "AS NUMBER(38, 0)) AS batch_size FROM items" + ), + authored_sql=( + 'SELECT CAST(CASE WHEN item_count > @const("large_batch") THEN 1 ELSE 0 END ' + "AS NUMBER(38, 0)) AS batch_size FROM items" + ), + config_values={"materialized": "table", "contract": "enforced"}, + select=("SQBRDECLARATION102",), + expected_codes=(), + ), + PolicyEvaluationTestCase( + description="authored numeric decision still faults when it matches cast precision", + model_name="commerce__mart__orders", + relative_path="models/mart/commerce__mart__orders.sql", + sql=( + "SELECT CAST(CASE WHEN item_count > 38 THEN 1 ELSE 0 END " + "AS NUMBER(38, 0)) AS batch_size FROM items" + ), + config_values={"materialized": "table", "contract": "enforced"}, + select=("SQBRDECLARATION102",), + expected_codes=("SQBRDECLARATION102",), + ), PolicyEvaluationTestCase( description="constant-backed numeric decision ignores digits in identifiers and strings", model_name="commerce__mart__orders",