diff --git a/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs b/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs index 1fa8be96ef..cc183887f2 100644 --- a/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs +++ b/crates/sqlbuild-rules-native/src/engine/tests/test_evaluation.rs @@ -359,6 +359,17 @@ fn given_enforced_contract_outputs_when_evaluating_explicit_type_rule_then_retur expected_fault_count: 0, expected_message_fragment: "", }, + test_types::ExplicitOutputTypeTestCase { + description: "wildcard terminal maps explicit final CTE column aliases by name", + query_sql: "WITH final(order_id, amount) AS (SELECT CAST(raw_id AS INTEGER) AS raw_id, CAST(raw_amount AS DECIMAL(18, 2)) AS raw_amount FROM orders) SELECT * FROM final", + columns: json!([ + {"name": "amount", "type": "DECIMAL(18, 2)", "type_proven": true}, + {"name": "order_id", "type": "INTEGER", "type_proven": true} + ]), + contract: "enforced", + expected_fault_count: 0, + expected_message_fragment: "", + }, test_types::ExplicitOutputTypeTestCase { description: "renamed and reordered terminal columns map through positional set branches", query_sql: "WITH final AS (SELECT CAST(amount AS DECIMAL(18, 2)) AS amount, CAST(order_id AS INTEGER) AS order_id FROM current_orders UNION ALL SELECT CAST(amount AS DECIMAL(18, 2)) AS amount, CAST(order_id AS INTEGER) AS order_id FROM archived_orders) SELECT order_id AS id, amount AS total FROM final", @@ -370,6 +381,48 @@ fn given_enforced_contract_outputs_when_evaluating_explicit_type_rule_then_retur expected_fault_count: 0, expected_message_fragment: "", }, + test_types::ExplicitOutputTypeTestCase { + description: "terminal subset ignores extra final CTE outputs", + query_sql: "WITH final AS (SELECT CAST(order_id AS INTEGER) AS order_id, ignored_count + 1, ignored_label, CAST(amount AS DECIMAL(18, 2)) AS amount FROM orders) SELECT order_id AS id, amount AS total FROM final", + columns: json!([ + {"name": "id", "type": "INTEGER", "type_proven": true}, + {"name": "total", "type": "DECIMAL(18, 2)", "type_proven": true} + ]), + contract: "enforced", + expected_fault_count: 0, + expected_message_fragment: "", + }, + test_types::ExplicitOutputTypeTestCase { + description: "terminal subset reports the selected calculated final CTE output", + query_sql: "WITH final AS (SELECT CAST(order_id AS INTEGER) AS order_id, ignored_count, ignored_label, amount + 1 AS amount FROM orders) SELECT order_id AS id, amount AS total FROM final", + columns: json!([ + {"name": "id", "type": "INTEGER", "type_proven": true}, + {"name": "total", "type": "DECIMAL(18, 2)", "type_proven": true} + ]), + contract: "enforced", + expected_fault_count: 1, + expected_message_fragment: "output \"amount\" is calculated without an outer explicit cast", + }, + test_types::ExplicitOutputTypeTestCase { + description: "terminal subset maps outputs introduced by right by-name branch", + query_sql: "WITH final AS (SELECT CAST(1 AS INTEGER) AS ignored UNION ALL BY NAME SELECT 2 + 1 AS amount) SELECT amount AS total FROM final", + columns: json!([ + {"name": "total", "type": "INTEGER", "type_proven": true} + ]), + contract: "enforced", + expected_fault_count: 1, + expected_message_fragment: "output \"amount\" is calculated without an outer explicit cast", + }, + test_types::ExplicitOutputTypeTestCase { + description: "terminal subset maps explicit final CTE column aliases", + query_sql: "WITH final(order_id, ignored) AS (SELECT 1 + 1 AS raw_id, 3 AS extra) SELECT order_id AS id FROM final", + columns: json!([ + {"name": "id", "type": "INTEGER", "type_proven": true} + ]), + contract: "enforced", + expected_fault_count: 1, + expected_message_fragment: "output \"raw_id\" is calculated without an outer explicit cast", + }, ]; for test_case in test_cases { let project_dir = TempDir::new().map_err(|error| error.to_string())?; diff --git a/crates/sqlbuild-rules-native/src/rules/_helpers/explicit_output_types.rs b/crates/sqlbuild-rules-native/src/rules/_helpers/explicit_output_types.rs index 5d4fb369f1..b00d4b65ce 100644 --- a/crates/sqlbuild-rules-native/src/rules/_helpers/explicit_output_types.rs +++ b/crates/sqlbuild-rules-native/src/rules/_helpers/explicit_output_types.rs @@ -68,12 +68,7 @@ impl OutputRule<'_> { right, .. } => { - let operation_by_name = matches!( - set_quantifier, - SetQuantifier::ByName - | SetQuantifier::AllByName - | SetQuantifier::DistinctByName - ); + let operation_by_name = set_quantifier_by_name(set_quantifier); let branch_scope = OutputScope { columns: scope.columns, set_branch: true, @@ -190,6 +185,12 @@ fn final_cte_output_boundary<'a>( return None; } let select = root_select(query)?; + let cte_column_aliases: Vec = final_cte + .alias + .columns + .iter() + .map(|column| column.name.value.clone()) + .collect(); if select.projection.len() == 1 && matches!( select.projection[0], @@ -198,7 +199,11 @@ fn final_cte_output_boundary<'a>( { return Some(( &final_cte.query, - columns_in_output_order(&final_cte.query.body, columns), + columns_in_output_order_with_missing( + &final_cte.query.body, + columns, + &cte_column_aliases, + ), )); } if select.projection.len() != columns.len() { @@ -213,10 +218,42 @@ fn final_cte_output_boundary<'a>( boundary_column.name = source_name; boundary_columns.push(boundary_column); } - let ordered_columns = columns_in_output_order(&final_cte.query.body, &boundary_columns); + let ordered_columns = columns_in_output_order_with_missing( + &final_cte.query.body, + &boundary_columns, + &cte_column_aliases, + ); Some((&final_cte.query, ordered_columns)) } +fn columns_in_output_order_with_missing( + set_expr: &SetExpr, + columns: &[Column], + cte_column_aliases: &[String], +) -> Vec { + let Some(output_names) = set_expression_output_names_with_missing(set_expr) else { + return columns.to_vec(); + }; + output_names + .into_iter() + .enumerate() + .map(|(index, body_name)| { + let exposed_name = cte_column_aliases + .get(index) + .map(String::as_str) + .or(body_name.as_deref()); + let mut column = exposed_name + .and_then(|name| column_named(columns, name)) + .cloned() + .unwrap_or_default(); + if let Some(body_name) = body_name { + column.name = body_name; + } + column + }) + .collect() +} + fn columns_in_output_order(set_expr: &SetExpr, columns: &[Column]) -> Vec { let Some(output_names) = set_expression_output_names(set_expr) else { return columns.to_vec(); @@ -252,6 +289,49 @@ fn set_expression_output_names(set_expr: &SetExpr) -> Option> { } } +fn set_expression_output_names_with_missing(set_expr: &SetExpr) -> Option>> { + match set_expr { + SetExpr::Select(select) => Some( + select + .projection + .iter() + .map(projection_output_name) + .collect(), + ), + SetExpr::Query(query) => set_expression_output_names_with_missing(&query.body), + SetExpr::SetOperation { + left, + right, + set_quantifier, + .. + } => { + let mut names = set_expression_output_names_with_missing(left)?; + if set_quantifier_by_name(set_quantifier) { + for right_name in set_expression_output_names_with_missing(right)? { + let already_present = right_name.as_ref().is_some_and(|right_name| { + names + .iter() + .flatten() + .any(|left_name| left_name.eq_ignore_ascii_case(right_name)) + }); + if !already_present { + names.push(right_name); + } + } + } + Some(names) + } + _ => None, + } +} + +fn set_quantifier_by_name(set_quantifier: &SetQuantifier) -> bool { + matches!( + set_quantifier, + SetQuantifier::ByName | SetQuantifier::AllByName | SetQuantifier::DistinctByName + ) +} + fn projection_output_name(item: &SelectItem) -> Option { match item { SelectItem::ExprWithAlias { alias, .. } => Some(alias.value.clone()), diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_rules.py b/tests/integration/src/sqlbuild/cli/commands/main/test_rules.py index 0c31cde075..34c5a49d27 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/test_rules.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_rules.py @@ -129,6 +129,59 @@ def test_given_constant_backed_decision_and_numeric_alias_when_compiling_then_ru 'order_id (type INTEGER),\n label (description "Current processing state")' ), ), + ExplicitContractOutputRuleIntegrationTestCase( + description="terminal subset ignores extra final CTE outputs", + query_sql=( + "WITH final AS (\n" + " SELECT CAST(1 AS INTEGER) AS order_id, " + "ignored_count + 1, 'unused' AS ignored_label, " + "CAST(12.50 AS DECIMAL(18, 2)) AS amount\n" + ")\n" + "SELECT order_id AS id, amount AS total FROM final\n" + ), + expected_exit_code=0, + expected_rule_findings=0, + columns_sql="id (type INTEGER),\n total (type DECIMAL(18, 2))", + ), + ExplicitContractOutputRuleIntegrationTestCase( + description="terminal subset checks selected final CTE outputs", + query_sql=( + "WITH final AS (\n" + " SELECT CAST(1 AS INTEGER) AS order_id, " + "1 AS ignored_count, 'unused' AS ignored_label, 12.50 + 1 AS amount\n" + ")\n" + "SELECT order_id AS id, amount AS total FROM final\n" + ), + expected_exit_code=1, + expected_rule_findings=1, + columns_sql="id (type INTEGER),\n total (type DECIMAL(18, 2))", + ), + ExplicitContractOutputRuleIntegrationTestCase( + description="terminal subset checks right by-name branch outputs", + query_sql=( + "WITH final AS (\n" + " SELECT CAST(1 AS INTEGER) AS ignored\n" + " UNION ALL BY NAME\n" + " SELECT 2 + 1 AS amount\n" + ")\n" + "SELECT amount AS total FROM final\n" + ), + expected_exit_code=1, + expected_rule_findings=1, + columns_sql="total (type INTEGER)", + ), + ExplicitContractOutputRuleIntegrationTestCase( + description="terminal subset checks explicit final CTE column aliases", + query_sql=( + "WITH final(order_id, ignored) AS (\n" + " SELECT 1 + 1 AS raw_id, 3 AS extra\n" + ")\n" + "SELECT order_id AS id FROM final\n" + ), + expected_exit_code=1, + expected_rule_findings=1, + columns_sql="id (type INTEGER)", + ), ExplicitContractOutputRuleIntegrationTestCase( description="uncast set branches fail", query_sql=(