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
53 changes: 53 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 @@ -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",
Expand All @@ -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())?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -190,6 +185,12 @@ fn final_cte_output_boundary<'a>(
return None;
}
let select = root_select(query)?;
let cte_column_aliases: Vec<String> = final_cte
.alias
.columns
.iter()
.map(|column| column.name.value.clone())
.collect();
if select.projection.len() == 1
&& matches!(
select.projection[0],
Expand All @@ -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() {
Expand All @@ -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<Column> {
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<Column> {
let Some(output_names) = set_expression_output_names(set_expr) else {
return columns.to_vec();
Expand Down Expand Up @@ -252,6 +289,49 @@ fn set_expression_output_names(set_expr: &SetExpr) -> Option<Vec<String>> {
}
}

fn set_expression_output_names_with_missing(set_expr: &SetExpr) -> Option<Vec<Option<String>>> {
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<String> {
match item {
SelectItem::ExprWithAlias { alias, .. } => Some(alias.value.clone()),
Expand Down
53 changes: 53 additions & 0 deletions tests/integration/src/sqlbuild/cli/commands/main/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
Loading