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
60 changes: 60 additions & 0 deletions cases/relational/pivot_unaliased.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# PIVOT without an alias column list: the names Snowflake gives the key columns, as dbt pivot
# models leave them.
category = "relational"
construct = "node:pivot"
session_profile = "utc-v1"

[[case]]
id = "pivot_star_without_alias_list"
description = "SELECT * over SUM(total) FOR channel IN ('web', 'store') with no alias list. Catches: key columns named web or WEB instead of the quoted text, a missing or NULL key, keys outside the list dropped, and the grouping column kept."
columns = [{ name = "order_id", type = "NUMBER(38,0)" }, { name = "channel", type = "VARCHAR" }, { name = "total", type = "NUMBER(12,2)" }]
rows = [
["1", "'web'", "10.50"],
["1", "'web'", "2.25"],
["1", "'store'", "4.00"],
["2", "'store'", "NULL"],
["2", "'phone'", "9.99"],
["3", "NULL", "1.00"],
["3", "'WEB'", "3.00"],
]
query = "SELECT * FROM (SELECT order_id, channel, total FROM input) PIVOT(SUM(total) FOR channel IN ('web', 'store')) ORDER BY order_id"
order = "ordered"
tags = ["null", "duplicates", "missing-key", "case-sensitive-key"]

[[case]]
id = "pivot_quoted_key_reference"
description = "The pivoted key columns read back by their quoted names, \"'web'\" and \"'store'\", through a table alias without a column list. Catches: the names not matching the quoted key text."
columns = [{ name = "order_id", type = "NUMBER(38,0)" }, { name = "channel", type = "VARCHAR" }, { name = "total", type = "NUMBER(12,2)" }]
rows = [
["1", "'web'", "10.50"],
["1", "'store'", "4.00"],
["2", "'web'", "1.00"],
]
query = "SELECT p.order_id, p.\"'web'\" AS web_total, p.\"'store'\" AS store_total FROM (SELECT order_id, channel, total FROM input) PIVOT(SUM(total) FOR channel IN ('web', 'store')) AS p ORDER BY p.order_id"
order = "ordered"
tags = ["null", "missing-key"]

[[case]]
id = "pivot_mixed_case_keys_without_alias_list"
description = "Keys with upper case, a space and an underscore, under MAX over BOOLEAN and no alias list. Catches: key names upper-cased, spaces changed, or the quotes dropped."
columns = [{ name = "customer_id", type = "VARCHAR" }, { name = "consent", type = "VARCHAR" }, { name = "granted", type = "BOOLEAN" }]
rows = [
["'c-1'", "'Email Opt'", "TRUE"],
["'c-1'", "'SMS_OPT'", "FALSE"],
["'c-2'", "'Email Opt'", "NULL"],
["'c-2'", "'other'", "TRUE"],
]
query = "SELECT * FROM (SELECT customer_id, consent, granted FROM input) PIVOT(MAX(granted) FOR consent IN ('Email Opt', 'SMS_OPT')) ORDER BY customer_id"
order = "ordered"
tags = ["null", "missing-key", "case-sensitive-key"]

[[case]]
id = "pivot_without_alias_list_over_no_rows"
description = "A PIVOT without an alias list over no rows. Catches: a row of NULLs instead of no rows, and the column names and types still described."
columns = [{ name = "order_id", type = "NUMBER(38,0)" }, { name = "channel", type = "VARCHAR" }, { name = "total", type = "NUMBER(12,2)" }]
rows = [
["1", "'web'", "10.50"],
]
query = "SELECT * FROM (SELECT order_id, channel, total FROM input WHERE total < 0) PIVOT(SUM(total) FOR channel IN ('web', 'store'))"
order = "bag"
tags = ["empty-input"]
33 changes: 33 additions & 0 deletions cases/semi-structured/table_flatten.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# FLATTEN written as TABLE(FLATTEN(...)) after a comma, with a positional input, as dbt models
# write it: the same rows as LATERAL FLATTEN(input => ...).
category = "semi-structured"
construct = "fn:FLATTEN"
session_profile = "utc-v1"

[[case]]
id = "table_flatten_positional_array"
description = "TABLE(FLATTEN(t.tags)) over an ARRAY column with a positional input: one row per element. Catches: the input not correlated to its row, empty arrays or NULL giving rows, or elements out of order."
columns = [{ name = "ticket_id", type = "NUMBER(38,0)" }, { name = "tags", type = "ARRAY" }]
rows = [
["1", "ARRAY_CONSTRUCT('billing', 'urgent')"],
["2", "ARRAY_CONSTRUCT()"],
["3", "NULL"],
["4", "ARRAY_CONSTRUCT('refund')"],
]
query = "SELECT t.ticket_id, f.index, f.value::STRING AS tag FROM input t, TABLE(FLATTEN(t.tags)) f ORDER BY t.ticket_id, f.index"
order = "ordered"
tags = ["null", "array-index"]

