From ed951fa289a7d45dba69c4410eaeda559f0739cf Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:12:35 +0000 Subject: [PATCH 1/2] fix(diagnostics): give unaliased output names their own code and help An unaliased select item whose generated name is not verified was refused as SQ0003, an operand type problem with a cast as help. Refuse it as SQ0009 unaliased_output_name, placed on the item, with an alias as the help. --- .../sqlquack-core/src/diagnostics/codes.lock | 1 + .../sqlquack-core/src/diagnostics/constants.rs | 18 ++++++++++++++++++ .../src/diagnostics/main/diagnose.rs | 9 ++++++++- .../src/conversion/_helpers/queries/query.rs | 12 +++++++----- crates/sqlquack/tests/cli/test_convert.rs | 13 +++++++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-core/src/diagnostics/codes.lock b/crates/sqlquack-core/src/diagnostics/codes.lock index 2d1443dbe..876146d43 100644 --- a/crates/sqlquack-core/src/diagnostics/codes.lock +++ b/crates/sqlquack-core/src/diagnostics/codes.lock @@ -8,6 +8,7 @@ SQ0005 not_expressible SQ0006 conversion_budget SQ0007 session_parameters SQ0008 divergence_refused +SQ0009 unaliased_output_name SQ0101 input_too_large SQ0102 token_budget_exceeded SQ0103 nesting_depth_exceeded diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index a6d54a88f..313e9a2d5 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -45,6 +45,10 @@ pub const CONVERSION_BUDGET: &str = "SQ0006"; pub const SESSION_PARAMETERS: &str = "SQ0007"; /// The conversion needs a known divergence the policy refuses in this mode. pub const DIVERGENCE_REFUSED: &str = "SQ0008"; +/// An unaliased select item's Snowflake-generated column name is not verified. +pub const UNALIASED_OUTPUT_NAME: &str = "SQ0009"; +/// The construct name of that refusal, which its code is registered under. +pub const UNALIASED_OUTPUT_NAME_CONSTRUCT: &str = "unaliased_output_name"; /// Every diagnostic code, in the order issued. pub const CODES: &[CodeEntry] = &[ @@ -156,6 +160,20 @@ pub const CODES: &[CodeEntry] = &[ construct.", example: r#"echo "SELECT HASH(1) AS h" | sqlquack convert --builtin-policy"#, }, + CodeEntry { + code: UNALIASED_OUTPUT_NAME, + name: UNALIASED_OUTPUT_NAME_CONSTRUCT, + alias: None, + title: "this column has no alias, and SQLQuack has not verified the name Snowflake gives it", + help: Some("add an alias, e.g. `AS one`"), + explanation: "Snowflake names an unaliased select item after the expression's text, and \ + a converted query must return the same column names. SQLQuack emits Snowflake's name \ + only for the expression shapes whose names are verified against recorded results; \ + the expression at the span is not one of them, so the statement is refused rather \ + than converted with a different column name. An alias fixes the name in both \ + engines.", + example: r#"echo "SELECT 1, UPPER('a')" | sqlquack convert --builtin-policy"#, + }, CodeEntry { code: "SQ0101", name: "input_too_large", diff --git a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs index 5c494ea9c..200f1c349 100644 --- a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs +++ b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs @@ -1,5 +1,6 @@ //! `diagnose`: refusals as coded diagnostics pointing into their source. +use crate::classification::models::ConstructFamily; use crate::diagnostics::_helpers::code_entry::code_entry; use crate::diagnostics::_helpers::coded::coded; use crate::diagnostics::_helpers::line_index::LineIndex; @@ -34,6 +35,7 @@ pub fn diagnose(refusals: &[Refusal], source: &str) -> Vec { .construct .as_ref() .filter(|_| refusal.category != RefusalCategory::SnowflakeRejects) + .filter(|_| code == MISSING_RULE || refusal.category != RefusalCategory::MissingRule) { diagnostic.message = format!("{}: `{construct}`", diagnostic.message); } @@ -46,7 +48,12 @@ pub fn diagnose(refusals: &[Refusal], source: &str) -> Vec { fn code_and_label(refusal: &Refusal) -> (&'static str, String) { let code = match refusal.category { RefusalCategory::ParseFidelity => PARSE_FIDELITY, - RefusalCategory::MissingRule => MISSING_RULE, + RefusalCategory::MissingRule => refusal + .construct + .as_ref() + .filter(|construct| construct.family == ConstructFamily::Syntax) + .and_then(|construct| code_entry(&construct.name)) + .map_or(MISSING_RULE, |entry| entry.code), RefusalCategory::TypeCondition => TYPE_CONDITION, RefusalCategory::UnresolvedName => UNRESOLVED_NAME, RefusalCategory::NotExpressible => NOT_EXPRESSIBLE, diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs index 377aaf738..04ecd87cc 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs @@ -1,7 +1,8 @@ //! Queries: SELECT with its projection and clauses, and WITH. use serde_json::{Map, Value, json}; -use sqlquack_core::classification::models::Construct; +use sqlquack_core::classification::models::{Construct, ConstructFamily}; +use sqlquack_core::diagnostics::constants::UNALIASED_OUTPUT_NAME_CONSTRUCT; use sqlquack_core::provenance::constants::QUERY_CONSTRUCT; use sqlquack_core::provenance::models::ShapeArgument; use sqlquack_core::refusals::main::missing::missing; @@ -622,10 +623,11 @@ fn convert_projection( Some((kind, _)) => { let emitted = convert_held(item, site)?; if named { - return Err(outside_conditions( - node_construct(kind), - "an unaliased expression gets a Snowflake-generated column name".to_owned(), - )); + let refusal = missing( + Construct::new(ConstructFamily::Syntax, UNALIASED_OUTPUT_NAME_CONSTRUCT), + format!("Snowflake names this `{kind}` column after its text"), + ); + return Err(site.context.locator.placed(refusal, item)); } Ok(vec![(None, emitted)]) } diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index da3d1cd29..c71a8f0b7 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -1302,6 +1302,19 @@ fn given_refused_sql_when_converting_then_text_and_json_carry_the_same_coded_dia under the built-in default\n", expected_json: ("SQ0203", "qualify_without_window", 1, 10), }, + ConvertDiagnosticTestCase { + description: "an unaliased item has its own code, its own span and an alias as help", + sql: "SELECT 'a' AS a, UPPER('b')", + flags: &[], + expected_text: concat!( + "error[SQ0009]: this column has no alias, and SQLQuack has not verified the name ", + "Snowflake gives it\n --> :1:18\n |\n1 | SELECT 'a' AS a, UPPER('b')\n", + " | ^^^^^^^^^^ Snowflake names this `function` column after its text\n", + " |\n = help: add an alias, e.g. `AS one`\n", + "sqlquack convert: refused, 1 refusal under the built-in default\n", + ), + expected_json: ("SQ0009", "unaliased_output_name", 1, 18), + }, ConvertDiagnosticTestCase { description: "a divergence the policy refuses has its own code and help naming the policy", sql: "SELECT HASH(1) AS h", From 24da0bd69c68645729e47d5ecf87ce2fe4744996 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:02:28 +0000 Subject: [PATCH 2/2] docs(diagnostics): document SQ0009 in the reference and the convert refusals Regenerate the diagnostics reference, list SQ0009 among convert's refusals, and give it an example that stays refused once verified names convert. --- crates/sqlquack-core/src/diagnostics/constants.rs | 2 +- website/src/content/docs/docs/cli/convert.mdx | 1 + .../content/docs/docs/diagnostics/reference.mdx | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index 313e9a2d5..a93eb1e12 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -172,7 +172,7 @@ pub const CODES: &[CodeEntry] = &[ the expression at the span is not one of them, so the statement is refused rather \ than converted with a different column name. An alias fixes the name in both \ engines.", - example: r#"echo "SELECT 1, UPPER('a')" | sqlquack convert --builtin-policy"#, + example: r#"echo "SELECT 'a' || 'b'" | sqlquack convert --builtin-policy"#, }, CodeEntry { code: "SQ0101", diff --git a/website/src/content/docs/docs/cli/convert.mdx b/website/src/content/docs/docs/cli/convert.mdx index 1588d39a3..2da414bc7 100644 --- a/website/src/content/docs/docs/cli/convert.mdx +++ b/website/src/content/docs/docs/cli/convert.mdx @@ -43,6 +43,7 @@ Each refusal is one of these: | [`SQ0006`](/docs/diagnostics/reference/#sq0006) | Conversion exceeded its time or size budget. | | [`SQ0007`](/docs/diagnostics/reference/#sq0007) | The session parameters match no recorded profile. | | [`SQ0008`](/docs/diagnostics/reference/#sq0008) | The conversion needs a known divergence the policy refuses. | +| [`SQ0009`](/docs/diagnostics/reference/#sq0009) | A select item has no alias, and SQLQuack has not verified the column name Snowflake gives it. | SQL that does not parse, or that Snowflake rejects, is refused with the same codes as [`check`](/docs/cli/check/). A `VALUES` list of more than 1,000 rows is refused. diff --git a/website/src/content/docs/docs/diagnostics/reference.mdx b/website/src/content/docs/docs/diagnostics/reference.mdx index dd083367e..7859a9eed 100644 --- a/website/src/content/docs/docs/diagnostics/reference.mdx +++ b/website/src/content/docs/docs/diagnostics/reference.mdx @@ -21,6 +21,7 @@ renumbered. See [Reading diagnostics](/docs/diagnostics/overview/) for the outpu | [`SQ0006`](#sq0006) | `conversion_budget` | conversion exceeded its budget | | [`SQ0007`](#sq0007) | `session_parameters` | the session parameters match no recorded session profile | | [`SQ0008`](#sq0008) | `divergence_refused` | the conversion needs a known divergence the policy refuses | +| [`SQ0009`](#sq0009) | `unaliased_output_name` | this column has no alias, and SQLQuack has not verified the name Snowflake gives it | | [`SQ0101`](#sq0101) | `input_too_large` | the input is larger than the parser accepts | | [`SQ0102`](#sq0102) | `token_budget_exceeded` | the input has more tokens than the parser accepts | | [`SQ0103`](#sq0103) | `nesting_depth_exceeded` | brackets nest deeper than the parser accepts | @@ -135,6 +136,20 @@ The construct at the span converts only by relying on the known divergence the l echo "SELECT HASH(1) AS h" | sqlquack convert --builtin-policy ``` +### SQ0009 + +**this column has no alias, and SQLQuack has not verified the name Snowflake gives it** (`unaliased_output_name`) + +Snowflake names an unaliased select item after the expression's text, and a converted query must return the same column names. SQLQuack emits Snowflake's name only for the expression shapes whose names are verified against recorded results; the expression at the span is not one of them, so the statement is refused rather than converted with a different column name. An alias fixes the name in both engines. + +**Help:** add an alias, e.g. `AS one` + +**Example:** + +```sh +echo "SELECT 'a' || 'b'" | sqlquack convert --builtin-policy +``` + ## Input limits Limits that keep parsing and conversion bounded.