Skip to content
Closed
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
4 changes: 2 additions & 2 deletions crates/sqlquack-rules/src/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ pub const RULES: &[RuleSpec] = &[
RuleSpec {
id: NULLIF,
constructs: &["fn:NULLIF"],
conditions: "Arguments unify to a comparable type.",
conditions: "Arguments unify to a comparable type, the result typed as they unify. NULLIF(NULL, x) is NULL typed VARCHAR, as recorded.",
failures: &[],
},
RuleSpec {
Expand Down Expand Up @@ -1235,7 +1235,7 @@ pub const RULES: &[RuleSpec] = &[
RuleSpec {
id: COUNT,
constructs: &["fn:COUNT"],
conditions: "COUNT(*), COUNT(x) or COUNT(DISTINCT x); typed NUMBER(18,0). COUNT of a VARIANT skips JSON null as well as NULL; COUNT(DISTINCT) of a VARIANT is refused.",
conditions: "COUNT(*), COUNT(x) or COUNT(DISTINCT x); typed NUMBER(18,0). COUNT of a VARIANT skips JSON null as well as NULL; COUNT(DISTINCT) of a VARIANT is refused. COUNT(t.*) or COUNT(* EXCLUDE ...) counts the rows whose selected columns are all non-NULL, as recorded; with DISTINCT, over a VARIANT column, or selecting no column it is refused.",
failures: &[],
},
RuleSpec {
Expand Down
66 changes: 62 additions & 4 deletions crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,12 @@ use crate::conversion::_helpers::expressions::text_functions::{
};
use crate::conversion::_helpers::expressions::text_search::charindex_call;
use crate::conversion::_helpers::expressions::variants::variant_function;
use crate::conversion::_helpers::queries::query::star as query_star;
use crate::conversion::_helpers::support::divergence::diverge;
use crate::conversion::_helpers::support::scope::{Context, Emitted};
use crate::conversion::_helpers::support::site::{Placement, Site};
use crate::conversion::_helpers::support::tree::{
Payload, field, flag, function_construct, list, node_construct, payload_object,
Payload, field, flag, function_construct, list, node_construct, node_kind, payload_object,
require_understood,
};
use crate::conversion::_helpers::support::typed_leaves::compared_by_text;
Expand Down Expand Up @@ -370,6 +371,12 @@ fn nullif(left: &Value, right: &Value, site: Site<'_>) -> Result<Emitted, Refusa
let _rule = site.context.log.rule(NULLIF);
let left = convert_expression(left, site)?;
let right = convert_expression(right, site)?;
if left.data_type == SnowflakeType::Null {
return Ok(Emitted::new(
"CAST(NULL AS VARCHAR)".to_owned(),
SnowflakeType::Varchar,
));
}
comparable(left.clone(), right.clone(), &construct, site.context)?;
let (branches, target) = unified(&[left, right], &construct, site.context)?;
Ok(Emitted::new(
Expand Down Expand Up @@ -432,9 +439,17 @@ impl AggregateCall<'_> {
fn count(&self) -> Result<Emitted, Refusal> {
let log = self.site.context.log;
let _rule = log.rule(COUNT);
let inner = match flag(self.payload, "star") {
true => "*".to_owned(),
false => {
let star = self
.payload
.get("this")
.and_then(|node| match node_kind(node) {
Some(("star", star)) => Some(star),
_ => None,
});
let inner = match (flag(self.payload, "star"), star) {
(true, _) => "*".to_owned(),
(false, Some(star)) => self.counted_star(star)?,
(false, None) => {
let argument = self.argument("count")?;
match argument.data_type {
SnowflakeType::SemiStructured if flag(self.payload, "distinct") => {
Expand All @@ -456,6 +471,31 @@ impl AggregateCall<'_> {
Ok(Emitted::new(sql, data_type))
}

/// `t.*` or a filtered star: Snowflake counts the rows whose selected columns are all non-NULL.
fn counted_star(&self, star: &Value) -> Result<String, Refusal> {
let refuse = |reason: &str| outside_conditions(node_construct("count"), reason.to_owned());
if flag(self.payload, "distinct") {
return Err(refuse("COUNT(DISTINCT) of a star"));
}
let columns = query_star(star, self.site)?;
if columns
.iter()
.any(|(_, column)| column.data_type == SnowflakeType::SemiStructured)
{
return Err(refuse(
"COUNT of a star over a VARIANT column, whose JSON null no recording shows",
));
}
let present: Vec<String> = columns
.iter()
.map(|(_, column)| format!("{} IS NOT NULL", column.sql))
.collect();
match present.is_empty() {
true => Err(refuse("COUNT of a star that selects no column")),
false => Ok(format!("CASE WHEN {} THEN 1 END", present.join(" AND "))),
}
}

fn sum(&self) -> Result<Emitted, Refusal> {
let log = self.site.context.log;
let refuse = |reason: String| outside_conditions(node_construct("sum"), reason);
Expand Down Expand Up @@ -501,6 +541,24 @@ impl AggregateCall<'_> {
)));
};
let distinct = self.distinct();
let data_type = match (distinct.is_empty(), argument.data_type) {
(true, _) => data_type,
(
false,
SnowflakeType::Number {
precision: 38,
scale: 0,
},
) => SnowflakeType::Number {
precision: 38,
scale: 3,
},
(false, other) => {
return Err(refuse(format!(
"AVG(DISTINCT) of {other}; its result type is recorded only for NUMBER(38,0)"
)));
}
};
let counted = cast(
&format!("count({distinct}{}){}", argument.sql, self.over),
count(log),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ fn shaped(spelling: &SpellingRule, arguments: &[Value], payload: &Payload) -> Op
json!({ "this": end, "expression": start, "unit": unit_var(unit)? })
}
(CallShape::Count, [argument]) => {
let star = argument.get("star").is_some();
let star = argument.get("star").is_some_and(bare_star);
json!({
"this": if star { Value::Null } else { argument.clone() },
"star": star,
Expand Down Expand Up @@ -165,3 +165,15 @@ pub(crate) fn rlike_as_call(payload: &Value) -> Option<Value> {
}})
})
}

/// Whether a star's payload names no relation and filters no column: `*` as written alone.
fn bare_star(star: &Value) -> bool {
star.as_object().is_none_or(|fields| {
fields.values().all(|value| match value {
Value::Null | Value::Bool(false) => true,
Value::Array(items) => items.is_empty(),
Value::Object(entries) => entries.is_empty(),
_ => false,
})
})
}
53 changes: 53 additions & 0 deletions crates/sqlquack/tests/cli/test_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,59 @@ fn given_a_bracket_expression_with_a_lazy_spelling_when_converting_then_the_clas
}
}

#[test]
fn given_aggregates_and_nullif_with_recorded_types_when_running_then_values_follow_the_types() {
let test_cases = [
ValueTestCase {
description: "NULLIF(NULL, x) is text, so COALESCE with text keeps the text",
sql: "SELECT COALESCE(NULLIF(NULL, 1), 'none') AS r",
expected_value: "none",
},
ValueTestCase {
description: "AVG(DISTINCT) of NUMBER(38,0) keeps three decimals",
sql: "SELECT AVG(DISTINCT x)::VARCHAR AS r FROM (SELECT * FROM (VALUES \
(1::NUMBER(38,0)), (2), (2)) AS v(x)) AS t",
expected_value: "1.500",
},
];
for test_case in test_cases {
assert_eq!(
converted_first_value(test_case.sql),
test_case.expected_value,
"{}",
test_case.description
);
}
}

#[test]
fn given_count_of_a_qualified_or_filtered_star_when_running_then_rows_with_a_null_are_skipped() {
let test_cases = [
ValueTestCase {
description: "input.* skips a row with a NULL in any column, unlike *",
sql: "SELECT COUNT(input.*)::VARCHAR || ',' || COUNT(*)::VARCHAR AS r FROM (SELECT * \
FROM (VALUES (1::NUMBER(38,0), 'x'::VARCHAR), (1, 'x'), (1, NULL), (NULL, 'y'), \
(2, 'x')) AS v(a, b)) AS input",
expected_value: "3,5",
},
ValueTestCase {
description: "* EXCLUDE b checks only the remaining column",
sql: "SELECT COUNT(* EXCLUDE b)::VARCHAR AS r FROM (SELECT * FROM (VALUES \
(1::NUMBER(38,0), 'x'::VARCHAR), (1, 'x'), (1, NULL), (NULL, 'y'), (2, 'x')) \
AS v(a, b)) AS input",
expected_value: "4",
},
];
for test_case in test_cases {
assert_eq!(
converted_first_value(test_case.sql),
test_case.expected_value,
"{}",
test_case.description
);
}
}

#[test]
fn given_a_time_with_nanoseconds_when_formatting_then_digits_beyond_the_format_are_truncated() {
let test_cases = [
Expand Down
Loading