[[case]]
id = "table_flatten_named_variant_path"
description = "TABLE(FLATTEN(input => v:items)) over a VARIANT path, with KEY, PATH and VALUE read back. Catches: the named input ignored, objects read as arrays, or JSON null and missing paths giving rows."
columns = [{ name = "k", type = "NUMBER(2,0)" }, { name = "v", type = "VARIANT" }]
rows = [
["1", "PARSE_JSON('{\"items\":[{\"sku\":\"A-10\"},{\"sku\":\"B-20\"}]}')"],
["2", "PARSE_JSON('{\"items\":{\"b\":1,\"a\":2}}')"],
["3", "PARSE_JSON('{\"items\":null}')"],
["4", "PARSE_JSON('{}')"],
]
query = "SELECT i.k, f.index, f.key, f.path, f.value::VARCHAR AS value FROM input i, TABLE(FLATTEN(input => i.v:items)) f ORDER BY i.k, f.index, f.key"
order = "ordered"
tags = ["null", "json-null", "missing-key", "nested", "array-index", "case-sensitive-key"]
2 changes: 1 addition & 1 deletion crates/sqlquack-rules/src/catalogue/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ pub const RULES: &[RuleSpec] = &[
RuleSpec {
id: FLATTEN_RULE,
constructs: &["node:lateral", "fn:FLATTEN"],
conditions: "LATERAL FLATTEN of a VARIANT input after another FROM source or after JOIN ... ON clauses, as a comma or CROSS JOIN source, with OUTER => a boolean literal at most: INDEX and PATH [i] for array elements, KEY and PATH for object keys (raising for keys that are not plain names), VALUE and THIS; scalars, JSON null and NULL give no rows. Under OUTER => TRUE an input with no rows gives one row: PATH '' and THIS the input for an empty container MODE takes, every column NULL otherwise (JSON null, NULL, a missing path, a scalar, a kind MODE skips), as recorded. MODE => 'ARRAY' or 'OBJECT' without RECURSIVE takes only that kind. RECURSIVE => TRUE walks every depth with keys in byte order, writing a key of letters, digits, `_`, spaces and dots that is not a plain name as ['key'] and raising for any other, MODE => 'BOTH', 'OBJECT' or 'ARRAY' choosing what it descends into, a scalar input giving one row of itself under BOTH and raising under OBJECT or ARRAY; RECURSIVE with OUTER, SEQ and PATH => are not converted.",
conditions: "LATERAL FLATTEN, or TABLE(FLATTEN(...)) with an alias and no column list, which correlates the same way, of a VARIANT input after another FROM source or after JOIN ... ON clauses, as a comma or CROSS JOIN source, with OUTER => a boolean literal at most: INDEX and PATH [i] for array elements, KEY and PATH for object keys (raising for keys that are not plain names), VALUE and THIS; scalars, JSON null and NULL give no rows. Under OUTER => TRUE an input with no rows gives one row: PATH '' and THIS the input for an empty container MODE takes, every column NULL otherwise (JSON null, NULL, a missing path, a scalar, a kind MODE skips), as recorded. MODE => 'ARRAY' or 'OBJECT' without RECURSIVE takes only that kind. RECURSIVE => TRUE walks every depth with keys in byte order, writing a key of letters, digits, `_`, spaces and dots that is not a plain name as ['key'] and raising for any other, MODE => 'BOTH', 'OBJECT' or 'ARRAY' choosing what it descends into, a scalar input giving one row of itself under BOTH and raising under OBJECT or ARRAY; RECURSIVE with OUTER, SEQ and PATH => are not converted.",
failures: &[],
},
RuleSpec {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use sqlquack_semantics::scopes::models::ScopeRelation;
use sqlquack_semantics::types::models::{ColumnSpec, Holding};

/// The name of the table function LATERAL converts.
const FLATTEN_NAME: &str = "FLATTEN";
pub(crate) const FLATTEN_NAME: &str = "FLATTEN";

/// A converted LATERAL FLATTEN: its join text and the relation it brings into scope.
pub(crate) struct Flattened {
Expand Down
34 changes: 28 additions & 6 deletions crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::conversion::_helpers::expressions::project_functions::{exact_argument
use crate::conversion::_helpers::expressions::scalar::convert_expression;
use crate::conversion::_helpers::expressions::semi_structured::guarded_column;
use crate::conversion::_helpers::queries::clauses::condition_sql;
use crate::conversion::_helpers::queries::flatten::lateral_flatten;
use crate::conversion::_helpers::queries::flatten::{FLATTEN_NAME, lateral_flatten};
use crate::conversion::_helpers::queries::pivot::pivot_subquery;
use crate::conversion::_helpers::queries::query::{convert_query, require_distinct_names};
use crate::conversion::_helpers::support::collecting::{
Expand Down Expand Up @@ -57,21 +57,21 @@ pub(crate) fn convert_from(
let sources = list(from, "expressions");
let separator = match sources
.iter()
.any(|source| source.get(LATERAL_KEY).is_some())
.any(|source| lateral_payload(source).is_some())
{
true => " CROSS JOIN ",
false => ", ",
};
let mut sql = String::from(" FROM ");
for (position, source) in sources.iter().enumerate() {
let before = events();
if let Some(lateral) = source.get(LATERAL_KEY) {
if let Some(lateral) = lateral_payload(source) {
let flattened = match position {
0 => Err(missing(
node_construct(LATERAL_KEY),
"LATERAL as the first FROM source".to_owned(),
)),
_ => lateral_flatten(lateral, &relations, outer, context)
_ => lateral_flatten(&lateral, &relations, outer, context)
.map(|flattened| (flattened.sql, flattened.relation)),
}
.map_err(|refusal| context.locator.placed(refusal, source));
Expand Down Expand Up @@ -161,8 +161,8 @@ fn convert_join(
.get("match_condition")
.filter(|matching| !matching.is_null());
let before = events();
if let (Some(lateral), "Implicit" | "Cross", None) = (source.get(LATERAL_KEY), kind, on) {
let flattened = lateral_flatten(lateral, earlier, outer, context)
if let (Some(lateral), "Implicit" | "Cross", None) = (lateral_payload(source), kind, on) {
let flattened = lateral_flatten(&lateral, earlier, outer, context)
.map(|flattened| (flattened.sql, flattened.relation));
return recovered(flattened, source, (before, earlier.len()));
}
Expand Down Expand Up @@ -492,6 +492,28 @@ fn source_alias(source: &Value) -> Option<String> {
}
}

/// The LATERAL payload of `LATERAL f(...) alias` or `TABLE(FLATTEN(...)) alias`, correlated alike.
fn lateral_payload(source: &Value) -> Option<Value> {
if let Some(lateral) = source.get(LATERAL_KEY) {
return Some(lateral.clone());
}
let ("alias", aliased) = node_kind(source)? else {
return None;
};
let call = table_call(aliased.get("this")?)?;
let flatten = call
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(FLATTEN_NAME));
let alias = identifier_name(aliased.get("alias")?)?;
let unlisted = aliased
.get("column_aliases")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty);
(flatten && unlisted && alias == alias.to_ascii_uppercase())
.then(|| json!({ "this": { "function": call }, "alias": alias }))
}

fn lateral_subquery(source: &Value) -> bool {
matches!(node_kind(source), Some(("subquery", payload))
if payload.get(LATERAL_KEY).and_then(Value::as_bool) == Some(true))
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"arrow_sha256": "aad895c9f4aeb32525580622b70d94b39b44b5c17b1f117270b89248d608b50a",
"case_hash": "21d4c9c5a220840574004b4e581286b3ed15137cb1456ce3df3c697673e01e5d",
"columns": [
{
"internal_size": null,
"name": "ORDER_ID",
"nullable": false,
"precision": 38,
"scale": 0,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": null,
"name": "'web'",
"nullable": true,
"precision": 24,
"scale": 2,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": null,
"name": "'store'",
"nullable": true,
"precision": 24,
"scale": 2,
"type_code": 0,
"type_name": "FIXED"
}
],
"connector": "snowflake-connector-python 4.7.2",
"duckdb_version": "v2.0.0",
"effective_parameters": {
"BINARY_INPUT_FORMAT": "HEX",
"BINARY_OUTPUT_FORMAT": "HEX",
"DATE_INPUT_FORMAT": "AUTO",
"DATE_OUTPUT_FORMAT": "YYYY-MM-DD",
"DEFAULT_NULL_ORDERING": "LAST",
"GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON",
"GEOMETRY_OUTPUT_FORMAT": "GeoJSON",
"JSON_INDENT": "2",
"QUOTED_IDENTIFIERS_IGNORE_CASE": "false",
"STRICT_JSON_OUTPUT": "false",
"TIMESTAMP_DAY_IS_ALWAYS_24H": "false",
"TIMESTAMP_INPUT_FORMAT": "AUTO",
"TIMESTAMP_LTZ_OUTPUT_FORMAT": "",
"TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3",
"TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM",
"TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ",
"TIMESTAMP_TZ_OUTPUT_FORMAT": "",
"TIMEZONE": "UTC",
"TIME_INPUT_FORMAT": "AUTO",
"TIME_OUTPUT_FORMAT": "HH24:MI:SS",
"TWO_DIGIT_CENTURY_START": "1970",
"USE_CACHED_RESULT": "false",
"WEEK_OF_YEAR_POLICY": "0",
"WEEK_START": "0"
},
"error": null,
"format": 1,
"outcome": "rows",
"parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61",
"query_id_sha256": "c7c2819c5d2f770f5aa606d95c7ddaf4e8ab9f644632588cfa4cc67f280c2b6f",
"recorded_at": "2026-11-06T06:09:54.301398+00:00",
"recorder": "sqlquack-record 0.1.0",
"row_count": 0,
"session_parameters": {
"DEFAULT_NULL_ORDERING": "LAST",
"QUOTED_IDENTIFIERS_IGNORE_CASE": false,
"TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ",
"TIMEZONE": "UTC",
"USE_CACHED_RESULT": false,
"WEEK_OF_YEAR_POLICY": 0,
"WEEK_START": 0
},
"warehouse_version": "10.40.102"
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"arrow_sha256": "e16ca5668f551f81428efd0c80c46ce0f55b5d5726c3ff1231b1fed30c8af227",
"case_hash": "542612dfa7749f96531af74a063f43b3e3f348f16c50622e0f30830b74f7cf1f",
"columns": [
{
"internal_size": null,
"name": "K",
"nullable": false,
"precision": 2,
"scale": 0,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": null,
"name": "INDEX",
"nullable": true,
"precision": 38,
"scale": 0,
"type_code": 0,
"type_name": "FIXED"
},
{
"internal_size": 134217728,
"name": "KEY",
"nullable": true,
"precision": null,
"scale": null,
"type_code": 2,
"type_name": "TEXT"
},
{
"internal_size": 134217728,
"name": "PATH",
"nullable": true,
"precision": null,
"scale": null,
"type_code": 2,
"type_name": "TEXT"
},
{
"internal_size": 134217728,
"name": "VALUE",
"nullable": true,
"precision": null,
"scale": null,
"type_code": 2,
"type_name": "TEXT"
}
],
"connector": "snowflake-connector-python 4.7.2",
"duckdb_version": "v2.0.0",
"effective_parameters": {
"BINARY_INPUT_FORMAT": "HEX",
"BINARY_OUTPUT_FORMAT": "HEX",
"DATE_INPUT_FORMAT": "AUTO",
"DATE_OUTPUT_FORMAT": "YYYY-MM-DD",
"DEFAULT_NULL_ORDERING": "LAST",
"GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON",
"GEOMETRY_OUTPUT_FORMAT": "GeoJSON",
"JSON_INDENT": "2",
"QUOTED_IDENTIFIERS_IGNORE_CASE": "false",
"STRICT_JSON_OUTPUT": "false",
"TIMESTAMP_DAY_IS_ALWAYS_24H": "false",
"TIMESTAMP_INPUT_FORMAT": "AUTO",
"TIMESTAMP_LTZ_OUTPUT_FORMAT": "",
"TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3",
"TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM",
"TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ",
"TIMESTAMP_TZ_OUTPUT_FORMAT": "",
"TIMEZONE": "UTC",
"TIME_INPUT_FORMAT": "AUTO",
"TIME_OUTPUT_FORMAT": "HH24:MI:SS",
"TWO_DIGIT_CENTURY_START": "1970",
"USE_CACHED_RESULT": "false",
"WEEK_OF_YEAR_POLICY": "0",
"WEEK_START": "0"
},
"error": null,
"format": 1,
"outcome": "rows",
"parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61",
"query_id_sha256": "c6acf3f6ecb1dc565f427f676461e985eeb4886484841bc8d1f686f4c992c03e",
"recorded_at": "2026-11-06T06:09:54.301398+00:00",
"recorder": "sqlquack-record 0.1.0",
"row_count": 4,
"session_parameters": {
"DEFAULT_NULL_ORDERING": "LAST",
"QUOTED_IDENTIFIERS_IGNORE_CASE": false,
"TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ",
"TIMEZONE": "UTC",
"USE_CACHED_RESULT": false,
"WEEK_OF_YEAR_POLICY": 0,
"WEEK_START": 0
},
"warehouse_version": "10.40.102"
}
Binary file not shown.
Loading
Loading