From 37346daef2a6ec0af4b6b31445dbcee13e7826ab Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:38:03 +0000 Subject: [PATCH 1/9] test: record the names Snowflake gives unaliased select items SELECT COUNT(*) FROM orders is refused as SQ0009 because no recorded result shows the name Snowflake gives the column. These cases record those names for aggregates, literals and expressions as written. --- cases/relational/unaliased_output_names.toml | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 cases/relational/unaliased_output_names.toml diff --git a/cases/relational/unaliased_output_names.toml b/cases/relational/unaliased_output_names.toml new file mode 100644 index 000000000..67b2ec409 --- /dev/null +++ b/cases/relational/unaliased_output_names.toml @@ -0,0 +1,66 @@ +# The names Snowflake gives select items without an alias: the first thing a reader types, as in +# SELECT COUNT(*) FROM orders. The recorded column names are the receipts for naming them. +category = "relational" +construct = "node:alias" +session_profile = "utc-v1" + +[[case]] +id = "unaliased_aggregates" +description = "Unaliased aggregates, written in upper, lower and mixed case and with inner spaces, over rows with NULLs and duplicates." +columns = [{ name = "g", type = "VARCHAR" }, { name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["'a'", "1.50"], + ["'a'", "1.50"], + ["'b'", "NULL"], + ["NULL", "-2.25"], +] +query = "SELECT COUNT(*), count(n), Count( DISTINCT g ), SUM(n), max(g), avg( n ) FROM input" +order = "ordered" +tags = ["null", "duplicates"] + +[[case]] +id = "unaliased_group_count" +description = "A grouped column beside an unaliased COUNT(*), as an exploratory query writes it." +columns = [{ name = "g", type = "VARCHAR" }] +rows = [ + ["'a'"], + ["'a'"], + ["NULL"], +] +query = "SELECT g, COUNT(*) FROM input GROUP BY g ORDER BY g" +order = "ordered" +tags = ["null", "duplicates"] + +[[case]] +id = "unaliased_over_no_rows" +description = "Unaliased aggregates over no rows still name their columns." +columns = [{ name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["1.00"], +] +query = "SELECT COUNT(*), SUM(n) FROM input WHERE n > 5" +order = "ordered" +tags = ["empty-input"] + +[[case]] +id = "unaliased_literals" +description = "Unaliased literals: integers, a negative, a decimal, an exponent, text in mixed case, NULL and booleans." +columns = [{ name = "k", type = "NUMBER(38,0)" }] +rows = [ + ["1"], +] +query = "SELECT 1, -2, 1.50, 1e3, 'MiXeD text', NULL, TRUE, false FROM input" +order = "ordered" +tags = ["null"] + +[[case]] +id = "unaliased_expressions" +description = "Unaliased arithmetic with and without spaces, function calls, casts, a qualified and a quoted column, and an expression across two lines." +columns = [{ name = "g", type = "VARCHAR" }, { name = "n", type = "NUMBER(10,2)" }] +rows = [ + ["'Ab'", "2.00"], + ["NULL", "NULL"], +] +query = "SELECT n + 1, n*2, lower(g), UPPER( g ), g || 'x', CAST(n AS INTEGER), n::INTEGER, COALESCE(n, 0), input.n - 1, \"N\" / 2, n\n + 3 FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null"] From 466cee3997cdc2d5dc06baa01a4ad6cdb4c91ef6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:55:13 +0000 Subject: [PATCH 2/9] evidence(relational): record the output names of unaliased select items Snowflake results for the five cases in cases/relational/unaliased_output_names.toml at 37346da. --- ...a6fcc37919aa18250bd6c75272a1258c4db9.arrow | Bin 0 -> 4002 bytes ...ea6fcc37919aa18250bd6c75272a1258c4db9.json | 106 ++++++++++++ ...1a74fcefe39fdfcf643958fb8eb5044d2e20.arrow | Bin 0 -> 5106 bytes ...41a74fcefe39fdfcf643958fb8eb5044d2e20.json | 151 ++++++++++++++++++ ...c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.arrow | Bin 0 -> 1226 bytes ...9c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json | 70 ++++++++ ...12a4a658c70a9db500f24bd827e1d092000e.arrow | Bin 0 -> 1770 bytes ...d12a4a658c70a9db500f24bd827e1d092000e.json | 70 ++++++++ ...e8338a4f67baf34f9eca42e759d3c49a7f8e.arrow | Bin 0 -> 5226 bytes ...4e8338a4f67baf34f9eca42e759d3c49a7f8e.json | 124 ++++++++++++++ 10 files changed, 521 insertions(+) create mode 100644 evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow create mode 100644 evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json create mode 100644 evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow create mode 100644 evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.json create mode 100644 evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.arrow create mode 100644 evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json create mode 100644 evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow create mode 100644 evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.json create mode 100644 evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.arrow create mode 100644 evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json diff --git a/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.arrow new file mode 100644 index 0000000000000000000000000000000000000000..694a86642df05e95f5b1f9c309642b8ed7bd1aab GIT binary patch literal 4002 zcmeHKzfV(96h1(KCssq07={iVh^dBwf)NJ?8o-t)rGcUy7z9$GNmB@PkVF#(1_uTQ zV;C769UL7T9EovYWZ(~Q@DDKls^9nSJ8*fu1qR%DlJoAp?|wb^-0z-q-fe1ndg@71 zqz3uiA<`|BE{RHqw2Oxxq$Pr}VC3N$37!IkNUj}kA|hW~MRuUM79vEx{H@jS!_FS` zV|d43_ChJ_ryi#h@ofAS$F#Et-G#n@=Ar#i+UfmStI;R+VaA`kg!WmfGGESbtQOI~ zZ(-nqB=2$uA`2VU;-g|^sk$uk)WXot0F;=;<@q(mY=toQFpurY`r>?<>%6n(xy~Q} z^ksFexL8^*tyIw81BQF1zx?fkiNg38+RM=L%2ElYe6KyZ3XEYihB{H0wWU7!zgGKfapdX;y&^f4bIQQ#p4mW|}`Ot6fDLHg!FgZNT zE!J`Q{tT?m=|O01@@{kb!=3zmGYkfBEqOL!^$=2)yS!d9zt${4f^T>K^%q zoXyNmCbHmf6KmYWm_EGy!`PKp@~p8NN1t*XIyyC*%_rhx6EpdV>}WooMi|ppqZmGf zZLUq<8pW_+O&jCly)T3rfW2@GXB-%t?`3P=&lLyX+rV(|^!KzF-U5zW8o|Tl`$^8o zHHEQlI3v@i=evixVe`O)W<1*Y-XFraxXOhvF0QH$jLp>>YrYXzAAva!SIl=0d2RCC zaHjb#b@^_1)O@e!@%9mq``~658m^l;)bq1=tcEZy9(O_*7muGD7@NmKYrYYW9x9&J z!}IYOb?|AvW3P~8v_Xl=4df=jWKqASN?XLQ0U0xmXBWn1ZRXNkjos(wAg==}88bY3Ht#aW s^?^Cryvw-nJguSAFvZpkN^Mx literal 0 HcmV?d00001 diff --git a/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json new file mode 100644 index 000000000..afffb732a --- /dev/null +++ b/evidence/results/2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9.json @@ -0,0 +1,106 @@ +{ + "arrow_sha256": "6e13368a167854a2d2cf771dc204f7b4daa87217fb2ddd71ebcc6618b89b55e2", + "case_hash": "2615ead10c0b07c181571b1521fea6fcc37919aa18250bd6c75272a1258c4db9", + "columns": [ + { + "internal_size": null, + "name": "COUNT(*)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "COUNT(N)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "COUNT( DISTINCT G )", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "SUM(N)", + "nullable": true, + "precision": 22, + "scale": 2, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 134217728, + "name": "MAX(G)", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "AVG( N )", + "nullable": true, + "precision": 28, + "scale": 8, + "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": "18eda5d3151391d11c1c50447de582dbce97fb2aa8b43b3a2a4cbc034824ffd3", + "recorded_at": "2026-11-06T03:52:30.454184+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "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" +} diff --git a/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow b/evidence/results/7b148651af0c1b4312384e0845541a74fcefe39fdfcf643958fb8eb5044d2e20.arrow new file mode 100644 index 0000000000000000000000000000000000000000..da6ecaf75520c0af48cf391360aa681baf5025a5 GIT binary patch literal 5106 zcmeHLJxmlq6n@|VCtl3O5QB-u#1K98RDMh(R-Skw30VlNN?{>?awG(aQfNY9Nnv5E zC@n20C@d_k426Y-g{5{D78IJ`FMi+bJZ{*#h10_7UNSfH_I>;2%{OmmcCIioGIX;` zWDD}PMI?@SG)WpQNJ9#`KuhB{<$gsDK%2rl9VnlgL{?HFxA%#dMv)EJUZBjO z^rFBd?>D!$;H-sxw7X}h1g}fbYRqZVBkRB%hYlB#{+5hC792C@Ot)-0JR|G%@JFGu zPYUB42!^kRX9IJ_bV(jK?ISB~c04V1gjqL!u`wFFr#=p%-8WPiEROaSb3xv8o)lU1 z{G9q>V{L2$uC0MwMPo!|ISejd90>tjIdbGn6Hi5G9YozSiNB{ICr-*0vw@ zo!R;Ex$xn_G}@nI7)+n-W8&fB^x*XT%wh@i_($vEmifx#L=*5Z7;(mduH<6V%Dsdz3Yl$0)Z@G|xIQ2R%+svk(}u_F3*xUn}V;sI9mp$R?c4Rf%Q525bHb6nP7|>TM}m- zz|_K73YkMrxfQCnY0?F`qoyGj>j8U&@}vB@$t!6?){0Xa6dm zvw!lSazq=b^)eEiCzF;NuvMH>l7Y%l!&vTHem-Jsd-0NH2{z|PSNTMmVL z3tfHSM>*t~8)$idh@RK)0`-D=p hc^!pVN8DTd|2*nUh7HuY*bU;Q?H$o56vd3uAqDSxn zTJ#vLvhO=HH^k}zf)1SDd*(Yo_cGN+qkdTtnSrL3NM1-eaU~;ZabSTg8@|AEAnino zq~P6x3*bDYgKx5$nNM)6Eb%8(@hCw?Vh5ZP&gI#jl zoR_n+Z(Ha&cOM+}nDv^n=YE@Q?wnlaKxtiDOk#~^WCOqoGt7ard$ hH?|8|QY*%OOMqVxBY7%g@&CU6-ur&XeOC1!_6daafNlT) literal 0 HcmV?d00001 diff --git a/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json new file mode 100644 index 000000000..9d78e52d0 --- /dev/null +++ b/evidence/results/a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "c790032dee88ed577cdaa9efe470af5455e4847205a81a7744da3e15d630fc1b", + "case_hash": "a33d2ac3e1dc2966754bcc7c4829c2b6dcdb1ce3f40f0d7fefc0865e9b263bd3", + "columns": [ + { + "internal_size": null, + "name": "COUNT(*)", + "nullable": false, + "precision": 18, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "SUM(N)", + "nullable": true, + "precision": 22, + "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": "48d82daac8b8c34cd3f5f3edcb3bc697730d62f1d0f316811c5e1f457458eb1f", + "recorded_at": "2026-11-06T03:52:30.454184+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "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" +} diff --git a/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow b/evidence/results/b814e299ffaba1caa43bdf0b0f7d12a4a658c70a9db500f24bd827e1d092000e.arrow new file mode 100644 index 0000000000000000000000000000000000000000..f6037dd9bbc7ddb78529d28c0e54d243a6a1c6c0 GIT binary patch literal 1770 zcmeHIy-or_5FYe65HE%pVk|5yEYMg0nqUc0{7H-?Ah!bh*l2pb+iBI{?@VBhFvi;NJFm6NTJ!0(E^&YfcBtW=p9t~E8uSD@%I1>?}jz=JlTW8 zTsEK2&EoNW4tAW+&xyUs`8c27V|ns z?xXWGR*a4_rmwLV0u-;~JU>(1#{mmADs>QQIWVXwMk4O(jrmRkV0X!1uY09>|NOw*=Q9>M^HgN42$eGi!3yWbcd)E zdHuUug@Z^3>T%rTGpsYUAdhBpPqHZK;t+Wcc?&s@d=HsE&c0Wx92e_w;n;4Vd|oQg zE$25^i>N10FKRF)XY9{5E5%%~yjWQh8L>1yo@Rb&ZcQ~yJ{pd5eSU74{cIa{OjG&> zL^zh!wc>nfy|hwBeIFXmk>dp(8J?KPWi#V)6ZPel#S&aOqh39$RzIEB_;gr&_BDV&I+vq_adnJ*fV_h|gRF6t|9LgH4QLX0hT|a9RA0I`)!*NX*M2F! zUS6R!@)iA=l39v8AB z$vj+i@m{0#xPyM<(6G;X>+!(QaQtaZkb8-_9zR-|l&3K{of;bUWphekUOJX~oCcxM zcx%Kdj2Y2hcJ?f3;xjRi75iYq_9m%pUqe3o#+d5yIkGgp@N4t=W@$KgYD_)}j8*6F z$7jH@r}4bKU(GDhH3f~?0YU_-S<2n!uNO|KpMqh9M4nVC32SogmgZQcJ|e!8BOANg_&yXJIo_aOMuq+`*YU nICBSQ?%>QFoVjcN!vtsU+8KgpJNq}w|8nMz;)hVi`H%4rDk?4s literal 0 HcmV?d00001 diff --git a/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json new file mode 100644 index 000000000..79cb2a1a7 --- /dev/null +++ b/evidence/results/c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e.json @@ -0,0 +1,124 @@ +{ + "arrow_sha256": "48ca0d6103fec22c1a50ef4bc5122b8f1bf34e167a82638c82537f5f456c4b04", + "case_hash": "c63635c159fd9cc02b6e7a8030e4e8338a4f67baf34f9eca42e759d3c49a7f8e", + "columns": [ + { + "internal_size": null, + "name": "1", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "-2", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "1.50", + "nullable": false, + "precision": 2, + "scale": 1, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "1E3", + "nullable": false, + "precision": 4, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 10, + "name": "'MIXED TEXT'", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 0, + "name": "NULL", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "TRUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + }, + { + "internal_size": null, + "name": "FALSE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 13, + "type_name": "BOOLEAN" + } + ], + "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": "efbbe9c60b4259421018b9237d01c43db0bb92871cb387ae0c489d917db44c69", + "recorded_at": "2026-11-06T03:52:30.454184+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "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" +} From 3f888ee66aad269471c396e8674f73265d45c544 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:16:09 +0000 Subject: [PATCH 3/9] feat: name unaliased select items the way Snowflake does An unaliased select item that is not a column takes its source text upper-cased as written, with spacing, line breaks and quotes kept, as the recorded receipts show. Literals, which carry no source position, are rebuilt from their value. Text outside ASCII or holding a comment is still refused with SQ0009. --- .../src/diagnostics/constants.rs | 10 +-- crates/sqlquack-core/src/parsing/models.rs | 11 +++ .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../src/conversion/_helpers/queries/query.rs | 68 ++++++++++++++++--- .../conversion/_helpers/support/locations.rs | 5 ++ crates/sqlquack/tests/cli/test_convert.rs | 42 +++++++++++- .../docs/docs/diagnostics/reference.mdx | 4 +- 7 files changed, 123 insertions(+), 19 deletions(-) diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index 1757bdcd4..a430470d0 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -168,11 +168,11 @@ pub const CODES: &[CodeEntry] = &[ 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 'a' || 'b'" | sqlquack convert --builtin-policy"#, + when recorded results verify it: for ASCII text without comments, the name is the \ + text upper-cased as written. The expression at the span holds other characters or a \ + comment, 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 'café' || 'b'" | sqlquack convert --builtin-policy"#, }, CodeEntry { code: "SQ0101", diff --git a/crates/sqlquack-core/src/parsing/models.rs b/crates/sqlquack-core/src/parsing/models.rs index a4cd2df1d..808fefe2a 100644 --- a/crates/sqlquack-core/src/parsing/models.rs +++ b/crates/sqlquack-core/src/parsing/models.rs @@ -53,6 +53,7 @@ pub struct SourceLines { line_starts: Vec, length: usize, shift: usize, + source: String, } impl SourceLines { @@ -75,9 +76,19 @@ impl SourceLines { line_starts, length, shift, + source: source.to_owned(), } } + /// The source text at `location`, a location these lines gave. + pub fn text(&self, location: &SourceLocation) -> String { + self.source + .chars() + .skip(location.start) + .take(location.end.saturating_sub(location.start)) + .collect() + } + /// The location of the characters `start..end` of the parsed text. pub fn location(&self, start: usize, end: usize) -> SourceLocation { let start = start.saturating_sub(self.shift).min(self.length); diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..d85c02f5c 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -194,7 +194,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: ALIAS, constructs: &["node:alias"], - conditions: "A single name.", + conditions: "A single name. An unaliased select item that is not a column takes Snowflake's name for it: its text upper-cased as written, spacing, line breaks and quotes kept, as recorded; text outside ASCII or holding a comment is refused with SQ0009.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs index 04ecd87cc..e360ff2d4 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs @@ -26,8 +26,8 @@ use crate::conversion::_helpers::support::scope::read_column; use crate::conversion::_helpers::support::scope::{Context, Emitted, Scope}; use crate::conversion::_helpers::support::site::{Placement, Site}; use crate::conversion::_helpers::support::tree::{ - Payload, field, flag, function_construct, identifier_name, list, node_construct, node_kind, - payload_object, require_understood, + LITERAL_NUMBER, LITERAL_STRING, Payload, field, flag, function_construct, identifier_name, + list, node_construct, node_kind, payload_object, require_understood, }; use crate::conversion::constants::WINDOW_MARKER; use crate::conversion::constants::{ @@ -622,14 +622,27 @@ fn convert_projection( } Some((kind, _)) => { let emitted = convert_held(item, site)?; - if named { - 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)); + if !named { + return Ok(vec![(None, emitted)]); + } + let written = site + .context + .locator + .source_text(item) + .or_else(|| literal_text(item)); + match written.and_then(output_name) { + Some(name) => { + let _rule = site.context.log.rule(ALIAS); + Ok(vec![(Some(name), emitted)]) + } + None => { + let refusal = missing( + Construct::new(ConstructFamily::Syntax, UNALIASED_OUTPUT_NAME_CONSTRUCT), + format!("Snowflake names this `{kind}` column after its text"), + ); + Err(site.context.locator.placed(refusal, item)) + } } - Ok(vec![(None, emitted)]) } None => Err(missing( node_construct("unknown"), @@ -638,6 +651,43 @@ fn convert_projection( } } +/// The name Snowflake gives an unaliased select item written as `text`: the text upper-cased as +/// written, spacing and line breaks kept, as recorded. Text outside ASCII, or holding a comment, +/// has no recorded name. +fn output_name(text: String) -> Option { + let recorded = !text.is_empty() + && text.is_ascii() + && text.trim() == text + && !text.contains("--") + && !text.contains("/*"); + recorded.then(|| text.to_ascii_uppercase()) +} + +/// The text a literal, which the parse gives no position, was written as: a number as parsed, a +/// string between quotes, NULL, TRUE or FALSE. A string holding a quote or a backslash could have +/// been written in more than one way, so it has none. +fn literal_text(item: &Value) -> Option { + match node_kind(item)? { + ("literal", payload) => { + let value = payload.get("value")?.as_str()?; + match payload.get("literal_type")?.as_str()? { + LITERAL_NUMBER => Some(value.to_owned()), + LITERAL_STRING if !value.contains(['\'', '\\']) => Some(format!("'{value}'")), + _ => None, + } + } + ("boolean", payload) => Some( + match payload.get("value")?.as_bool()? { + true => "TRUE", + false => "FALSE", + } + .to_owned(), + ), + ("null", _) => Some("NULL".to_owned()), + _ => None, + } +} + /// The columns `*` or `alias.*` names, without those its EXCLUDE list names. pub(crate) fn star(payload: &Value, site: Site<'_>) -> Result, Refusal> { let log = site.context.log; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs index ebb12352d..f10b289cb 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/locations.rs @@ -100,6 +100,11 @@ impl<'a> Locator<'a> { Some(self.lines.location(*start, *end)) } + /// The source text of `node`, a node of the converted tree; `None` for a node built during conversion. + pub(crate) fn source_text(&self, node: &Value) -> Option { + self.locate(node).map(|location| self.lines.text(&location)) + } + /// `refusal`, placed at `node` unless a node inside it placed it already. pub(crate) fn placed(&self, mut refusal: Refusal, node: &Value) -> Refusal { if refusal.location.is_none() { diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 304766dd3..6477c983c 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -391,6 +391,44 @@ fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_pri } } +#[test] +fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowflakes_names() { + let test_cases = [ + ColumnNameTestCase { + description: "an aggregate, a literal and an expression across lines, named as recorded", + sql: "SELECT COUNT(*), 'MiXeD text', max(n)\n + 1, Count( DISTINCT n ) \ + FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec![ + "COUNT(*)".to_owned(), + "'MIXED TEXT'".to_owned(), + "MAX(N)\n + 1".to_owned(), + "COUNT( DISTINCT N )".to_owned(), + ], + }, + ColumnNameTestCase { + description: "numbers keep their written form, upper-cased", + sql: "SELECT 1.50, 1e3, -2, NULL, false", + flags: &[], + expected_names: vec![ + "1.50".to_owned(), + "1E3".to_owned(), + "-2".to_owned(), + "NULL".to_owned(), + "FALSE".to_owned(), + ], + }, + ]; + for test_case in test_cases { + assert_eq!( + converted_column_names(test_case.sql, test_case.flags), + test_case.expected_names, + "{}", + test_case.description + ); + } +} + #[test] fn given_quoted_names_when_converted_and_run_on_duckdb_then_output_names_follow_the_session() { let test_cases = [ @@ -1372,11 +1410,11 @@ fn given_refused_sql_when_converting_then_text_and_json_carry_the_same_coded_dia }, ConvertDiagnosticTestCase { description: "an unaliased item has its own code, its own span and an alias as help", - sql: "SELECT 'a' AS a, UPPER('b')", + sql: "SELECT 'a' AS a, UPPER('é')", 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 gives it\n --> :1:18\n |\n1 | SELECT 'a' AS a, UPPER('é')\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", diff --git a/website/src/content/docs/docs/diagnostics/reference.mdx b/website/src/content/docs/docs/diagnostics/reference.mdx index 4b3d01460..fd540a7ca 100644 --- a/website/src/content/docs/docs/diagnostics/reference.mdx +++ b/website/src/content/docs/docs/diagnostics/reference.mdx @@ -140,14 +140,14 @@ echo "SELECT HASH(1) AS h" | sqlquack convert --builtin-policy **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. +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 when recorded results verify it: for ASCII text without comments, the name is the text upper-cased as written. The expression at the span holds other characters or a comment, 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 +echo "SELECT 'café' || 'b'" | sqlquack convert --builtin-policy ``` ## Input limits From 70231e02ac29619e7ef4491361ef0932c9deb430 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:00:00 +0000 Subject: [PATCH 4/9] fix: type a statement's result column of only NULL as VARCHAR Snowflake describes an untyped NULL result column as VARCHAR(0), as the recorded unaliased NULL literal shows; the converted statement now casts such a column to VARCHAR at its output, leaving inner queries untouched. --- .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../src/conversion/main/convert_statement.rs | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index d85c02f5c..d9199a8bd 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -384,7 +384,7 @@ pub const RULES: &[RuleSpec] = &[ "fn:DATE_PART", "node:extract", ], - conditions: "Every branch an untyped NULL, in a conditional, in every branch of a set operation, or as the operand of DATE_PART, EXTRACT and the single-part functions: the result is NULL of Snowflake's untyped NULL type, described as VARCHAR(0), held as a DuckDB VARCHAR, as recorded.", + conditions: "Every branch an untyped NULL, in a conditional, in every branch of a set operation, as the operand of DATE_PART, EXTRACT and the single-part functions, or as a result column of the statement: the result is NULL of Snowflake's untyped NULL type, described as VARCHAR(0), held as a DuckDB VARCHAR, as recorded.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index d6d8d2737..68df54420 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -20,7 +20,7 @@ use crate::conversion::_helpers::support::depth::positioned; use crate::conversion::_helpers::support::locations::Locator; use crate::conversion::_helpers::support::scope::{Context, StatementTracks}; use crate::conversion::_helpers::support::tree::{node_construct, session_tree}; -use crate::conversion::constants::OUTPUT_ALIAS; +use crate::conversion::constants::{NULL_BRANCHES, OUTPUT_ALIAS}; use crate::conversion::models::{ConversionOptions, Converted}; use sqlquack_core::refusals::main::outside_conditions::outside_conditions; use sqlquack_semantics::types::models::TypedLeaves; @@ -50,9 +50,14 @@ pub fn convert_statement( free_projections: false, fixture_body: false, }; - let (sql, columns) = checked(&typed, convert_query(&tree, None, &context, true)) + let (sql, mut columns) = checked(&typed, convert_query(&tree, None, &context, true)) .map_err(|refusal| locator.placed(refusal, &tree))?; let (sql, raises) = locator.finished(&output_variants(sql, &columns, &log), &tree); + for column in &mut columns { + if column.data_type == SnowflakeType::Null { + column.data_type = SnowflakeType::Varchar; + } + } let relation = |name: &str| -> Option> { let columns = schema.relations.get(name)?; Some(column_types(columns)) @@ -74,20 +79,26 @@ pub fn convert_statement( }) } -/// The query with each VARIANT result column written as Snowflake returns it. +/// The query with each VARIANT result column written as Snowflake returns it, and each column +/// of only NULL typed VARCHAR, as Snowflake types it. fn output_variants(sql: String, columns: &[ColumnSpec], log: &ProvenanceLog) -> String { let replaced: Vec = columns .iter() - .filter(|column| column.data_type == SnowflakeType::SemiStructured) - .map(|column| { + .filter_map(|column| { let name = quoted_identifier(&column.name, log); - match column.holding { - Holding::Variant => { - format!("CAST({} AS JSON) AS {name}", variant_written(&name, log)) + match (column.data_type, column.holding) { + (SnowflakeType::SemiStructured, Holding::Variant) => Some(format!( + "CAST({} AS JSON) AS {name}", + variant_written(&name, log) + )), + (SnowflakeType::SemiStructured, Holding::Json | Holding::Chained) => { + Some(format!("{} AS {name}", json_output(&name, log))) } - Holding::Json | Holding::Chained => { - format!("{} AS {name}", json_output(&name, log)) + (SnowflakeType::Null, _) => { + let _rule = log.rule(NULL_BRANCHES); + Some(format!("CAST({name} AS VARCHAR) AS {name}")) } + _ => None, } }) .collect(); From fe4d5dac2a8f95d5f63c58014ab42ba510229f15 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:29:00 +0000 Subject: [PATCH 5/9] style: keep the output-name doc comments to one line --- .../src/conversion/_helpers/queries/query.rs | 8 ++------ .../src/conversion/main/convert_statement.rs | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs index e360ff2d4..d8f3386e2 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs @@ -651,9 +651,7 @@ fn convert_projection( } } -/// The name Snowflake gives an unaliased select item written as `text`: the text upper-cased as -/// written, spacing and line breaks kept, as recorded. Text outside ASCII, or holding a comment, -/// has no recorded name. +/// Snowflake's name for an unaliased item written as ASCII `text` without comments: it upper-cased. fn output_name(text: String) -> Option { let recorded = !text.is_empty() && text.is_ascii() @@ -663,9 +661,7 @@ fn output_name(text: String) -> Option { recorded.then(|| text.to_ascii_uppercase()) } -/// The text a literal, which the parse gives no position, was written as: a number as parsed, a -/// string between quotes, NULL, TRUE or FALSE. A string holding a quote or a backslash could have -/// been written in more than one way, so it has none. +/// A literal's written text: a number as parsed, a plain string quoted, NULL, TRUE or FALSE. fn literal_text(item: &Value) -> Option { match node_kind(item)? { ("literal", payload) => { diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index 68df54420..09a30d6a6 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -79,8 +79,7 @@ pub fn convert_statement( }) } -/// The query with each VARIANT result column written as Snowflake returns it, and each column -/// of only NULL typed VARCHAR, as Snowflake types it. +/// The query with VARIANT result columns written as Snowflake returns them, NULL-only ones VARCHAR. fn output_variants(sql: String, columns: &[ColumnSpec], log: &ProvenanceLog) -> String { let replaced: Vec = columns .iter() From 41f756fc3c0be4f97d17c2116b636181150561a0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:48:13 +0000 Subject: [PATCH 6/9] fix: name only the unaliased item shapes Snowflake receipts record Naming an unaliased select item after its text now applies only to the shapes the receipts cover: literals in their recorded spellings, COUNT, SUM, MAX and AVG of a column or COUNT(*), LOWER, UPPER and COALESCE of columns and integers, a column then a literal under + - * / and ||, and a column cast to INTEGER, written with single spaces and at most one line break. Semi-structured paths and subscripts, which were named from a span stopping at their base, and every other shape refuse with SQ0009 again. A repeated result name now suggests aliasing each column. --- .../src/diagnostics/constants.rs | 16 +- .../src/diagnostics/main/diagnose.rs | 10 +- .../src/conversion/_helpers/queries/mod.rs | 1 + .../_helpers/queries/output_names.rs | 153 ++++++++++++++++++ .../src/conversion/_helpers/queries/query.rs | 22 ++- .../src/conversion/_helpers/support/tree.rs | 21 ++- .../src/conversion/constants.rs | 9 ++ .../tests/cli/fixtures/unaliased_items.json | 11 ++ crates/sqlquack/tests/cli/test_convert.rs | 85 +++++++++- .../docs/docs/diagnostics/reference.mdx | 2 +- 10 files changed, 306 insertions(+), 24 deletions(-) create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs create mode 100644 crates/sqlquack/tests/cli/fixtures/unaliased_items.json diff --git a/crates/sqlquack-core/src/diagnostics/constants.rs b/crates/sqlquack-core/src/diagnostics/constants.rs index a430470d0..d150544c6 100644 --- a/crates/sqlquack-core/src/diagnostics/constants.rs +++ b/crates/sqlquack-core/src/diagnostics/constants.rs @@ -10,6 +10,11 @@ pub const CASE_ONLY_MATCH: &str = "only without regard to case"; pub const CASE_ONLY_MATCH_HELP: &str = "Snowflake folds unquoted names to upper case, so declare \ the relation or column in the schema as the SQL resolves it (`ORDERS` for `FROM orders`), or \ quote the name in the SQL to keep its case"; +/// The words of an unresolved name's reason when two result columns share a name. +pub const REPEATED_RESULT_NAME: &str = "repeats, possibly differing only in case"; +/// The help for such a name, replacing SQ0004's own. +pub const REPEATED_RESULT_NAME_HELP: &str = + "give each result column its own alias, such as `SELECT 1 AS first, 1 AS second`"; /// The words of a refusal reading a TIMESTAMP_TZ's local time, which needs the offset DuckDB drops. pub const ZONED_LOCAL_TIME: &str = "which reads the local time of an offset DuckDB's TIMESTAMPTZ drops"; @@ -168,10 +173,13 @@ pub const CODES: &[CodeEntry] = &[ 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 \ - when recorded results verify it: for ASCII text without comments, the name is the \ - text upper-cased as written. The expression at the span holds other characters or a \ - comment, so the statement is refused rather than converted with a different column \ - name. An alias fixes the name in both engines.", + only for the shapes recorded results verify, named by their text upper-cased as \ + written: literals, columns and COUNT, SUM, MAX, AVG, LOWER, UPPER and COALESCE of \ + columns, a column plus, minus, times, divided by or concatenated with a literal, and \ + a column cast to INTEGER, written with single spaces and at most one line break. \ + Other items, such as paths, CASE, nested calls or other functions, are refused \ + rather than converted with a different column name. An alias fixes the name in both \ + engines.", example: r#"echo "SELECT 'café' || 'b'" | sqlquack convert --builtin-policy"#, }, CodeEntry { diff --git a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs index 200f1c349..30a8ffe0b 100644 --- a/crates/sqlquack-core/src/diagnostics/main/diagnose.rs +++ b/crates/sqlquack-core/src/diagnostics/main/diagnose.rs @@ -6,8 +6,9 @@ use crate::diagnostics::_helpers::coded::coded; use crate::diagnostics::_helpers::line_index::LineIndex; use crate::diagnostics::constants::{ CASE_ONLY_MATCH, CASE_ONLY_MATCH_HELP, CONVERSION_BUDGET, DIVERGENCE_REFUSED, GUARD_PREFIX, - MISSING_RULE, NOT_EXPRESSIBLE, PARSE_FIDELITY, TYPE_CONDITION, UNPARSED_PREFIX, - UNRESOLVED_NAME, ZONED_LOCAL_TIME, ZONED_LOCAL_TIME_HELP, ZONED_LOCAL_TIME_NOTE, + MISSING_RULE, NOT_EXPRESSIBLE, PARSE_FIDELITY, REPEATED_RESULT_NAME, REPEATED_RESULT_NAME_HELP, + TYPE_CONDITION, UNPARSED_PREFIX, UNRESOLVED_NAME, ZONED_LOCAL_TIME, ZONED_LOCAL_TIME_HELP, + ZONED_LOCAL_TIME_NOTE, }; use crate::diagnostics::models::Diagnostic; use crate::refusals::models::{Refusal, RefusalCategory}; @@ -25,6 +26,11 @@ pub fn diagnose(refusals: &[Refusal], source: &str) -> Vec { { diagnostic.help = Some(CASE_ONLY_MATCH_HELP.to_owned()); } + if refusal.category == RefusalCategory::UnresolvedName + && refusal.reason.contains(REPEATED_RESULT_NAME) + { + diagnostic.help = Some(REPEATED_RESULT_NAME_HELP.to_owned()); + } if refusal.category == RefusalCategory::TypeCondition && refusal.reason.contains(ZONED_LOCAL_TIME) { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs index 17a5569da..8cda66712 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod flatten; pub(crate) mod function_body; pub(crate) mod group_all; pub(crate) mod hierarchy; +pub(crate) mod output_names; pub(crate) mod pivot; pub(crate) mod query; pub(crate) mod set_operations; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs new file mode 100644 index 000000000..66432287c --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs @@ -0,0 +1,153 @@ +//! The unaliased select items whose column names Snowflake receipts record. + +use serde_json::Value; + +use crate::conversion::_helpers::support::tree::{ + LITERAL_NUMBER, LITERAL_STRING, list, node_kind, unexpected_fields, +}; +use crate::conversion::constants::{ + OUTPUT_NAMED_AGGREGATES, OUTPUT_NAMED_ARITHMETIC, OUTPUT_NAMED_CAST_ENDINGS, + OUTPUT_NAMED_FUNCTIONS, +}; + +/// Whether an unaliased item written as `text` has a shape whose name receipts record. +pub(crate) fn recorded_name_shape(item: &Value, text: &str) -> bool { + let Some((kind, payload)) = node_kind(item) else { + return false; + }; + let Some(fields) = payload.as_object() else { + return matches!(kind, "null"); + }; + let only = |understood: &[&str]| unexpected_fields(fields, understood).is_empty(); + let named = |names: &[&str]| { + payload + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| names.contains(&name.to_ascii_uppercase().as_str())) + }; + let arguments = list(fields, "args"); + match kind { + "aggregate_function" => { + only(&["name", "args", "distinct"]) + && named(OUTPUT_NAMED_AGGREGATES) + && arguments.len() == 1 + && arguments.iter().all(|argument| { + plain_column(argument) + || (named(&["COUNT"]) && matches!(node_kind(argument), Some(("star", _)))) + }) + } + "function" => { + only(&["name", "args"]) + && named(OUTPUT_NAMED_FUNCTIONS) + && !arguments.is_empty() + && arguments + .iter() + .all(|argument| plain_column(argument) || integer_literal(argument)) + } + "concat" => { + only(&["left", "right"]) + && payload.get("left").is_some_and(plain_column) + && payload.get("right").is_some_and(string_literal) + } + "cast" => { + let upper = text.to_ascii_uppercase(); + only(&["this", "to", "double_colon_syntax"]) + && payload.get("this").is_some_and(plain_column) + && OUTPUT_NAMED_CAST_ENDINGS + .iter() + .any(|ending| upper.ends_with(ending)) + } + "neg" => { + only(&["this"]) + && payload.get("this").is_some_and(integer_literal) + && text.strip_prefix('-').is_some_and(|rest| { + !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit()) + }) + } + "literal" => recorded_literal(item), + "null" | "boolean" => true, + arithmetic if OUTPUT_NAMED_ARITHMETIC.contains(&arithmetic) => { + only(&["left", "right"]) + && payload.get("left").is_some_and(plain_column) + && payload.get("right").is_some_and(integer_literal) + } + _ => false, + } +} + +/// A column, qualified or not, each part unquoted or quoted and already upper case. +fn plain_column(node: &Value) -> bool { + let Some(("column", payload)) = node_kind(node) else { + return false; + }; + let Some(fields) = payload.as_object() else { + return false; + }; + let upper_or_unquoted = |part: &Value| { + let quoted = part.get("quoted").and_then(Value::as_bool) == Some(true); + let name = part.get("name").and_then(Value::as_str).unwrap_or_default(); + !name.is_empty() && (!quoted || name == name.to_ascii_uppercase()) + }; + unexpected_fields(fields, &["name", "table"]).is_empty() + && payload.get("name").is_some_and(upper_or_unquoted) + && payload + .get("table") + .filter(|table| !table.is_null()) + .is_none_or(upper_or_unquoted) +} + +/// A literal spelled as a receipt records: an integer, a decimal, an exponent or plain text. +fn recorded_literal(node: &Value) -> bool { + let Some(("literal", payload)) = node_kind(node) else { + return false; + }; + let value = payload + .get("value") + .and_then(Value::as_str) + .unwrap_or_default(); + match payload.get("literal_type").and_then(Value::as_str) { + Some(LITERAL_NUMBER) => { + let (mantissa, exponent) = match value.split_once('e') { + Some((mantissa, exponent)) => (mantissa, Some(exponent)), + None => (value, None), + }; + let (whole, fraction) = match mantissa.split_once('.') { + Some((whole, fraction)) => (whole, Some(fraction)), + None => (mantissa, None), + }; + let digits = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()); + digits(whole) + && (whole == "0" || !whole.starts_with('0')) + && fraction.is_none_or(digits) + && exponent + .is_none_or(|exponent| fraction.is_none() && whole != "0" && digits(exponent)) + } + Some(LITERAL_STRING) => string_literal(node), + _ => false, + } +} + +fn integer_literal(node: &Value) -> bool { + recorded_literal(node) + && node_kind(node) + .and_then(|(_, payload)| payload.get("value")) + .and_then(Value::as_str) + .is_some_and(|value| value.bytes().all(|byte| byte.is_ascii_digit())) +} + +/// Non-empty printable ASCII text without a quote or backslash, which has one spelling. +fn string_literal(node: &Value) -> bool { + let Some(("literal", payload)) = node_kind(node) else { + return false; + }; + payload.get("literal_type").and_then(Value::as_str) == Some(LITERAL_STRING) + && payload + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| { + !value.is_empty() + && value + .bytes() + .all(|byte| (b' '..=b'~').contains(&byte) && byte != b'\'' && byte != b'\\') + }) +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs index d8f3386e2..9f31153c1 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/query.rs @@ -2,7 +2,9 @@ use serde_json::{Map, Value, json}; use sqlquack_core::classification::models::{Construct, ConstructFamily}; -use sqlquack_core::diagnostics::constants::UNALIASED_OUTPUT_NAME_CONSTRUCT; +use sqlquack_core::diagnostics::constants::{ + REPEATED_RESULT_NAME, UNALIASED_OUTPUT_NAME_CONSTRUCT, +}; use sqlquack_core::provenance::constants::QUERY_CONSTRUCT; use sqlquack_core::provenance::models::ShapeArgument; use sqlquack_core::refusals::main::missing::missing; @@ -20,6 +22,7 @@ use crate::conversion::_helpers::queries::clauses::{ }; use crate::conversion::_helpers::queries::group_all::group_all_positions; use crate::conversion::_helpers::queries::hierarchy::hierarchy; +use crate::conversion::_helpers::queries::output_names::recorded_name_shape; use crate::conversion::_helpers::queries::set_operations::convert_set_operation; use crate::conversion::_helpers::queries::sources::convert_from; use crate::conversion::_helpers::support::scope::read_column; @@ -630,7 +633,10 @@ fn convert_projection( .locator .source_text(item) .or_else(|| literal_text(item)); - match written.and_then(output_name) { + match written + .filter(|text| recorded_name_shape(item, text)) + .and_then(output_name) + { Some(name) => { let _rule = site.context.log.rule(ALIAS); Ok(vec![(Some(name), emitted)]) @@ -651,13 +657,19 @@ fn convert_projection( } } -/// Snowflake's name for an unaliased item written as ASCII `text` without comments: it upper-cased. +/// Snowflake's name for an unaliased item written as `text` in recorded spacing: it upper-cased. fn output_name(text: String) -> Option { let recorded = !text.is_empty() && text.is_ascii() && text.trim() == text && !text.contains("--") - && !text.contains("/*"); + && !text.contains("/*") + && !text.contains(['\t', '\r']) + && !text.contains(" (") + && text.matches('\n').count() <= 1 + && text + .split('\n') + .all(|line| !line.trim_start_matches(' ').contains(" ")); recorded.then(|| text.to_ascii_uppercase()) } @@ -831,7 +843,7 @@ pub(crate) fn require_distinct_names(columns: &[ColumnSpec]) -> Result<(), Refus .any(|earlier| earlier.name.eq_ignore_ascii_case(&column.name)) { return Err(unresolved(format!( - "the result column name `{}` repeats, possibly differing only in case", + "the result column name `{}` {REPEATED_RESULT_NAME}", column.name ))); } diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs index b08730857..32afd251b 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/tree.rs @@ -72,13 +72,9 @@ pub(crate) fn is_inert(value: &Value) -> bool { } } -/// Refuses when a payload sets a field the rule does not understand. -pub(crate) fn require_understood( - payload: &Payload, - understood: &[&str], - construct: &Construct, -) -> Result<(), Refusal> { - let unexpected: Vec<&str> = payload +/// The fields a payload sets beyond `understood`, cosmetic and inert ones aside. +pub(crate) fn unexpected_fields<'a>(payload: &'a Payload, understood: &[&str]) -> Vec<&'a str> { + payload .iter() .filter(|(field, value)| { !understood.contains(&field.as_str()) @@ -86,7 +82,16 @@ pub(crate) fn require_understood( && !is_inert(value) }) .map(|(field, _)| field.as_str()) - .collect(); + .collect() +} + +/// Refuses when a payload sets a field the rule does not understand. +pub(crate) fn require_understood( + payload: &Payload, + understood: &[&str], + construct: &Construct, +) -> Result<(), Refusal> { + let unexpected = unexpected_fields(payload, understood); if unexpected.is_empty() { return Ok(()); } diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 6269405a0..006eb3e0f 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1981,3 +1981,12 @@ pub(crate) const CORRELATED_DERIVED: &str = pub(crate) const AMBIGUOUS_SUFFIX: &str = "is ambiguous"; /// A JSON-held VARIANT as a sort key, in ARRAY_SORT's recorded order of kinds. pub const VARIANT_ORDER_KEY: &str = "rule.variant_order_key"; + +/// Aggregates whose unaliased column names Snowflake receipts record. +pub(crate) const OUTPUT_NAMED_AGGREGATES: &[&str] = &["COUNT", "SUM", "MAX", "AVG"]; +/// Scalar functions whose unaliased column names Snowflake receipts record. +pub(crate) const OUTPUT_NAMED_FUNCTIONS: &[&str] = &["LOWER", "UPPER", "COALESCE"]; +/// Arithmetic whose unaliased column names, a column then a number, receipts record. +pub(crate) const OUTPUT_NAMED_ARITHMETIC: &[&str] = &["add", "sub", "mul", "div"]; +/// The cast target spellings whose unaliased column names receipts record. +pub(crate) const OUTPUT_NAMED_CAST_ENDINGS: &[&str] = &[" AS INTEGER)", "::INTEGER"]; diff --git a/crates/sqlquack/tests/cli/fixtures/unaliased_items.json b/crates/sqlquack/tests/cli/fixtures/unaliased_items.json new file mode 100644 index 000000000..80704c545 --- /dev/null +++ b/crates/sqlquack/tests/cli/fixtures/unaliased_items.json @@ -0,0 +1,11 @@ +{ + "relations": { + "INPUT": [ + ["N", "NUMBER(10,2)"], + ["G", "VARCHAR"], + ["V", "VARIANT"], + ["T", "TIMESTAMP_NTZ(9)"], + ["lc", "NUMBER(10,0)"] + ] + } +} diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 6477c983c..f95da58bc 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -19,12 +19,20 @@ const QUOTED_ALIAS: &str = "WITH totals AS (SELECT 1 AS \"orderTotal\") SELECT ordertotal FROM totals"; const PLACED_DATE: &str = "SELECT TO_DATE(placed_at) AS placed_on FROM orders"; const DECLARED_SCHEMA: &str = "tests/cli/fixtures/declared_offsets.json"; +const UNALIASED_SCHEMA: &str = "tests/cli/fixtures/unaliased_items.json"; const NAIVE_TO_ZONED: &str = "SELECT '2024-03-10 22:30:00'::TIMESTAMP_NTZ(6)::TIMESTAMP_TZ AS placed_at"; #[test] fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_printed() { let test_cases = [ + ConvertTestCase { + description: "a repeated result name refuses with help to alias each column", + sql: "SELECT 1, 1", + flags: &[], + mention: "give each result column its own alias", + expected_outcome: convert_outcome(1, false), + }, ConvertTestCase { description: "EQUAL_NULL of a VARIANT and text refuses: no recording shows a JSON null", sql: "SELECT EQUAL_NULL(PARSE_JSON('null'), '1') AS e", @@ -395,17 +403,21 @@ fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_pri fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowflakes_names() { let test_cases = [ ColumnNameTestCase { - description: "an aggregate, a literal and an expression across lines, named as recorded", - sql: "SELECT COUNT(*), 'MiXeD text', max(n)\n + 1, Count( DISTINCT n ) \ - FROM (SELECT 1 AS n) AS input", + description: "aggregates and a literal, named as recorded", + sql: "SELECT COUNT(*), 'MiXeD text', Count( DISTINCT n ) FROM (SELECT 1 AS n) AS input", flags: &[], expected_names: vec![ "COUNT(*)".to_owned(), "'MIXED TEXT'".to_owned(), - "MAX(N)\n + 1".to_owned(), "COUNT( DISTINCT N )".to_owned(), ], }, + ColumnNameTestCase { + description: "a column-and-number expression across a line break keeps the break", + sql: "SELECT n\n + 1 FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec!["N\n + 1".to_owned()], + }, ColumnNameTestCase { description: "numbers keep their written form, upper-cased", sql: "SELECT 1.50, 1e3, -2, NULL, false", @@ -429,6 +441,71 @@ fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowfla } } +#[test] +fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with_sq0009() { + let shapes: [(&str, &str); 44] = [ + ( + "v:a", + "a semi-structured path, which Snowflake names after the whole path", + ), + ("v:a.b", "a nested path"), + ("v['a']", "a subscript by key"), + ("v:a[0]", "a path with a subscript by index"), + ("\"V\":a", "a path from a quoted column"), + ("input.v:a", "a path from a qualified column"), + ("PARSE_JSON('{}'):a", "a path from a call"), + ("v:a::STRING", "a cast of a path"), + ("CASE WHEN n > 1 THEN 1 END", "CASE"), + ("IFF(n > 1, 1, 0)", "IFF"), + ("(SELECT 1)", "a scalar subquery"), + ("EXISTS (SELECT 1)", "EXISTS"), + ("n IN (1, 2)", "IN"), + ("n BETWEEN 1 AND 2", "BETWEEN"), + ("g LIKE 'a%'", "LIKE"), + ("n IS NULL", "IS NULL"), + ("n IS NOT DISTINCT FROM 1", "IS NOT DISTINCT FROM"), + ("NOT TRUE", "NOT"), + ("TRUE AND FALSE", "AND"), + ("ROW_NUMBER() OVER (ORDER BY n)", "a window function"), + ("UPPER(LOWER(g))", "a nested call"), + ("(n + 1)", "a parenthesised expression"), + ("-n", "a negated column"), + ("n % 2", "modulo"), + ("n::INT::STRING", "a chained cast"), + ("CAST(n AS INT)", "a cast spelled INT"), + ("ROUND(n, 1)", "ROUND, which no receipt names"), + ("CURRENT_DATE()", "CURRENT_DATE(), which no receipt names"), + ("DATEADD(day, 1, t)", "DATEADD"), + ("TRY_CAST(g AS INTEGER)", "TRY_CAST"), + ("1 + n", "a literal before a column"), + ("n\t+ 1", "a tab"), + ("n + 1", "a double space"), + ("COUNT (*)", "a space before the parenthesis"), + ("n\r\n+ 1", "a CRLF line break"), + (".5", "a decimal without a leading digit"), + ("007", "an integer with leading zeros"), + ("1.0e-3", "an exponent with a fraction and a sign"), + ("1E+3", "an exponent with a plus sign"), + ("- 2", "a spaced negative"), + ("-(2)", "a negated parenthesised number"), + ("'a\tb'", "text with a tab"), + ("''", "empty text"), + ("\"lc\" + 1", "a quoted identifier that is not upper case"), + ]; + for (item, description) in shapes { + let sql = format!("SELECT {item} FROM input"); + assert_eq!( + run_convert( + &sql, + &["--schema", UNALIASED_SCHEMA], + "has not verified the name Snowflake gives it" + ), + convert_outcome(1, false), + "{description} refuses with SQ0009" + ); + } +} + #[test] fn given_quoted_names_when_converted_and_run_on_duckdb_then_output_names_follow_the_session() { let test_cases = [ diff --git a/website/src/content/docs/docs/diagnostics/reference.mdx b/website/src/content/docs/docs/diagnostics/reference.mdx index fd540a7ca..9b0f820af 100644 --- a/website/src/content/docs/docs/diagnostics/reference.mdx +++ b/website/src/content/docs/docs/diagnostics/reference.mdx @@ -140,7 +140,7 @@ echo "SELECT HASH(1) AS h" | sqlquack convert --builtin-policy **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 when recorded results verify it: for ASCII text without comments, the name is the text upper-cased as written. The expression at the span holds other characters or a comment, so the statement is refused rather than converted with a different column name. An alias fixes the name in both engines. +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 shapes recorded results verify, named by their text upper-cased as written: literals, columns and COUNT, SUM, MAX, AVG, LOWER, UPPER and COALESCE of columns, a column plus, minus, times, divided by or concatenated with a literal, and a column cast to INTEGER, written with single spaces and at most one line break. Other items, such as paths, CASE, nested calls or other functions, are 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` From a3b4a52bf5c136d41fc1c1bd57d3950ca322eea3 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:53:19 +0000 Subject: [PATCH 7/9] test: one case per unrecorded unaliased shape, in their own file --- .../_helpers/queries/output_names.rs | 5 +- crates/sqlquack/tests/cli.rs | 2 + crates/sqlquack/tests/cli/test_convert.rs | 108 ----- .../sqlquack/tests/cli/test_output_names.rs | 369 ++++++++++++++++++ 4 files changed, 374 insertions(+), 110 deletions(-) create mode 100644 crates/sqlquack/tests/cli/test_output_names.rs diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs index 66432287c..b4904c54e 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/output_names.rs @@ -116,11 +116,12 @@ fn recorded_literal(node: &Value) -> bool { None => (mantissa, None), }; let digits = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()); + let leading_zero = whole.starts_with('0'); digits(whole) - && (whole == "0" || !whole.starts_with('0')) + && (whole.len() == 1 || !leading_zero) && fraction.is_none_or(digits) && exponent - .is_none_or(|exponent| fraction.is_none() && whole != "0" && digits(exponent)) + .is_none_or(|exponent| fraction.is_none() && !leading_zero && digits(exponent)) } Some(LITERAL_STRING) => string_literal(node), _ => false, diff --git a/crates/sqlquack/tests/cli.rs b/crates/sqlquack/tests/cli.rs index 4d05fd8e7..1f05e64e2 100644 --- a/crates/sqlquack/tests/cli.rs +++ b/crates/sqlquack/tests/cli.rs @@ -18,6 +18,8 @@ mod test_evidence_commands; mod test_explain; #[path = "cli/test_function.rs"] mod test_function; +#[path = "cli/test_output_names.rs"] +mod test_output_names; #[path = "cli/test_review.rs"] mod test_review; #[path = "cli/test_types.rs"] diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index f95da58bc..a3b5a6e2c 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -19,7 +19,6 @@ const QUOTED_ALIAS: &str = "WITH totals AS (SELECT 1 AS \"orderTotal\") SELECT ordertotal FROM totals"; const PLACED_DATE: &str = "SELECT TO_DATE(placed_at) AS placed_on FROM orders"; const DECLARED_SCHEMA: &str = "tests/cli/fixtures/declared_offsets.json"; -const UNALIASED_SCHEMA: &str = "tests/cli/fixtures/unaliased_items.json"; const NAIVE_TO_ZONED: &str = "SELECT '2024-03-10 22:30:00'::TIMESTAMP_NTZ(6)::TIMESTAMP_TZ AS placed_at"; @@ -399,113 +398,6 @@ fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_pri } } -#[test] -fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowflakes_names() { - let test_cases = [ - ColumnNameTestCase { - description: "aggregates and a literal, named as recorded", - sql: "SELECT COUNT(*), 'MiXeD text', Count( DISTINCT n ) FROM (SELECT 1 AS n) AS input", - flags: &[], - expected_names: vec![ - "COUNT(*)".to_owned(), - "'MIXED TEXT'".to_owned(), - "COUNT( DISTINCT N )".to_owned(), - ], - }, - ColumnNameTestCase { - description: "a column-and-number expression across a line break keeps the break", - sql: "SELECT n\n + 1 FROM (SELECT 1 AS n) AS input", - flags: &[], - expected_names: vec!["N\n + 1".to_owned()], - }, - ColumnNameTestCase { - description: "numbers keep their written form, upper-cased", - sql: "SELECT 1.50, 1e3, -2, NULL, false", - flags: &[], - expected_names: vec![ - "1.50".to_owned(), - "1E3".to_owned(), - "-2".to_owned(), - "NULL".to_owned(), - "FALSE".to_owned(), - ], - }, - ]; - for test_case in test_cases { - assert_eq!( - converted_column_names(test_case.sql, test_case.flags), - test_case.expected_names, - "{}", - test_case.description - ); - } -} - -#[test] -fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with_sq0009() { - let shapes: [(&str, &str); 44] = [ - ( - "v:a", - "a semi-structured path, which Snowflake names after the whole path", - ), - ("v:a.b", "a nested path"), - ("v['a']", "a subscript by key"), - ("v:a[0]", "a path with a subscript by index"), - ("\"V\":a", "a path from a quoted column"), - ("input.v:a", "a path from a qualified column"), - ("PARSE_JSON('{}'):a", "a path from a call"), - ("v:a::STRING", "a cast of a path"), - ("CASE WHEN n > 1 THEN 1 END", "CASE"), - ("IFF(n > 1, 1, 0)", "IFF"), - ("(SELECT 1)", "a scalar subquery"), - ("EXISTS (SELECT 1)", "EXISTS"), - ("n IN (1, 2)", "IN"), - ("n BETWEEN 1 AND 2", "BETWEEN"), - ("g LIKE 'a%'", "LIKE"), - ("n IS NULL", "IS NULL"), - ("n IS NOT DISTINCT FROM 1", "IS NOT DISTINCT FROM"), - ("NOT TRUE", "NOT"), - ("TRUE AND FALSE", "AND"), - ("ROW_NUMBER() OVER (ORDER BY n)", "a window function"), - ("UPPER(LOWER(g))", "a nested call"), - ("(n + 1)", "a parenthesised expression"), - ("-n", "a negated column"), - ("n % 2", "modulo"), - ("n::INT::STRING", "a chained cast"), - ("CAST(n AS INT)", "a cast spelled INT"), - ("ROUND(n, 1)", "ROUND, which no receipt names"), - ("CURRENT_DATE()", "CURRENT_DATE(), which no receipt names"), - ("DATEADD(day, 1, t)", "DATEADD"), - ("TRY_CAST(g AS INTEGER)", "TRY_CAST"), - ("1 + n", "a literal before a column"), - ("n\t+ 1", "a tab"), - ("n + 1", "a double space"), - ("COUNT (*)", "a space before the parenthesis"), - ("n\r\n+ 1", "a CRLF line break"), - (".5", "a decimal without a leading digit"), - ("007", "an integer with leading zeros"), - ("1.0e-3", "an exponent with a fraction and a sign"), - ("1E+3", "an exponent with a plus sign"), - ("- 2", "a spaced negative"), - ("-(2)", "a negated parenthesised number"), - ("'a\tb'", "text with a tab"), - ("''", "empty text"), - ("\"lc\" + 1", "a quoted identifier that is not upper case"), - ]; - for (item, description) in shapes { - let sql = format!("SELECT {item} FROM input"); - assert_eq!( - run_convert( - &sql, - &["--schema", UNALIASED_SCHEMA], - "has not verified the name Snowflake gives it" - ), - convert_outcome(1, false), - "{description} refuses with SQ0009" - ); - } -} - #[test] fn given_quoted_names_when_converted_and_run_on_duckdb_then_output_names_follow_the_session() { let test_cases = [ diff --git a/crates/sqlquack/tests/cli/test_output_names.rs b/crates/sqlquack/tests/cli/test_output_names.rs new file mode 100644 index 000000000..766fbb11c --- /dev/null +++ b/crates/sqlquack/tests/cli/test_output_names.rs @@ -0,0 +1,369 @@ +use crate::helpers::{convert_outcome, converted_column_names, run_convert}; +use crate::test_types::{ColumnNameTestCase, ConvertTestCase}; + +const UNALIASED_SCHEMA: &str = "tests/cli/fixtures/unaliased_items.json"; +const UNVERIFIED_NAME: &str = "has not verified the name Snowflake gives it"; + +#[test] +fn given_unaliased_items_when_converted_and_run_on_duckdb_then_they_take_snowflakes_names() { + let test_cases = [ + ColumnNameTestCase { + description: "aggregates and a literal, named as recorded", + sql: "SELECT COUNT(*), 'MiXeD text', Count( DISTINCT n ) FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec![ + "COUNT(*)".to_owned(), + "'MIXED TEXT'".to_owned(), + "COUNT( DISTINCT N )".to_owned(), + ], + }, + ColumnNameTestCase { + description: "a column-and-number expression across a line break keeps the break", + sql: "SELECT n\n + 1 FROM (SELECT 1 AS n) AS input", + flags: &[], + expected_names: vec!["N\n + 1".to_owned()], + }, + ColumnNameTestCase { + description: "numbers keep their written form, upper-cased", + sql: "SELECT 1.50, 1e3, -2, NULL, false", + flags: &[], + expected_names: vec![ + "1.50".to_owned(), + "1E3".to_owned(), + "-2".to_owned(), + "NULL".to_owned(), + "FALSE".to_owned(), + ], + }, + ]; + for test_case in test_cases { + assert_eq!( + converted_column_names(test_case.sql, test_case.flags), + test_case.expected_names, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_unaliased_items_no_receipt_names_when_converting_then_each_refuses_with_sq0009() { + let test_cases = [ + ConvertTestCase { + description: "a semi-structured path, which Snowflake names after the whole path refuses with SQ0009", + sql: "SELECT v:a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a nested path refuses with SQ0009", + sql: "SELECT v:a.b FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a subscript by key refuses with SQ0009", + sql: "SELECT v['a'] FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path with a subscript by index refuses with SQ0009", + sql: "SELECT v:a[0] FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a quoted column refuses with SQ0009", + sql: "SELECT \"V\":a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a qualified column refuses with SQ0009", + sql: "SELECT input.v:a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a path from a call refuses with SQ0009", + sql: "SELECT PARSE_JSON('{}'):a FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a cast of a path refuses with SQ0009", + sql: "SELECT v:a::STRING FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "CASE refuses with SQ0009", + sql: "SELECT CASE WHEN n > 1 THEN 1 END FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IFF refuses with SQ0009", + sql: "SELECT IFF(n > 1, 1, 0) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a scalar subquery refuses with SQ0009", + sql: "SELECT (SELECT 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "EXISTS refuses with SQ0009", + sql: "SELECT EXISTS (SELECT 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IN refuses with SQ0009", + sql: "SELECT n IN (1, 2) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "BETWEEN refuses with SQ0009", + sql: "SELECT n BETWEEN 1 AND 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "LIKE refuses with SQ0009", + sql: "SELECT g LIKE 'a%' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IS NULL refuses with SQ0009", + sql: "SELECT n IS NULL FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "IS NOT DISTINCT FROM refuses with SQ0009", + sql: "SELECT n IS NOT DISTINCT FROM 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "NOT refuses with SQ0009", + sql: "SELECT NOT TRUE FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "AND refuses with SQ0009", + sql: "SELECT TRUE AND FALSE FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a window function refuses with SQ0009", + sql: "SELECT ROW_NUMBER() OVER (ORDER BY n) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a nested call refuses with SQ0009", + sql: "SELECT UPPER(LOWER(g)) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a parenthesised expression refuses with SQ0009", + sql: "SELECT (n + 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a negated column refuses with SQ0009", + sql: "SELECT -n FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "modulo refuses with SQ0009", + sql: "SELECT n % 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a chained cast refuses with SQ0009", + sql: "SELECT n::INT::STRING FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a cast spelled INT refuses with SQ0009", + sql: "SELECT CAST(n AS INT) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "ROUND, which no receipt names yet refuses with SQ0009", + sql: "SELECT ROUND(n, 1) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "CURRENT_DATE(), which no receipt names yet refuses with SQ0009", + sql: "SELECT CURRENT_DATE() FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "DATEADD refuses with SQ0009", + sql: "SELECT DATEADD(day, 1, t) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "TRY_CAST refuses with SQ0009", + sql: "SELECT TRY_CAST(g AS INTEGER) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a literal before a column refuses with SQ0009", + sql: "SELECT 1 + n FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a tab refuses with SQ0009", + sql: "SELECT n\t+ 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a double space refuses with SQ0009", + sql: "SELECT n + 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a space before the parenthesis refuses with SQ0009", + sql: "SELECT COUNT (*) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a CRLF line break refuses with SQ0009", + sql: "SELECT n\r\n+ 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a decimal without a leading digit refuses with SQ0009", + sql: "SELECT .5 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an integer with leading zeros refuses with SQ0009", + sql: "SELECT 007 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an exponent with a fraction and a sign refuses with SQ0009", + sql: "SELECT 1.0e-3 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "an exponent with a plus sign refuses with SQ0009", + sql: "SELECT 1E+3 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a spaced negative refuses with SQ0009", + sql: "SELECT - 2 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a negated parenthesised number refuses with SQ0009", + sql: "SELECT -(2) FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "text with a tab refuses with SQ0009", + sql: "SELECT 'a\tb' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "empty text refuses with SQ0009", + sql: "SELECT '' FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a quoted identifier that is not upper case refuses with SQ0009", + sql: "SELECT \"lc\" + 1 FROM input", + flags: &["--schema", UNALIASED_SCHEMA], + mention: UNVERIFIED_NAME, + expected_outcome: convert_outcome(1, false), + }, + ]; + for test_case in test_cases { + assert_eq!( + run_convert(test_case.sql, test_case.flags, test_case.mention), + test_case.expected_outcome, + "{}", + test_case.description + ); + } +} From 1aba664587f7ec699c01081f9c852ff1bb027613 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:14:39 +0000 Subject: [PATCH 8/9] chore(coverage): record the unaliased output-name statements in the results baseline --- coverage/translation/results-baseline.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index f86754a5e..61ea4140c 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -6713,6 +6713,11 @@ "relational/subqueries.toml#scalar_subquery_empty": "matching", "relational/subqueries.toml#select_distinct": "matching", "relational/subqueries.toml#where_filters": "matching", + "relational/unaliased_output_names.toml#unaliased_aggregates": "matching", + "relational/unaliased_output_names.toml#unaliased_expressions": "matching", + "relational/unaliased_output_names.toml#unaliased_group_count": "matching", + "relational/unaliased_output_names.toml#unaliased_literals": "matching", + "relational/unaliased_output_names.toml#unaliased_over_no_rows": "matching", "relational/union_by_name.toml#union_all_by_name_reordered": "matching", "relational/union_by_name.toml#union_by_name_case_insensitive": "matching", "relational/union_by_name.toml#union_by_name_chain": "matching", From 37e3767f7d241cb3cfae721a15cea0bf75451cd2 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:13:11 +0000 Subject: [PATCH 9/9] chore(coverage): record the chained WITH statement unaliased names now convert --- coverage/translation/results-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 85ca846eb..c891a50dd 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -11703,7 +11703,7 @@ "translation/query/with.select.syntax.toml#t001_recursive": "refused", "translation/query/with.select.syntax.toml#t002_hierarchy": "refused", "translation/query/with.select.syntax.toml#t003_null_stop": "refused", - "translation/query/with.select.toml#t001_chained": "refused", + "translation/query/with.select.toml#t001_chained": "matching", "translation/query/with.select.toml#t002_null_group": "matching", "typing/array_agg.toml#array_agg_empty": "shape_only", "typing/array_agg.toml#array_agg_ordered": "shape_only",