From e675d174d78de8a6abc1e251a1a32ac949ef3d5e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:19:02 +0000 Subject: [PATCH 01/18] cases: text cast to BINARY under the HEX input format CAST, :: and TRY_CAST of hex text in mixed case, empty text, NULL, odd-length and non-hex text, and BINARY(2) overflow, to record before a VARCHAR-to-BINARY cast rule is built. --- cases/translation/functions/cast.cast-as.toml | 59 +++++++++++++++++++ .../functions/cast.double-colon.toml | 12 ++++ cases/translation/functions/try-cast.toml | 14 +++++ 3 files changed, 85 insertions(+) diff --git a/cases/translation/functions/cast.cast-as.toml b/cases/translation/functions/cast.cast-as.toml index b03f6cec5..3df55b364 100644 --- a/cases/translation/functions/cast.cast-as.toml +++ b/cases/translation/functions/cast.cast-as.toml @@ -60,3 +60,62 @@ rows = [ query = "SELECT CAST(x AS NUMBER) AS r FROM input ORDER BY ordinal" order = "ordered" tags = ["null"] + +[[case]] +id = "t006_text_to_binary" +description = "Hex text in upper, lower and mixed case cast to BINARY under BINARY_INPUT_FORMAT HEX. Catches: the text's UTF-8 bytes taken instead of its hex digits, or case-sensitive hex" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'ABCD'"], + ["'00ff10'"], + ["'4a4B'"], +] +query = "SELECT CAST(v AS BINARY) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t007_binary_empty_and_null" +description = "Empty text and NULL cast to BINARY. Catches: empty text read as NULL, or NULL as empty" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["''"], + ["NULL"], +] +query = "SELECT CAST(v AS BINARY) AS r, CAST(v AS BINARY) IS NULL AS n FROM input ORDER BY ordinal" +order = "ordered" +tags = ["boundary"] + +[[case]] +id = "t008_binary_odd_length" +description = "Hex text of odd length cast to BINARY, which the page says is an error. Catches: a padded or truncated byte" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'ABC'"], +] +query = "SELECT CAST(v AS BINARY) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["error"] + +[[case]] +id = "t009_binary_invalid_digit" +description = "Text with a non-hex character cast to BINARY. Catches: the character skipped or read as 0" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'0G'"], +] +query = "SELECT CAST(v AS BINARY) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["error"] + +[[case]] +id = "t010_binary_length" +description = "Hex text of two and three bytes cast to BINARY(2), with its length. Catches: the excess bytes silently truncated, or the declared length ignored" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'ABCD'"], + ["'ABCDEF'"], +] +query = "SELECT CAST(v AS BINARY(2)) AS r, LENGTH(CAST(v AS BINARY(2))) AS l FROM input ORDER BY ordinal" +order = "ordered" +tags = ["boundary"] diff --git a/cases/translation/functions/cast.double-colon.toml b/cases/translation/functions/cast.double-colon.toml index 89abddedb..ad88008cc 100644 --- a/cases/translation/functions/cast.double-colon.toml +++ b/cases/translation/functions/cast.double-colon.toml @@ -60,3 +60,15 @@ rows = [ query = "SELECT x::NUMBER AS r FROM input ORDER BY ordinal" order = "ordered" tags = ["null"] + +[[case]] +id = "t006_text_to_binary" +description = "Hex text cast to BINARY with ::, beside NULL. Catches: :: read differently from CAST" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'00ff10'"], + ["NULL"], +] +query = "SELECT v::BINARY AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] diff --git a/cases/translation/functions/try-cast.toml b/cases/translation/functions/try-cast.toml index 439d6c2ca..5ce60779a 100644 --- a/cases/translation/functions/try-cast.toml +++ b/cases/translation/functions/try-cast.toml @@ -58,3 +58,17 @@ rows = [ query = "SELECT TRY_CAST(v AS NUMBER) AS r FROM input ORDER BY ordinal" order = "ordered" tags = ["null"] + +[[case]] +id = "t006_text_to_binary" +description = "TRY_CAST of valid hex, odd-length hex, a non-hex character and NULL to BINARY. Catches: an error instead of NULL, or a partial value" +columns = [{ name = "v", type = "VARCHAR" }] +rows = [ + ["'4a4B'"], + ["'ABC'"], + ["'0G'"], + ["NULL"], +] +query = "SELECT TRY_CAST(v AS BINARY) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] From c061da6f05dd3fa886eea88ae898e44700e1f0b2 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:35:43 +0000 Subject: [PATCH 02/18] evidence: record text casts to BINARY under the HEX input format Snowflake results for cast.cast-as t006-t010, cast.double-colon t006 and try-cast t006 at e675d174. --- ...3b4fcd7f67ce47905b68c3b204a697c7ee1d.arrow | Bin 0 -> 1034 bytes ...73b4fcd7f67ce47905b68c3b204a697c7ee1d.json | 61 +++++++++++++++ ...706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349.json | 55 ++++++++++++++ ...afd44e021da137f59654133025490abd1095.arrow | Bin 0 -> 1674 bytes ...bafd44e021da137f59654133025490abd1095.json | 70 ++++++++++++++++++ ...3fa572e7f0ff2e19669a610e9d3fde05ae600.json | 55 ++++++++++++++ ...1a12deb1982bafa76f75b3217a69a02fd21c.arrow | Bin 0 -> 1026 bytes ...e1a12deb1982bafa76f75b3217a69a02fd21c.json | 61 +++++++++++++++ ...836e261af67d7603cbfb20990e514292f053.arrow | Bin 0 -> 1042 bytes ...8836e261af67d7603cbfb20990e514292f053.json | 61 +++++++++++++++ ...882ef4e54cb9644673bb1d77abaac894a85da.json | 55 ++++++++++++++ 11 files changed, 418 insertions(+) create mode 100644 evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.arrow create mode 100644 evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.json create mode 100644 evidence/results/0c0d907c6f3f98326a3f1d4827f706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349.json create mode 100644 evidence/results/43e2c93942ca2e0a63cc45bde8abafd44e021da137f59654133025490abd1095.arrow create mode 100644 evidence/results/43e2c93942ca2e0a63cc45bde8abafd44e021da137f59654133025490abd1095.json create mode 100644 evidence/results/6094d64591d9ae9e4dbaaa41d443fa572e7f0ff2e19669a610e9d3fde05ae600.json create mode 100644 evidence/results/9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c.arrow create mode 100644 evidence/results/9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c.json create mode 100644 evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.arrow create mode 100644 evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.json create mode 100644 evidence/results/fa9b733c08d8c9c1091747472a6882ef4e54cb9644673bb1d77abaac894a85da.json diff --git a/evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.arrow b/evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.arrow new file mode 100644 index 0000000000000000000000000000000000000000..98db8daa45acb19e6b0e7e867a6156c84600e486 GIT binary patch literal 1034 zcmeHHJxjw-6uq&pG1d%0ia2&~aByf*YDVK$C<>tN)S_iF9#t^TK)W-uKS^c=v`xaeQ;z5!pbV9g#pN4e`a5x_Hnaj)E<)Jgll( zaSZDS5IMl^!DCnR0%_ue;;1Ip6A9VJuC{n*OgM!Y&OOOt|m zKY!fsz&;C2ZIfx1=CgcU;><%Q*eTY<9 zABr!B)g*k~Td67C^OnBl(tvGpaaMhRePdl#YF5F{UQ$>Wg>$8tIf`s^Z5-=oclwa~ y&DYa1_4AH3_;`h@I}ucQlyEr50N=`3%PQa-bV^IFX8Hf#{{DMgq5qNo2YdmzEqojR literal 0 HcmV?d00001 diff --git a/evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.json b/evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.json new file mode 100644 index 000000000..420ca82a0 --- /dev/null +++ b/evidence/results/097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "34c78da36d0738055bab36902880600800b6383f6a7734c5f72d33887271f5a9", + "case_hash": "097322dd0ccf7f6ec7763d314e173b4fcd7f67ce47905b68c3b204a697c7ee1d", + "columns": [ + { + "internal_size": 67108864, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 11, + "type_name": "BINARY" + } + ], + "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": "93cb3f6e3ac843b4b128f4c88564893a950569860963e6133718bb88162118f2", + "recorded_at": "2026-11-06T16:33:28.866701+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "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/0c0d907c6f3f98326a3f1d4827f706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349.json b/evidence/results/0c0d907c6f3f98326a3f1d4827f706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349.json new file mode 100644 index 000000000..eec602559 --- /dev/null +++ b/evidence/results/0c0d907c6f3f98326a3f1d4827f706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "0c0d907c6f3f98326a3f1d4827f706ec1fdc1ed4adc3ad7e8540f1e5c1ae8349", + "columns": [], + "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": { + "errno": 100115, + "message": "The following string is not a legal hex-encoded value: '0G'", + "sqlstate": "22000" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "12ee83ad16ca9807ed2114d78c90a43d8e3e288d5839ae7b9303a1f268ca540b", + "recorded_at": "2026-11-06T16:33:28.866701+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "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/43e2c93942ca2e0a63cc45bde8abafd44e021da137f59654133025490abd1095.arrow b/evidence/results/43e2c93942ca2e0a63cc45bde8abafd44e021da137f59654133025490abd1095.arrow new file mode 100644 index 0000000000000000000000000000000000000000..15becf90aa4b3c95d0d46b9771d373408dcfb9f4 GIT binary patch literal 1674 zcmeHIu}%U(5FPY55w9SI7)nYC3y21-P|wg2TJA-YuZoXw*(Cb=iUM_d6c83aU16YjQ+(B?nbZu&HH6vWU#b*YxTZ}qHRaV}(umPVvy*sts*=Fu$KgI4TVE6`Uq4d^YB=VD-K%)|58 z(>_4$SeqK_S7Q&-SR0MsjK)3VwrZ~Vuzy)>?K{i{64iT4%OY~j{saMMgw ommxOR3V^gqT3E~G>SXd+|@k-B54GKp<1e^0blY`?Dk~Hc30E?8CmV%8R z;4kubpO1Jm1hk|xP#|gVNdOXXFa(z>l-H3k-Go%Eze9n$bEU2 x(my@ZzL9Dj58c;Q0pIkDlkrk^0G^R`7aU+uI@)(+lK*%1_wVc(`iJ`Oe*p`0ehL5p literal 0 HcmV?d00001 diff --git a/evidence/results/9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c.json b/evidence/results/9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c.json new file mode 100644 index 000000000..933265010 --- /dev/null +++ b/evidence/results/9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "1ab9c72a0dfd58e5ce2e09444abad1811e3e8a7d0f7721c74bc34b8ff8dc4308", + "case_hash": "9df2179d7dd3418acb40a44323fe1a12deb1982bafa76f75b3217a69a02fd21c", + "columns": [ + { + "internal_size": 67108864, + "name": "R", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 11, + "type_name": "BINARY" + } + ], + "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": "b947f4982e75723b7b0f4140666d51dbf461ec8c1b944d38ac2854f810366a6f", + "recorded_at": "2026-11-06T16:33:28.866701+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 3, + "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/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.arrow b/evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.arrow new file mode 100644 index 0000000000000000000000000000000000000000..8117da5392c2b60de65e0b27fd3e5ae504d207d3 GIT binary patch literal 1042 zcmeHHJxjw-6uq&pG1d%0ia0trbZ}@I{E|r zDGvUVdd_=!SX`Xkyl~#T_vPHr_ed1S*Eb!J739eg353!RUtFn+2MyvV*aFMLs;U*o zu=W6v9qb-FHZ?DhCQc}hYGQ4HNKX^n_{FeY;1hTQo`E~y5KzD=>LH&x^Yno&;4v?g zqCX#J&>z5IesUPUd(vr#VfPrSuKRvI%X(Qkm<>f1zi`uGGO^tEAGzCzR|lqPQZVnw z+V>6EN1>^0Jjv30nvY7Hd6)zz#kx2H*2sR(&aa~QR%9D`F&g9usk>2Nkb}^xC3gBy zeA%sT!q>Bvn$k0Gna@}ouuU%BRS&Rpvy_@uuyd9a))_eNC0El2ch=$~i>oIRm>v+7mvjIyK4UJbz%S4#-~F*xR{Q_%{_@?uV*Zi-7kmLM CwR_0` literal 0 HcmV?d00001 diff --git a/evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.json b/evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.json new file mode 100644 index 000000000..d3ed2c5f8 --- /dev/null +++ b/evidence/results/9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "494e9c6a6ba7d2bd86b06408a657f046c0c17bf35a1d1895c088b381fc2b1f86", + "case_hash": "9ed793b297cf289d5c2b5c800d78836e261af67d7603cbfb20990e514292f053", + "columns": [ + { + "internal_size": 67108864, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 11, + "type_name": "BINARY" + } + ], + "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": "9a97bfab54acab1044b5b4d32a3fee5c432bb308d63fab5d877a8ad24451144e", + "recorded_at": "2026-11-06T16:33:28.866701+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/fa9b733c08d8c9c1091747472a6882ef4e54cb9644673bb1d77abaac894a85da.json b/evidence/results/fa9b733c08d8c9c1091747472a6882ef4e54cb9644673bb1d77abaac894a85da.json new file mode 100644 index 000000000..1bd3fd444 --- /dev/null +++ b/evidence/results/fa9b733c08d8c9c1091747472a6882ef4e54cb9644673bb1d77abaac894a85da.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "fa9b733c08d8c9c1091747472a6882ef4e54cb9644673bb1d77abaac894a85da", + "columns": [], + "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": { + "errno": 100145, + "message": "Binary value 'ABCDEF' is too long and would be truncated", + "sqlstate": "22000" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "345bcf5c2d1bf1065e4b8a6be29c5a19298aa1dbcc0cfd422b6560291f463737", + "recorded_at": "2026-11-06T16:33:28.866701+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "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 d92cf53d4bf6b85940c3460367cfd8ed4103a128 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:37:37 +0000 Subject: [PATCH 03/18] cases: tag the BINARY(2) overflow case as the error Snowflake raised --- cases/translation/functions/cast.cast-as.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cases/translation/functions/cast.cast-as.toml b/cases/translation/functions/cast.cast-as.toml index 3df55b364..2b539d6ac 100644 --- a/cases/translation/functions/cast.cast-as.toml +++ b/cases/translation/functions/cast.cast-as.toml @@ -110,7 +110,7 @@ tags = ["error"] [[case]] id = "t010_binary_length" -description = "Hex text of two and three bytes cast to BINARY(2), with its length. Catches: the excess bytes silently truncated, or the declared length ignored" +description = "Hex text of two and three bytes cast to BINARY(2), with its length; Snowflake raises for the three-byte value, failing the query. Catches: the excess bytes silently truncated, or the declared length ignored" columns = [{ name = "v", type = "VARCHAR" }] rows = [ ["'ABCD'"], @@ -118,4 +118,4 @@ rows = [ ] query = "SELECT CAST(v AS BINARY(2)) AS r, LENGTH(CAST(v AS BINARY(2))) AS l FROM input ORDER BY ordinal" order = "ordered" -tags = ["boundary"] +tags = ["error"] From 902d30147d14bb40c8f030a67c45e8a037276547 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:51:43 +0000 Subject: [PATCH 04/18] refactor: move the recorded time zone names into a time_zones domain conversion/constants.rs is at 2,000 lines on main, and the BINARY rules add to it. The 601-line table of time zone names Snowflake was recorded accepting now lives in time_zones/constants.rs. No behaviour change. --- .../expressions/temporal_functions.rs | 13 +- .../src/conversion/constants.rs | 601 ----------------- crates/sqlquack-rules/src/lib.rs | 1 + .../src/time_zones/constants.rs | 603 ++++++++++++++++++ .../sqlquack-rules/src/time_zones/main/mod.rs | 1 + .../src/time_zones/main/recorded_zone.rs | 8 + crates/sqlquack-rules/src/time_zones/mod.rs | 4 + 7 files changed, 624 insertions(+), 607 deletions(-) create mode 100644 crates/sqlquack-rules/src/time_zones/constants.rs create mode 100644 crates/sqlquack-rules/src/time_zones/main/mod.rs create mode 100644 crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs create mode 100644 crates/sqlquack-rules/src/time_zones/mod.rs diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs index cb25e49cd..2922f1e1c 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -62,12 +62,13 @@ use crate::conversion::constants::{ DATE_FORMAT_RULE, DATE_NAME, DATE_PART_RULE, DATE_PARTS, DAY_NAME_RULE, DECIMAL_EPOCH_RULE, FRACTIONAL_DATE_AMOUNT, FROM_PARTS, LAST_DAY_RULE, MAX_FRACTION_DIGITS_U8, MICROSECOND_DIGITS, MONTH_NAME_FIELDS, MONTH_NAME_FOLLOWERS, NULL_BRANCHES, NUMBER_TEXT_FORMAT, OFFSET_FREE_UNITS, - RECORDED_TIME_ZONES, SCALE_DIGITS, TEMPORAL_TEXT_FIELDS, TEMPORAL_TEXT_FORMAT, - TEMPORAL_TEXT_SEPARATORS, TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TIME_ARITHMETIC, - TIME_ZONE_CONVERSION, TIMESTAMP_FORMAT_RULE, TIMESTAMP_NTZ_NAME, TIMESTAMP_TIME_RULE, - TO_DATE_TEMPORAL, TO_TIMESTAMP_TEMPORAL, UTC_OFFSET_ARITHMETIC, UTC_ZONE, ZONE_COLUMNS, - ZONED_PARTS, + SCALE_DIGITS, TEMPORAL_TEXT_FIELDS, TEMPORAL_TEXT_FORMAT, TEMPORAL_TEXT_SEPARATORS, + TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TIME_ARITHMETIC, TIME_ZONE_CONVERSION, + TIMESTAMP_FORMAT_RULE, TIMESTAMP_NTZ_NAME, TIMESTAMP_TIME_RULE, TO_DATE_TEMPORAL, + TO_TIMESTAMP_TEMPORAL, UTC_OFFSET_ARITHMETIC, UTC_ZONE, ZONE_COLUMNS, ZONED_PARTS, }; +use crate::time_zones::constants::RECORDED_TIME_ZONES; +use crate::time_zones::main::recorded_zone::recorded_zone; use sqlquack_duckdb::raises::main::raised::raised; use sqlquack_duckdb::temporal::constants::{DUCKDB_CURRENT_DATE, DUCKDB_CURRENT_TIMESTAMP}; use sqlquack_policy::policy::constants::{ @@ -1110,7 +1111,7 @@ struct Zone { /// A literal zone name, or a VARCHAR expression naming one per row. fn zone_operand(node: &Value, construct: &Construct, site: Site<'_>) -> Result { if let Some(zone) = zone_literal(node, site) { - if !RECORDED_TIME_ZONES.contains(&zone.as_str()) { + if !recorded_zone(&zone) { return Err(outside_conditions( construct.clone(), format!( diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index c0a81f28c..c506203ab 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1369,607 +1369,6 @@ pub const FLATTEN_ORDERED_COLUMNS: &[&str] = &["PATH", "INDEX", "THIS"]; pub const USER_OVERRIDE_RULE: &str = "rule.user_override"; /// The prefix of the macro converted SQL calls a user override by. pub const OVERRIDE_MACRO_PREFIX: &str = "__sqlquack_override__"; -/// Time zone names recorded as accepted by Snowflake, which matches them exactly, case included. -pub(crate) const RECORDED_TIME_ZONES: &[&str] = &[ - "Africa/Abidjan", - "Africa/Accra", - "Africa/Addis_Ababa", - "Africa/Algiers", - "Africa/Asmara", - "Africa/Asmera", - "Africa/Bamako", - "Africa/Bangui", - "Africa/Banjul", - "Africa/Bissau", - "Africa/Blantyre", - "Africa/Brazzaville", - "Africa/Bujumbura", - "Africa/Cairo", - "Africa/Casablanca", - "Africa/Ceuta", - "Africa/Conakry", - "Africa/Dakar", - "Africa/Dar_es_Salaam", - "Africa/Djibouti", - "Africa/Douala", - "Africa/El_Aaiun", - "Africa/Freetown", - "Africa/Gaborone", - "Africa/Harare", - "Africa/Johannesburg", - "Africa/Juba", - "Africa/Kampala", - "Africa/Khartoum", - "Africa/Kigali", - "Africa/Kinshasa", - "Africa/Lagos", - "Africa/Libreville", - "Africa/Lome", - "Africa/Luanda", - "Africa/Lubumbashi", - "Africa/Lusaka", - "Africa/Malabo", - "Africa/Maputo", - "Africa/Maseru", - "Africa/Mbabane", - "Africa/Mogadishu", - "Africa/Monrovia", - "Africa/Nairobi", - "Africa/Ndjamena", - "Africa/Niamey", - "Africa/Nouakchott", - "Africa/Ouagadougou", - "Africa/Porto-Novo", - "Africa/Sao_Tome", - "Africa/Timbuktu", - "Africa/Tripoli", - "Africa/Tunis", - "Africa/Windhoek", - "America/Adak", - "America/Anchorage", - "America/Anguilla", - "America/Antigua", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Argentina/Catamarca", - "America/Argentina/ComodRivadavia", - "America/Argentina/Cordoba", - "America/Argentina/Jujuy", - "America/Argentina/La_Rioja", - "America/Argentina/Mendoza", - "America/Argentina/Rio_Gallegos", - "America/Argentina/Salta", - "America/Argentina/San_Juan", - "America/Argentina/San_Luis", - "America/Argentina/Tucuman", - "America/Argentina/Ushuaia", - "America/Aruba", - "America/Asuncion", - "America/Atikokan", - "America/Atka", - "America/Bahia", - "America/Bahia_Banderas", - "America/Barbados", - "America/Belem", - "America/Belize", - "America/Blanc-Sablon", - "America/Boa_Vista", - "America/Bogota", - "America/Boise", - "America/Buenos_Aires", - "America/Cambridge_Bay", - "America/Campo_Grande", - "America/Cancun", - "America/Caracas", - "America/Catamarca", - "America/Cayenne", - "America/Cayman", - "America/Chicago", - "America/Chihuahua", - "America/Ciudad_Juarez", - "America/Coral_Harbour", - "America/Cordoba", - "America/Costa_Rica", - "America/Coyhaique", - "America/Creston", - "America/Cuiaba", - "America/Curacao", - "America/Danmarkshavn", - "America/Dawson", - "America/Dawson_Creek", - "America/Denver", - "America/Detroit", - "America/Dominica", - "America/Edmonton", - "America/Eirunepe", - "America/El_Salvador", - "America/Ensenada", - "America/Fort_Nelson", - "America/Fort_Wayne", - "America/Fortaleza", - "America/Glace_Bay", - "America/Godthab", - "America/Goose_Bay", - "America/Grand_Turk", - "America/Grenada", - "America/Guadeloupe", - "America/Guatemala", - "America/Guayaquil", - "America/Guyana", - "America/Halifax", - "America/Havana", - "America/Hermosillo", - "America/Indiana/Indianapolis", - "America/Indiana/Knox", - "America/Indiana/Marengo", - "America/Indiana/Petersburg", - "America/Indiana/Tell_City", - "America/Indiana/Vevay", - "America/Indiana/Vincennes", - "America/Indiana/Winamac", - "America/Indianapolis", - "America/Inuvik", - "America/Iqaluit", - "America/Jamaica", - "America/Jujuy", - "America/Juneau", - "America/Kentucky/Louisville", - "America/Kentucky/Monticello", - "America/Knox_IN", - "America/Kralendijk", - "America/La_Paz", - "America/Lima", - "America/Los_Angeles", - "America/Louisville", - "America/Lower_Princes", - "America/Maceio", - "America/Managua", - "America/Manaus", - "America/Marigot", - "America/Martinique", - "America/Matamoros", - "America/Mazatlan", - "America/Mendoza", - "America/Menominee", - "America/Merida", - "America/Metlakatla", - "America/Mexico_City", - "America/Miquelon", - "America/Moncton", - "America/Monterrey", - "America/Montevideo", - "America/Montreal", - "America/Montserrat", - "America/Nassau", - "America/New_York", - "America/Nipigon", - "America/Nome", - "America/Noronha", - "America/North_Dakota/Beulah", - "America/North_Dakota/Center", - "America/North_Dakota/New_Salem", - "America/Nuuk", - "America/Ojinaga", - "America/Panama", - "America/Pangnirtung", - "America/Paramaribo", - "America/Phoenix", - "America/Port-au-Prince", - "America/Port_of_Spain", - "America/Porto_Acre", - "America/Porto_Velho", - "America/Puerto_Rico", - "America/Punta_Arenas", - "America/Rainy_River", - "America/Rankin_Inlet", - "America/Recife", - "America/Regina", - "America/Resolute", - "America/Rio_Branco", - "America/Rosario", - "America/Santa_Isabel", - "America/Santarem", - "America/Santiago", - "America/Santo_Domingo", - "America/Sao_Paulo", - "America/Scoresbysund", - "America/Shiprock", - "America/Sitka", - "America/St_Barthelemy", - "America/St_Johns", - "America/St_Kitts", - "America/St_Lucia", - "America/St_Thomas", - "America/St_Vincent", - "America/Swift_Current", - "America/Tegucigalpa", - "America/Thule", - "America/Thunder_Bay", - "America/Tijuana", - "America/Toronto", - "America/Tortola", - "America/Vancouver", - "America/Virgin", - "America/Whitehorse", - "America/Winnipeg", - "America/Yakutat", - "America/Yellowknife", - "Antarctica/Casey", - "Antarctica/Davis", - "Antarctica/DumontDUrville", - "Antarctica/Macquarie", - "Antarctica/Mawson", - "Antarctica/McMurdo", - "Antarctica/Palmer", - "Antarctica/Rothera", - "Antarctica/South_Pole", - "Antarctica/Syowa", - "Antarctica/Troll", - "Antarctica/Vostok", - "Arctic/Longyearbyen", - "Asia/Aden", - "Asia/Almaty", - "Asia/Amman", - "Asia/Anadyr", - "Asia/Aqtau", - "Asia/Aqtobe", - "Asia/Ashgabat", - "Asia/Ashkhabad", - "Asia/Atyrau", - "Asia/Baghdad", - "Asia/Bahrain", - "Asia/Baku", - "Asia/Bangkok", - "Asia/Barnaul", - "Asia/Beirut", - "Asia/Bishkek", - "Asia/Brunei", - "Asia/Calcutta", - "Asia/Chita", - "Asia/Choibalsan", - "Asia/Chongqing", - "Asia/Chungking", - "Asia/Colombo", - "Asia/Dacca", - "Asia/Damascus", - "Asia/Dhaka", - "Asia/Dili", - "Asia/Dubai", - "Asia/Dushanbe", - "Asia/Famagusta", - "Asia/Gaza", - "Asia/Harbin", - "Asia/Hebron", - "Asia/Ho_Chi_Minh", - "Asia/Hong_Kong", - "Asia/Hovd", - "Asia/Irkutsk", - "Asia/Istanbul", - "Asia/Jakarta", - "Asia/Jayapura", - "Asia/Jerusalem", - "Asia/Kabul", - "Asia/Kamchatka", - "Asia/Karachi", - "Asia/Kashgar", - "Asia/Kathmandu", - "Asia/Katmandu", - "Asia/Khandyga", - "Asia/Kolkata", - "Asia/Krasnoyarsk", - "Asia/Kuala_Lumpur", - "Asia/Kuching", - "Asia/Kuwait", - "Asia/Macao", - "Asia/Macau", - "Asia/Magadan", - "Asia/Makassar", - "Asia/Manila", - "Asia/Muscat", - "Asia/Nicosia", - "Asia/Novokuznetsk", - "Asia/Novosibirsk", - "Asia/Omsk", - "Asia/Oral", - "Asia/Phnom_Penh", - "Asia/Pontianak", - "Asia/Pyongyang", - "Asia/Qatar", - "Asia/Qostanay", - "Asia/Qyzylorda", - "Asia/Rangoon", - "Asia/Riyadh", - "Asia/Saigon", - "Asia/Sakhalin", - "Asia/Samarkand", - "Asia/Seoul", - "Asia/Shanghai", - "Asia/Singapore", - "Asia/Srednekolymsk", - "Asia/Taipei", - "Asia/Tashkent", - "Asia/Tbilisi", - "Asia/Tehran", - "Asia/Tel_Aviv", - "Asia/Thimbu", - "Asia/Thimphu", - "Asia/Tokyo", - "Asia/Tomsk", - "Asia/Ujung_Pandang", - "Asia/Ulaanbaatar", - "Asia/Ulan_Bator", - "Asia/Urumqi", - "Asia/Ust-Nera", - "Asia/Vientiane", - "Asia/Vladivostok", - "Asia/Yakutsk", - "Asia/Yangon", - "Asia/Yekaterinburg", - "Asia/Yerevan", - "Atlantic/Azores", - "Atlantic/Bermuda", - "Atlantic/Canary", - "Atlantic/Cape_Verde", - "Atlantic/Faeroe", - "Atlantic/Faroe", - "Atlantic/Jan_Mayen", - "Atlantic/Madeira", - "Atlantic/Reykjavik", - "Atlantic/South_Georgia", - "Atlantic/St_Helena", - "Atlantic/Stanley", - "Australia/ACT", - "Australia/Adelaide", - "Australia/Brisbane", - "Australia/Broken_Hill", - "Australia/Canberra", - "Australia/Currie", - "Australia/Darwin", - "Australia/Eucla", - "Australia/Hobart", - "Australia/LHI", - "Australia/Lindeman", - "Australia/Lord_Howe", - "Australia/Melbourne", - "Australia/NSW", - "Australia/North", - "Australia/Perth", - "Australia/Queensland", - "Australia/South", - "Australia/Sydney", - "Australia/Tasmania", - "Australia/Victoria", - "Australia/West", - "Australia/Yancowinna", - "Brazil/Acre", - "Brazil/DeNoronha", - "Brazil/East", - "Brazil/West", - "CET", - "CST6CDT", - "Canada/Atlantic", - "Canada/Central", - "Canada/Eastern", - "Canada/Mountain", - "Canada/Newfoundland", - "Canada/Pacific", - "Canada/Saskatchewan", - "Canada/Yukon", - "Chile/Continental", - "Chile/EasterIsland", - "Cuba", - "EET", - "EST", - "EST5EDT", - "Egypt", - "Eire", - "Etc/GMT", - "Etc/GMT+0", - "Etc/GMT+1", - "Etc/GMT+10", - "Etc/GMT+11", - "Etc/GMT+12", - "Etc/GMT+2", - "Etc/GMT+3", - "Etc/GMT+4", - "Etc/GMT+5", - "Etc/GMT+6", - "Etc/GMT+7", - "Etc/GMT+8", - "Etc/GMT+9", - "Etc/GMT-0", - "Etc/GMT-1", - "Etc/GMT-10", - "Etc/GMT-11", - "Etc/GMT-12", - "Etc/GMT-13", - "Etc/GMT-14", - "Etc/GMT-2", - "Etc/GMT-3", - "Etc/GMT-4", - "Etc/GMT-5", - "Etc/GMT-6", - "Etc/GMT-7", - "Etc/GMT-8", - "Etc/GMT-9", - "Etc/GMT0", - "Etc/Greenwich", - "Etc/UCT", - "Etc/UTC", - "Etc/Universal", - "Etc/Zulu", - "Europe/Amsterdam", - "Europe/Andorra", - "Europe/Astrakhan", - "Europe/Athens", - "Europe/Belfast", - "Europe/Belgrade", - "Europe/Berlin", - "Europe/Bratislava", - "Europe/Brussels", - "Europe/Bucharest", - "Europe/Budapest", - "Europe/Busingen", - "Europe/Chisinau", - "Europe/Copenhagen", - "Europe/Dublin", - "Europe/Gibraltar", - "Europe/Guernsey", - "Europe/Helsinki", - "Europe/Isle_of_Man", - "Europe/Istanbul", - "Europe/Jersey", - "Europe/Kaliningrad", - "Europe/Kiev", - "Europe/Kirov", - "Europe/Kyiv", - "Europe/Lisbon", - "Europe/Ljubljana", - "Europe/London", - "Europe/Luxembourg", - "Europe/Madrid", - "Europe/Malta", - "Europe/Mariehamn", - "Europe/Minsk", - "Europe/Monaco", - "Europe/Moscow", - "Europe/Nicosia", - "Europe/Oslo", - "Europe/Paris", - "Europe/Podgorica", - "Europe/Prague", - "Europe/Riga", - "Europe/Rome", - "Europe/Samara", - "Europe/San_Marino", - "Europe/Sarajevo", - "Europe/Saratov", - "Europe/Simferopol", - "Europe/Skopje", - "Europe/Sofia", - "Europe/Stockholm", - "Europe/Tallinn", - "Europe/Tirane", - "Europe/Tiraspol", - "Europe/Ulyanovsk", - "Europe/Uzhgorod", - "Europe/Vaduz", - "Europe/Vatican", - "Europe/Vienna", - "Europe/Vilnius", - "Europe/Volgograd", - "Europe/Warsaw", - "Europe/Zagreb", - "Europe/Zaporozhye", - "Europe/Zurich", - "Factory", - "GB", - "GB-Eire", - "GMT", - "GMT+0", - "GMT-0", - "GMT0", - "Greenwich", - "HST", - "Hongkong", - "Iceland", - "Indian/Antananarivo", - "Indian/Chagos", - "Indian/Christmas", - "Indian/Cocos", - "Indian/Comoro", - "Indian/Kerguelen", - "Indian/Mahe", - "Indian/Maldives", - "Indian/Mauritius", - "Indian/Mayotte", - "Indian/Reunion", - "Iran", - "Israel", - "Jamaica", - "Japan", - "Kwajalein", - "Libya", - "MET", - "MST", - "MST7MDT", - "Mexico/BajaNorte", - "Mexico/BajaSur", - "Mexico/General", - "NZ", - "NZ-CHAT", - "Navajo", - "PRC", - "PST8PDT", - "Pacific/Apia", - "Pacific/Auckland", - "Pacific/Bougainville", - "Pacific/Chatham", - "Pacific/Chuuk", - "Pacific/Easter", - "Pacific/Efate", - "Pacific/Enderbury", - "Pacific/Fakaofo", - "Pacific/Fiji", - "Pacific/Funafuti", - "Pacific/Galapagos", - "Pacific/Gambier", - "Pacific/Guadalcanal", - "Pacific/Guam", - "Pacific/Honolulu", - "Pacific/Johnston", - "Pacific/Kanton", - "Pacific/Kiritimati", - "Pacific/Kosrae", - "Pacific/Kwajalein", - "Pacific/Majuro", - "Pacific/Marquesas", - "Pacific/Midway", - "Pacific/Nauru", - "Pacific/Niue", - "Pacific/Norfolk", - "Pacific/Noumea", - "Pacific/Pago_Pago", - "Pacific/Palau", - "Pacific/Pitcairn", - "Pacific/Pohnpei", - "Pacific/Ponape", - "Pacific/Port_Moresby", - "Pacific/Rarotonga", - "Pacific/Saipan", - "Pacific/Samoa", - "Pacific/Tahiti", - "Pacific/Tarawa", - "Pacific/Tongatapu", - "Pacific/Truk", - "Pacific/Wake", - "Pacific/Wallis", - "Pacific/Yap", - "Poland", - "Portugal", - "ROC", - "ROK", - "Singapore", - "Turkey", - "UCT", - "US/Alaska", - "US/Aleutian", - "US/Arizona", - "US/Central", - "US/East-Indiana", - "US/Eastern", - "US/Hawaii", - "US/Indiana-Starke", - "US/Michigan", - "US/Mountain", - "US/Pacific", - "US/Samoa", - "UTC", - "Universal", - "W-SU", - "WET", - "Zulu", -]; /// The keyword of an inner join, whose USING keys all take the earlier side's type, as recorded. pub(crate) const INNER_JOIN_KEYWORD: &str = "JOIN"; /// The sequence-number column of FLATTEN and SPLIT_TO_TABLE, which no rule converts. diff --git a/crates/sqlquack-rules/src/lib.rs b/crates/sqlquack-rules/src/lib.rs index 4c23bf847..d29c3e0e3 100644 --- a/crates/sqlquack-rules/src/lib.rs +++ b/crates/sqlquack-rules/src/lib.rs @@ -5,3 +5,4 @@ pub mod catalogue; pub mod conversion; pub mod session; +pub mod time_zones; diff --git a/crates/sqlquack-rules/src/time_zones/constants.rs b/crates/sqlquack-rules/src/time_zones/constants.rs new file mode 100644 index 000000000..4c7d503a2 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/constants.rs @@ -0,0 +1,603 @@ +//! The time zone names Snowflake was recorded accepting. + +/// Time zone names recorded as accepted by Snowflake, which matches them exactly, case included. +pub const RECORDED_TIME_ZONES: &[&str] = &[ + "Africa/Abidjan", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Asmara", + "Africa/Asmera", + "Africa/Bamako", + "Africa/Bangui", + "Africa/Banjul", + "Africa/Bissau", + "Africa/Blantyre", + "Africa/Brazzaville", + "Africa/Bujumbura", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Conakry", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Djibouti", + "Africa/Douala", + "Africa/El_Aaiun", + "Africa/Freetown", + "Africa/Gaborone", + "Africa/Harare", + "Africa/Johannesburg", + "Africa/Juba", + "Africa/Kampala", + "Africa/Khartoum", + "Africa/Kigali", + "Africa/Kinshasa", + "Africa/Lagos", + "Africa/Libreville", + "Africa/Lome", + "Africa/Luanda", + "Africa/Lubumbashi", + "Africa/Lusaka", + "Africa/Malabo", + "Africa/Maputo", + "Africa/Maseru", + "Africa/Mbabane", + "Africa/Mogadishu", + "Africa/Monrovia", + "Africa/Nairobi", + "Africa/Ndjamena", + "Africa/Niamey", + "Africa/Nouakchott", + "Africa/Ouagadougou", + "Africa/Porto-Novo", + "Africa/Sao_Tome", + "Africa/Timbuktu", + "Africa/Tripoli", + "Africa/Tunis", + "Africa/Windhoek", + "America/Adak", + "America/Anchorage", + "America/Anguilla", + "America/Antigua", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Argentina/Catamarca", + "America/Argentina/ComodRivadavia", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Mendoza", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Aruba", + "America/Asuncion", + "America/Atikokan", + "America/Atka", + "America/Bahia", + "America/Bahia_Banderas", + "America/Barbados", + "America/Belem", + "America/Belize", + "America/Blanc-Sablon", + "America/Boa_Vista", + "America/Bogota", + "America/Boise", + "America/Buenos_Aires", + "America/Cambridge_Bay", + "America/Campo_Grande", + "America/Cancun", + "America/Caracas", + "America/Catamarca", + "America/Cayenne", + "America/Cayman", + "America/Chicago", + "America/Chihuahua", + "America/Ciudad_Juarez", + "America/Coral_Harbour", + "America/Cordoba", + "America/Costa_Rica", + "America/Coyhaique", + "America/Creston", + "America/Cuiaba", + "America/Curacao", + "America/Danmarkshavn", + "America/Dawson", + "America/Dawson_Creek", + "America/Denver", + "America/Detroit", + "America/Dominica", + "America/Edmonton", + "America/Eirunepe", + "America/El_Salvador", + "America/Ensenada", + "America/Fort_Nelson", + "America/Fort_Wayne", + "America/Fortaleza", + "America/Glace_Bay", + "America/Godthab", + "America/Goose_Bay", + "America/Grand_Turk", + "America/Grenada", + "America/Guadeloupe", + "America/Guatemala", + "America/Guayaquil", + "America/Guyana", + "America/Halifax", + "America/Havana", + "America/Hermosillo", + "America/Indiana/Indianapolis", + "America/Indiana/Knox", + "America/Indiana/Marengo", + "America/Indiana/Petersburg", + "America/Indiana/Tell_City", + "America/Indiana/Vevay", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Indianapolis", + "America/Inuvik", + "America/Iqaluit", + "America/Jamaica", + "America/Jujuy", + "America/Juneau", + "America/Kentucky/Louisville", + "America/Kentucky/Monticello", + "America/Knox_IN", + "America/Kralendijk", + "America/La_Paz", + "America/Lima", + "America/Los_Angeles", + "America/Louisville", + "America/Lower_Princes", + "America/Maceio", + "America/Managua", + "America/Manaus", + "America/Marigot", + "America/Martinique", + "America/Matamoros", + "America/Mazatlan", + "America/Mendoza", + "America/Menominee", + "America/Merida", + "America/Metlakatla", + "America/Mexico_City", + "America/Miquelon", + "America/Moncton", + "America/Monterrey", + "America/Montevideo", + "America/Montreal", + "America/Montserrat", + "America/Nassau", + "America/New_York", + "America/Nipigon", + "America/Nome", + "America/Noronha", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "America/Nuuk", + "America/Ojinaga", + "America/Panama", + "America/Pangnirtung", + "America/Paramaribo", + "America/Phoenix", + "America/Port-au-Prince", + "America/Port_of_Spain", + "America/Porto_Acre", + "America/Porto_Velho", + "America/Puerto_Rico", + "America/Punta_Arenas", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Recife", + "America/Regina", + "America/Resolute", + "America/Rio_Branco", + "America/Rosario", + "America/Santa_Isabel", + "America/Santarem", + "America/Santiago", + "America/Santo_Domingo", + "America/Sao_Paulo", + "America/Scoresbysund", + "America/Shiprock", + "America/Sitka", + "America/St_Barthelemy", + "America/St_Johns", + "America/St_Kitts", + "America/St_Lucia", + "America/St_Thomas", + "America/St_Vincent", + "America/Swift_Current", + "America/Tegucigalpa", + "America/Thule", + "America/Thunder_Bay", + "America/Tijuana", + "America/Toronto", + "America/Tortola", + "America/Vancouver", + "America/Virgin", + "America/Whitehorse", + "America/Winnipeg", + "America/Yakutat", + "America/Yellowknife", + "Antarctica/Casey", + "Antarctica/Davis", + "Antarctica/DumontDUrville", + "Antarctica/Macquarie", + "Antarctica/Mawson", + "Antarctica/McMurdo", + "Antarctica/Palmer", + "Antarctica/Rothera", + "Antarctica/South_Pole", + "Antarctica/Syowa", + "Antarctica/Troll", + "Antarctica/Vostok", + "Arctic/Longyearbyen", + "Asia/Aden", + "Asia/Almaty", + "Asia/Amman", + "Asia/Anadyr", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Ashgabat", + "Asia/Ashkhabad", + "Asia/Atyrau", + "Asia/Baghdad", + "Asia/Bahrain", + "Asia/Baku", + "Asia/Bangkok", + "Asia/Barnaul", + "Asia/Beirut", + "Asia/Bishkek", + "Asia/Brunei", + "Asia/Calcutta", + "Asia/Chita", + "Asia/Choibalsan", + "Asia/Chongqing", + "Asia/Chungking", + "Asia/Colombo", + "Asia/Dacca", + "Asia/Damascus", + "Asia/Dhaka", + "Asia/Dili", + "Asia/Dubai", + "Asia/Dushanbe", + "Asia/Famagusta", + "Asia/Gaza", + "Asia/Harbin", + "Asia/Hebron", + "Asia/Ho_Chi_Minh", + "Asia/Hong_Kong", + "Asia/Hovd", + "Asia/Irkutsk", + "Asia/Istanbul", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Jerusalem", + "Asia/Kabul", + "Asia/Kamchatka", + "Asia/Karachi", + "Asia/Kashgar", + "Asia/Kathmandu", + "Asia/Katmandu", + "Asia/Khandyga", + "Asia/Kolkata", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macao", + "Asia/Macau", + "Asia/Magadan", + "Asia/Makassar", + "Asia/Manila", + "Asia/Muscat", + "Asia/Nicosia", + "Asia/Novokuznetsk", + "Asia/Novosibirsk", + "Asia/Omsk", + "Asia/Oral", + "Asia/Phnom_Penh", + "Asia/Pontianak", + "Asia/Pyongyang", + "Asia/Qatar", + "Asia/Qostanay", + "Asia/Qyzylorda", + "Asia/Rangoon", + "Asia/Riyadh", + "Asia/Saigon", + "Asia/Sakhalin", + "Asia/Samarkand", + "Asia/Seoul", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Srednekolymsk", + "Asia/Taipei", + "Asia/Tashkent", + "Asia/Tbilisi", + "Asia/Tehran", + "Asia/Tel_Aviv", + "Asia/Thimbu", + "Asia/Thimphu", + "Asia/Tokyo", + "Asia/Tomsk", + "Asia/Ujung_Pandang", + "Asia/Ulaanbaatar", + "Asia/Ulan_Bator", + "Asia/Urumqi", + "Asia/Ust-Nera", + "Asia/Vientiane", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Yangon", + "Asia/Yekaterinburg", + "Asia/Yerevan", + "Atlantic/Azores", + "Atlantic/Bermuda", + "Atlantic/Canary", + "Atlantic/Cape_Verde", + "Atlantic/Faeroe", + "Atlantic/Faroe", + "Atlantic/Jan_Mayen", + "Atlantic/Madeira", + "Atlantic/Reykjavik", + "Atlantic/South_Georgia", + "Atlantic/St_Helena", + "Atlantic/Stanley", + "Australia/ACT", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Broken_Hill", + "Australia/Canberra", + "Australia/Currie", + "Australia/Darwin", + "Australia/Eucla", + "Australia/Hobart", + "Australia/LHI", + "Australia/Lindeman", + "Australia/Lord_Howe", + "Australia/Melbourne", + "Australia/NSW", + "Australia/North", + "Australia/Perth", + "Australia/Queensland", + "Australia/South", + "Australia/Sydney", + "Australia/Tasmania", + "Australia/Victoria", + "Australia/West", + "Australia/Yancowinna", + "Brazil/Acre", + "Brazil/DeNoronha", + "Brazil/East", + "Brazil/West", + "CET", + "CST6CDT", + "Canada/Atlantic", + "Canada/Central", + "Canada/Eastern", + "Canada/Mountain", + "Canada/Newfoundland", + "Canada/Pacific", + "Canada/Saskatchewan", + "Canada/Yukon", + "Chile/Continental", + "Chile/EasterIsland", + "Cuba", + "EET", + "EST", + "EST5EDT", + "Egypt", + "Eire", + "Etc/GMT", + "Etc/GMT+0", + "Etc/GMT+1", + "Etc/GMT+10", + "Etc/GMT+11", + "Etc/GMT+12", + "Etc/GMT+2", + "Etc/GMT+3", + "Etc/GMT+4", + "Etc/GMT+5", + "Etc/GMT+6", + "Etc/GMT+7", + "Etc/GMT+8", + "Etc/GMT+9", + "Etc/GMT-0", + "Etc/GMT-1", + "Etc/GMT-10", + "Etc/GMT-11", + "Etc/GMT-12", + "Etc/GMT-13", + "Etc/GMT-14", + "Etc/GMT-2", + "Etc/GMT-3", + "Etc/GMT-4", + "Etc/GMT-5", + "Etc/GMT-6", + "Etc/GMT-7", + "Etc/GMT-8", + "Etc/GMT-9", + "Etc/GMT0", + "Etc/Greenwich", + "Etc/UCT", + "Etc/UTC", + "Etc/Universal", + "Etc/Zulu", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Astrakhan", + "Europe/Athens", + "Europe/Belfast", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Busingen", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Gibraltar", + "Europe/Guernsey", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Jersey", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Kirov", + "Europe/Kyiv", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Mariehamn", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Nicosia", + "Europe/Oslo", + "Europe/Paris", + "Europe/Podgorica", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Samara", + "Europe/San_Marino", + "Europe/Sarajevo", + "Europe/Saratov", + "Europe/Simferopol", + "Europe/Skopje", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Tallinn", + "Europe/Tirane", + "Europe/Tiraspol", + "Europe/Ulyanovsk", + "Europe/Uzhgorod", + "Europe/Vaduz", + "Europe/Vatican", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Volgograd", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zaporozhye", + "Europe/Zurich", + "Factory", + "GB", + "GB-Eire", + "GMT", + "GMT+0", + "GMT-0", + "GMT0", + "Greenwich", + "HST", + "Hongkong", + "Iceland", + "Indian/Antananarivo", + "Indian/Chagos", + "Indian/Christmas", + "Indian/Cocos", + "Indian/Comoro", + "Indian/Kerguelen", + "Indian/Mahe", + "Indian/Maldives", + "Indian/Mauritius", + "Indian/Mayotte", + "Indian/Reunion", + "Iran", + "Israel", + "Jamaica", + "Japan", + "Kwajalein", + "Libya", + "MET", + "MST", + "MST7MDT", + "Mexico/BajaNorte", + "Mexico/BajaSur", + "Mexico/General", + "NZ", + "NZ-CHAT", + "Navajo", + "PRC", + "PST8PDT", + "Pacific/Apia", + "Pacific/Auckland", + "Pacific/Bougainville", + "Pacific/Chatham", + "Pacific/Chuuk", + "Pacific/Easter", + "Pacific/Efate", + "Pacific/Enderbury", + "Pacific/Fakaofo", + "Pacific/Fiji", + "Pacific/Funafuti", + "Pacific/Galapagos", + "Pacific/Gambier", + "Pacific/Guadalcanal", + "Pacific/Guam", + "Pacific/Honolulu", + "Pacific/Johnston", + "Pacific/Kanton", + "Pacific/Kiritimati", + "Pacific/Kosrae", + "Pacific/Kwajalein", + "Pacific/Majuro", + "Pacific/Marquesas", + "Pacific/Midway", + "Pacific/Nauru", + "Pacific/Niue", + "Pacific/Norfolk", + "Pacific/Noumea", + "Pacific/Pago_Pago", + "Pacific/Palau", + "Pacific/Pitcairn", + "Pacific/Pohnpei", + "Pacific/Ponape", + "Pacific/Port_Moresby", + "Pacific/Rarotonga", + "Pacific/Saipan", + "Pacific/Samoa", + "Pacific/Tahiti", + "Pacific/Tarawa", + "Pacific/Tongatapu", + "Pacific/Truk", + "Pacific/Wake", + "Pacific/Wallis", + "Pacific/Yap", + "Poland", + "Portugal", + "ROC", + "ROK", + "Singapore", + "Turkey", + "UCT", + "US/Alaska", + "US/Aleutian", + "US/Arizona", + "US/Central", + "US/East-Indiana", + "US/Eastern", + "US/Hawaii", + "US/Indiana-Starke", + "US/Michigan", + "US/Mountain", + "US/Pacific", + "US/Samoa", + "UTC", + "Universal", + "W-SU", + "WET", + "Zulu", +]; diff --git a/crates/sqlquack-rules/src/time_zones/main/mod.rs b/crates/sqlquack-rules/src/time_zones/main/mod.rs new file mode 100644 index 000000000..f1814c444 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/main/mod.rs @@ -0,0 +1 @@ +pub mod recorded_zone; diff --git a/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs b/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs new file mode 100644 index 000000000..0bb1f3da6 --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/main/recorded_zone.rs @@ -0,0 +1,8 @@ +//! `recorded_zone`: whether Snowflake was recorded accepting a time zone name. + +use crate::time_zones::constants::RECORDED_TIME_ZONES; + +/// Whether Snowflake was recorded accepting `name`, which it matches exactly, case included. +pub fn recorded_zone(name: &str) -> bool { + RECORDED_TIME_ZONES.contains(&name) +} diff --git a/crates/sqlquack-rules/src/time_zones/mod.rs b/crates/sqlquack-rules/src/time_zones/mod.rs new file mode 100644 index 000000000..bf4bd7e3e --- /dev/null +++ b/crates/sqlquack-rules/src/time_zones/mod.rs @@ -0,0 +1,4 @@ +//! The time zone names Snowflake was recorded accepting. + +pub mod constants; +pub mod main; From cfc726820127ab873dccaedce8326bbba5f63199 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:01:04 +0000 Subject: [PATCH 05/18] BINARY type and the VARCHAR to BINARY cast from the recorded receipts SnowflakeType gains BINARY, held in DuckDB as BLOB. CAST, :: and TRY_CAST read text as hex under the pinned BINARY_INPUT_FORMAT HEX: digits of either case decode, empty text gives empty bytes and NULL stays NULL. Odd-length or non-hex text raises 100115, or is NULL under TRY_CAST. More bytes than BINARY(n) holds raise 100145 instead of being truncated; TRY_CAST to BINARY(n) is refused, since nothing records it. --- .../sqlquack-core/src/failures/constants.rs | 6 +++ .../src/blocks/_helpers/types_map.rs | 1 + crates/sqlquack-duckdb/src/text/constants.rs | 8 ++++ .../src/text/main/binary_length_check.rs | 19 ++++++++ .../src/text/main/hex_bytes.rs | 22 +++++++++ crates/sqlquack-duckdb/src/text/main/mod.rs | 2 + .../sqlquack-rules/src/catalogue/constants.rs | 47 ++++++++++++------- .../conversion/_helpers/expressions/casts.rs | 29 +++++++++--- .../src/conversion/_helpers/support/shapes.rs | 1 + .../src/conversion/constants.rs | 2 + .../lineage/_helpers/typing/snowflake_sets.rs | 2 +- .../src/typing/_helpers/targets.rs | 15 ++++++ .../src/typing/main/cast_target.rs | 5 +- .../sqlquack-snowflake/src/typing/models.rs | 5 +- 14 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 crates/sqlquack-duckdb/src/text/main/binary_length_check.rs create mode 100644 crates/sqlquack-duckdb/src/text/main/hex_bytes.rs diff --git a/crates/sqlquack-core/src/failures/constants.rs b/crates/sqlquack-core/src/failures/constants.rs index d990bb16b..6bb412435 100644 --- a/crates/sqlquack-core/src/failures/constants.rs +++ b/crates/sqlquack-core/src/failures/constants.rs @@ -78,6 +78,11 @@ pub const STRING_TOO_LONG: SnowflakeCode = SnowflakeCode { errno: 100_078, sqlstate: "22000", }; +/// Bytes longer than a BINARY(n) target, which Snowflake does not truncate. +pub const BINARY_TOO_LONG: SnowflakeCode = SnowflakeCode { + errno: 100_145, + sqlstate: "22000", +}; /// A function's array argument is not an array. pub const NOT_AN_ARRAY_ARGUMENT: SnowflakeCode = SnowflakeCode { @@ -167,6 +172,7 @@ pub const SNOWFLAKE_ERRORS: &[(SnowflakeCode, ErrorKind)] = &[ (JSON_NOT_PARSED, ErrorKind::JsonParse), (VARIANT_NOT_CAST, ErrorKind::VariantCast), (STRING_TOO_LONG, ErrorKind::StringTooLong), + (BINARY_TOO_LONG, ErrorKind::StringTooLong), (NOT_AN_ARRAY_ARGUMENT, ErrorKind::InvalidArgument), (OBJECT_KEY_EXISTS, ErrorKind::InvalidArgument), (INVALID_CHARACTER_CODE, ErrorKind::InvalidArgument), diff --git a/crates/sqlquack-duckdb/src/blocks/_helpers/types_map.rs b/crates/sqlquack-duckdb/src/blocks/_helpers/types_map.rs index e08ae20af..95b3975ed 100644 --- a/crates/sqlquack-duckdb/src/blocks/_helpers/types_map.rs +++ b/crates/sqlquack-duckdb/src/blocks/_helpers/types_map.rs @@ -11,6 +11,7 @@ pub(crate) fn duckdb_type(data_type: SnowflakeType, log: &ProvenanceLog) -> Resu SnowflakeType::Number { precision, scale } => Ok(format!("DECIMAL({precision},{scale})")), SnowflakeType::Float => Ok("DOUBLE".to_owned()), SnowflakeType::Varchar => Ok("VARCHAR".to_owned()), + SnowflakeType::Binary => Ok("BLOB".to_owned()), SnowflakeType::Boolean => Ok("BOOLEAN".to_owned()), SnowflakeType::Date => Ok("DATE".to_owned()), SnowflakeType::Time { precision } if precision <= NANOSECOND_DIGITS => { diff --git a/crates/sqlquack-duckdb/src/text/constants.rs b/crates/sqlquack-duckdb/src/text/constants.rs index be7e8feca..4a9232bac 100644 --- a/crates/sqlquack-duckdb/src/text/constants.rs +++ b/crates/sqlquack-duckdb/src/text/constants.rs @@ -21,6 +21,14 @@ pub const CODE_POINT_TEXT: &str = "duckdb.code_point_text"; /// HEX_ENCODE's hexadecimal digits of the UTF-8 bytes: `duckdb.hex_text`. pub const HEX_TEXT: &str = "duckdb.hex_text"; +/// Raising an error for bytes longer than a BINARY(n) target: `duckdb.binary_length_check`. +pub const BINARY_LENGTH_CHECK: &str = "duckdb.binary_length_check"; +/// Hexadecimal text read as bytes, as under BINARY_INPUT_FORMAT HEX: `duckdb.hex_bytes`. +pub const HEX_BYTES: &str = "duckdb.hex_bytes"; +/// What a cast of text that is not hex digits to BINARY raises with. +pub const HEX_BYTES_MESSAGE: &str = "The following string is not a legal hex-encoded value"; +/// What a cast of bytes longer than its BINARY(n) target raises with. +pub const BINARY_TOO_LONG_MESSAGE: &str = "Binary value is too long and would be truncated"; /// TRY_HEX_DECODE_STRING's text from hexadecimal digits: `duckdb.hex_decoded_text`. pub const HEX_DECODED_TEXT: &str = "duckdb.hex_decoded_text"; diff --git a/crates/sqlquack-duckdb/src/text/main/binary_length_check.rs b/crates/sqlquack-duckdb/src/text/main/binary_length_check.rs new file mode 100644 index 000000000..26ae7a7c8 --- /dev/null +++ b/crates/sqlquack-duckdb/src/text/main/binary_length_check.rs @@ -0,0 +1,19 @@ +//! `binary_length_check`: raises an error when bytes are longer than `max_length`, as a BINARY(n) cast does. + +use sqlquack_core::failures::constants::BINARY_TOO_LONG; +use sqlquack_core::failures::main::tagged_message::tagged_message; +use sqlquack_core::provenance::models::ProvenanceLog; + +use crate::raises::main::raised::raised; +use crate::text::constants::{BINARY_LENGTH_CHECK, BINARY_TOO_LONG_MESSAGE}; + +/// Raises an error when bytes are longer than `max_length`, as a BINARY(n) cast does. +pub fn binary_length_check(sql: &str, max_length: u32, log: &ProvenanceLog) -> String { + log.building_block(BINARY_LENGTH_CHECK); + format!( + "list_transform([{sql}], lambda binary_value: CASE \ + WHEN octet_length(binary_value) > {max_length} THEN CAST({} AS BLOB) \ + ELSE binary_value END)[1]", + raised(&tagged_message(BINARY_TOO_LONG, BINARY_TOO_LONG_MESSAGE)) + ) +} diff --git a/crates/sqlquack-duckdb/src/text/main/hex_bytes.rs b/crates/sqlquack-duckdb/src/text/main/hex_bytes.rs new file mode 100644 index 000000000..b68a86e14 --- /dev/null +++ b/crates/sqlquack-duckdb/src/text/main/hex_bytes.rs @@ -0,0 +1,22 @@ +//! `hex_bytes`: text of whole pairs of hexadecimal digits to BINARY, as under BINARY_INPUT_FORMAT HEX. + +use sqlquack_core::failures::constants::HEX_DECODE_FAILED; +use sqlquack_core::failures::main::tagged_message::tagged_message; +use sqlquack_core::provenance::models::ProvenanceLog; + +use crate::raises::main::raised::raised; +use crate::text::constants::{HEX_BYTES, HEX_BYTES_MESSAGE, HEX_PAIRS_PATTERN}; + +/// The bytes hex digits of either case spell; other text raises 100115, or is NULL when `trying`. +pub fn hex_bytes(sql: &str, trying: bool, log: &ProvenanceLog) -> String { + log.building_block(HEX_BYTES); + let failure = match trying { + true => "NULL".to_owned(), + false => raised(&tagged_message(HEX_DECODE_FAILED, HEX_BYTES_MESSAGE)), + }; + format!( + "list_transform([{sql}], lambda hex_text: CASE WHEN hex_text IS NULL THEN NULL \ + WHEN regexp_full_match(hex_text, '{HEX_PAIRS_PATTERN}') THEN unhex(hex_text) \ + ELSE CAST({failure} AS BLOB) END)[1]" + ) +} diff --git a/crates/sqlquack-duckdb/src/text/main/mod.rs b/crates/sqlquack-duckdb/src/text/main/mod.rs index 9f11271e8..b632a2b82 100644 --- a/crates/sqlquack-duckdb/src/text/main/mod.rs +++ b/crates/sqlquack-duckdb/src/text/main/mod.rs @@ -1,6 +1,7 @@ //! One building block per module. pub mod base64_bytes; +pub mod binary_length_check; pub mod bytes_substring; pub mod bytes_text; pub mod character_edit_distance; @@ -10,6 +11,7 @@ pub mod compared_number_range; pub mod compared_number_text; pub mod folded_text; pub mod guarded_pattern; +pub mod hex_bytes; pub mod hex_decoded_text; pub mod hex_text; pub mod json_parse_url; diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 1d6212b15..cd76f7bc6 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -2,13 +2,13 @@ use crate::catalogue::models::NotExpressibleDeclaration; use sqlquack_core::failures::constants::{ - BOOLEAN_NOT_RECOGNIZED, CHARACTER_CODE_FAILURES, DATE_FORMAT_MISMATCH, DATE_NOT_RECOGNIZED, - DIVISION_BY_ZERO, DIVISION_FAILURES, FLATTEN_FAILURES, HEX_DECODE_FAILURES, - INVALID_UTF8_FAILURES, JSON_NOT_PARSED, KEY_EXISTS_FAILURES, LOGARITHM_FAILURES, - NOT_AN_ARRAY_ARGUMENT, NOT_AN_ARRAY_FAILURES, NUMBER_NOT_RECOGNIZED, NUMBER_OUT_OF_RANGE, - NUMBER_TEXT_OUT_OF_RANGE, OBJECT_KEY_EXISTS, RANGE_STEP_FAILURES, STRING_TOO_LONG, - TIME_TEXT_FAILURES, TIMESTAMP_FORMAT_MISMATCH, TIMESTAMP_NOT_RECOGNIZED, VARIANT_NOT_CAST, - ZONE_FAILURES, + BINARY_TOO_LONG, BOOLEAN_NOT_RECOGNIZED, CHARACTER_CODE_FAILURES, DATE_FORMAT_MISMATCH, + DATE_NOT_RECOGNIZED, DIVISION_BY_ZERO, DIVISION_FAILURES, FLATTEN_FAILURES, HEX_DECODE_FAILED, + HEX_DECODE_FAILURES, INVALID_UTF8_FAILURES, JSON_NOT_PARSED, KEY_EXISTS_FAILURES, + LOGARITHM_FAILURES, NOT_AN_ARRAY_ARGUMENT, NOT_AN_ARRAY_FAILURES, NUMBER_NOT_RECOGNIZED, + NUMBER_OUT_OF_RANGE, NUMBER_TEXT_OUT_OF_RANGE, OBJECT_KEY_EXISTS, RANGE_STEP_FAILURES, + STRING_TOO_LONG, TIME_TEXT_FAILURES, TIMESTAMP_FORMAT_MISMATCH, TIMESTAMP_NOT_RECOGNIZED, + VARIANT_NOT_CAST, ZONE_FAILURES, }; use sqlquack_core::failures::models::{ErrorKind, FailureMode}; use sqlquack_snowflake::typing::constants::VARIANT_COMMON_TYPE; @@ -22,15 +22,15 @@ use crate::conversion::constants::{ ARRAY_EXTREME_RULE, ARRAY_FLATTEN_RULE, ARRAY_POSITION_RULE, ARRAY_RANGE_RULE, ARRAY_REVERSE_RULE, ARRAY_SET_RULE, ARRAY_SIZE_RULE, ARRAY_SLICE_RULE, ARRAY_SORT_RULE, ARRAY_TO_STRING_RULE, ASOF_JOIN, AVERAGE, BASE64_BINARY, BETWEEN, BITWISE, CASE, CASE_MAPPING, - CAST_BOOLEAN, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, CAST_TEXT_LITERAL, - CAST_TEXT_TIME, CAST_TEXT_TIMESTAMP, CAST_TO_TEXT, CAST_UTC_ZONED, COALESCE, COLUMN, COMMENT, - COMMON_TABLE, COMPARISON, COMPUTED_SUBSTR_LENGTH, CONCAT, CONCAT_WS, CONSTANT_LITERAL, COUNT, - CURRENT_TIME_RULE, DATE_ARITHMETIC, DATE_DAY_ARITHMETIC, DATE_FORMAT_RULE, - DATE_LITERAL_COMPARISON, DATE_PART_RULE, DAY_NAME_RULE, DECIMAL_ARITHMETIC, DECIMAL_DIVISION, - DECIMAL_EPOCH_RULE, DECIMAL_MODULO, DECODE_RULE, DISTINCT, DIV0_RULE, EDIT_DISTANCE, EXISTS, - FLATTEN_RULE, FLOAT_ARITHMETIC, FLOAT_MODULO, FLOAT_TO_DOUBLE, FRACTIONAL_DATE_AMOUNT, - FROM_PARTS, FROM_RELATION, FROM_SUBQUERY, GENERATOR_RULE, GROUP_BY, GROUP_BY_ALL_RULE, - HASH_RULE, HAVING, HIERARCHY_RULE, IFF, ILIKE_FOLDING, IN_LIST, IN_SUBQUERY, + CAST_BOOLEAN, CAST_HEX_BINARY, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, + CAST_TEXT_LITERAL, CAST_TEXT_TIME, CAST_TEXT_TIMESTAMP, CAST_TO_TEXT, CAST_UTC_ZONED, COALESCE, + COLUMN, COMMENT, COMMON_TABLE, COMPARISON, COMPUTED_SUBSTR_LENGTH, CONCAT, CONCAT_WS, + CONSTANT_LITERAL, COUNT, CURRENT_TIME_RULE, DATE_ARITHMETIC, DATE_DAY_ARITHMETIC, + DATE_FORMAT_RULE, DATE_LITERAL_COMPARISON, DATE_PART_RULE, DAY_NAME_RULE, DECIMAL_ARITHMETIC, + DECIMAL_DIVISION, DECIMAL_EPOCH_RULE, DECIMAL_MODULO, DECODE_RULE, DISTINCT, DIV0_RULE, + EDIT_DISTANCE, EXISTS, FLATTEN_RULE, FLOAT_ARITHMETIC, FLOAT_MODULO, FLOAT_TO_DOUBLE, + FRACTIONAL_DATE_AMOUNT, FROM_PARTS, FROM_RELATION, FROM_SUBQUERY, GENERATOR_RULE, GROUP_BY, + GROUP_BY_ALL_RULE, HASH_RULE, HAVING, HIERARCHY_RULE, IFF, ILIKE_FOLDING, IN_LIST, IN_SUBQUERY, INTERVAL_ARITHMETIC, IS_NULL, JARO_WINKLER, JOIN_CROSS, JOIN_ON, JOIN_USING, LAMBDA_RULE, LAST_DAY_RULE, LATERAL_SUBQUERY, LEAST_GREATEST, LIKE, LIMIT, LISTAGG_RULE, LOGARITHM, LOGIC, MEDIAN_RULE, MIN_MAX, MIN_MAX_FLOAT, MODE_RULE, NAMED_AGGREGATES, NEGATE, NOT_IN_SUBQUERY, @@ -456,6 +456,21 @@ pub const RULES: &[RuleSpec] = &[ }, ], }, + RuleSpec { + id: CAST_HEX_BINARY, + constructs: &["node:cast", "node:try_cast"], + conditions: "VARCHAR to BINARY or BINARY(n) under the session profile's pinned BINARY_INPUT_FORMAT HEX: whole pairs of hexadecimal digits of either case, as recorded; empty text gives empty bytes and NULL gives NULL. An odd count of digits or any other character fails with error 100115, or is NULL under TRY_CAST. More bytes than BINARY(n) holds fail the query with error 100145 rather than being truncated, as recorded; TRY_CAST to BINARY(n) is refused, since no recording shows it.", + failures: &[ + FailureMode { + kind: ErrorKind::InvalidArgument, + snowflake: &[HEX_DECODE_FAILED], + }, + FailureMode { + kind: ErrorKind::StringTooLong, + snowflake: &[BINARY_TOO_LONG], + }, + ], + }, RuleSpec { id: CAST_TEXT_LITERAL, constructs: &["node:cast", "node:try_cast"], diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs index d55811335..deac0b68f 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs @@ -25,6 +25,8 @@ use sqlquack_duckdb::temporal::main::text_to_timestamp::text_to_timestamp; use sqlquack_duckdb::temporal::main::text_to_zoned::text_to_zoned; use sqlquack_duckdb::temporal::main::utc_to_zone::utc_to_zone; use sqlquack_duckdb::temporal::main::utc_wall_time::utc_wall_time; +use sqlquack_duckdb::text::main::binary_length_check::binary_length_check; +use sqlquack_duckdb::text::main::hex_bytes::hex_bytes; use sqlquack_duckdb::times::main::text_to_time::text_to_time; use sqlquack_duckdb::times::main::time_text::time_text; use sqlquack_duckdb::times::main::timestamp_time::timestamp_time; @@ -59,13 +61,13 @@ use crate::conversion::_helpers::support::tree::{ LITERAL_NUMBER, field, node_construct, node_kind, payload_object, require_understood, }; use crate::conversion::constants::{ - CAST_BOOLEAN, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, CAST_TEXT_LITERAL, - CAST_TEXT_TIME, CAST_TEXT_TIMESTAMP, CAST_TO_TEXT, CAST_UTC_ZONED, FLOAT_SCALE_FACET, - FLOAT_TO_DOUBLE, KEEP_NULL_CONSTRUCTOR, KIND_CAST_TARGETS, MICROSECOND_DIGITS, - NTZ_OUTPUT_PATTERN, OBJECT_CONSTRUCTOR, OBJECT_TYPE_NAME, TARGET_FACET, TEXT_TYPE_PAIR, - TEXT_TYPE_SPELLINGS, TIME_CAST, TIMESTAMP_TIME_RULE, TO_BOOLEAN_RULE, TO_DOUBLE_PAIR, - TO_NUMBER_PAIR, TO_VARCHAR_PAIR, TO_VARIANT_RULE, VARIANT_CAST, VARIANT_KIND_CAST, - VARIANT_NAME, VARIANT_TEXT_FUNCTION, VARIANT_TO_VARCHAR, + CAST_BOOLEAN, CAST_HEX_BINARY, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, + CAST_TEXT_LITERAL, CAST_TEXT_TIME, CAST_TEXT_TIMESTAMP, CAST_TO_TEXT, CAST_UTC_ZONED, + FLOAT_SCALE_FACET, FLOAT_TO_DOUBLE, KEEP_NULL_CONSTRUCTOR, KIND_CAST_TARGETS, + MICROSECOND_DIGITS, NTZ_OUTPUT_PATTERN, OBJECT_CONSTRUCTOR, OBJECT_TYPE_NAME, TARGET_FACET, + TEXT_TYPE_PAIR, TEXT_TYPE_SPELLINGS, TIME_CAST, TIMESTAMP_TIME_RULE, TO_BOOLEAN_RULE, + TO_DOUBLE_PAIR, TO_NUMBER_PAIR, TO_VARCHAR_PAIR, TO_VARIANT_RULE, VARIANT_CAST, + VARIANT_KIND_CAST, VARIANT_NAME, VARIANT_TEXT_FUNCTION, VARIANT_TO_VARCHAR, }; use sqlquack_policy::policy::constants::{ DATE_TEXT_SPELLING, SESSION_TIMEZONE_UTC, TIMESTAMP_RANGE, TIMESTAMP_TEXT_ROUNDING, @@ -294,6 +296,18 @@ fn cast_sql( let _rule = cast_rule(CAST_PARSED_TEXT, log); Ok(text_to_boolean(&operand.sql, text_failure(trying), log)) } + (SnowflakeType::Varchar, SnowflakeType::Binary, _) => { + let _rule = cast_rule(CAST_HEX_BINARY, log); + let bytes = hex_bytes(&operand.sql, trying, log); + match (target.max_length, trying) { + (None, _) => Ok(bytes), + (Some(max_length), false) => Ok(binary_length_check(&bytes, max_length, log)), + (Some(_), true) => Err( + "TRY_CAST to BINARY(n), whose result for longer bytes no recording shows" + .to_owned(), + ), + } + } (SnowflakeType::Varchar, SnowflakeType::Time { precision }, literal) => { let _rule = cast_rule(CAST_TEXT_TIME, log); time_submicro((operand.literal.as_ref(), precision), construct, context) @@ -791,6 +805,7 @@ pub(crate) fn data_type_name(data_type: &Value) -> Option { "text" => "VARCHAR".to_owned(), "char" => with_arguments("CHAR", &[number("length")]), "boolean" => "BOOLEAN".to_owned(), + "binary" | "var_binary" => with_arguments("BINARY", &[number("length")]), "date" => "DATE".to_owned(), "time" if !zoned => with_arguments("TIME", &[number("precision")]), "timestamp" if zoned => with_arguments("TIMESTAMP_TZ", &[number("precision")]), diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/shapes.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/shapes.rs index 19a2c510a..7fbcef01a 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/shapes.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/shapes.rs @@ -41,6 +41,7 @@ pub(crate) fn type_family(data_type: SnowflakeType) -> &'static str { SnowflakeType::Number { .. } => "decimal", SnowflakeType::Float => "float", SnowflakeType::Varchar => "text", + SnowflakeType::Binary => "binary", SnowflakeType::Boolean => "boolean", SnowflakeType::Date => "date", SnowflakeType::Time { .. } => "time", diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index c506203ab..a1482d3a6 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -615,6 +615,8 @@ pub const CAST_TO_TEXT: &str = "rule.cast_to_text"; pub const CAST_BOOLEAN: &str = "rule.cast_boolean"; /// CAST and TRY_CAST of text to NUMBER, FLOAT or BOOLEAN. pub const CAST_PARSED_TEXT: &str = "rule.cast_parsed_text"; +/// VARCHAR to BINARY under the pinned BINARY_INPUT_FORMAT HEX. +pub const CAST_HEX_BINARY: &str = "rule.cast_hex_binary"; /// Casts of string literals. pub const CAST_TEXT_LITERAL: &str = "rule.cast_text_literal"; /// Casts to the operand's own type, and of NULL. diff --git a/crates/sqlquack-semantics/src/lineage/_helpers/typing/snowflake_sets.rs b/crates/sqlquack-semantics/src/lineage/_helpers/typing/snowflake_sets.rs index bb2ad2590..c8296343d 100644 --- a/crates/sqlquack-semantics/src/lineage/_helpers/typing/snowflake_sets.rs +++ b/crates/sqlquack-semantics/src/lineage/_helpers/typing/snowflake_sets.rs @@ -97,7 +97,7 @@ fn set_type(spelling: &str) -> Option { (Kind::Variant, None, 0) } SnowflakeType::SemiStructured => return None, - SnowflakeType::Null => return None, + SnowflakeType::Binary | SnowflakeType::Null => return None, }; Some(SetType { kind, diff --git a/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs b/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs index 830c12a6a..3f2f87e2f 100644 --- a/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs +++ b/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs @@ -50,6 +50,21 @@ pub(crate) fn text_target( }) } +/// BINARY or BINARY(n): bytes, at most `n` of them. +pub(crate) fn binary_target(arguments: &[u32], name: &str) -> Result { + match arguments { + [] => Ok(CastTarget { + data_type: SnowflakeType::Binary, + max_length: None, + }), + [length] => Ok(CastTarget { + data_type: SnowflakeType::Binary, + max_length: Some(*length), + }), + _ => Err(format!("too many arguments in `{name}`")), + } +} + pub(crate) fn fractional(arguments: &[u32], name: &str) -> Result { match arguments { [] => Ok(DEFAULT_FRACTIONAL_PRECISION), diff --git a/crates/sqlquack-snowflake/src/typing/main/cast_target.rs b/crates/sqlquack-snowflake/src/typing/main/cast_target.rs index 7c747f8ca..4363351e4 100644 --- a/crates/sqlquack-snowflake/src/typing/main/cast_target.rs +++ b/crates/sqlquack-snowflake/src/typing/main/cast_target.rs @@ -2,7 +2,9 @@ use sqlquack_core::provenance::models::ProvenanceLog; -use crate::typing::_helpers::targets::{fractional, number_target, parse_arguments, text_target}; +use crate::typing::_helpers::targets::{ + binary_target, fractional, number_target, parse_arguments, text_target, +}; use crate::typing::constants::{ CAST_TARGET, NUMBER_38_0, STRUCTURED_TYPES, STRUCTURED_UNSUPPORTED, }; @@ -49,6 +51,7 @@ pub fn cast_target(name: &str, log: &ProvenanceLog) -> Result text_target(&arguments, Some(1), name), + "BINARY" | "VARBINARY" => binary_target(&arguments, name), "BOOLEAN" => plain(SnowflakeType::Boolean), "DATE" => plain(SnowflakeType::Date), "TIME" => fractional(&arguments, name).map(|precision| CastTarget { diff --git a/crates/sqlquack-snowflake/src/typing/models.rs b/crates/sqlquack-snowflake/src/typing/models.rs index f6b4ccfd8..9831903fc 100644 --- a/crates/sqlquack-snowflake/src/typing/models.rs +++ b/crates/sqlquack-snowflake/src/typing/models.rs @@ -42,6 +42,8 @@ pub enum SnowflakeType { /// Fractional-second digits. precision: u8, }, + /// `BINARY`; the declared length never changes a value it accepts. + Binary, /// `VARIANT`, `OBJECT` or `ARRAY`. SemiStructured, /// An untyped `NULL` literal, which takes the type of its context. @@ -70,6 +72,7 @@ impl Display for SnowflakeType { Self::TimestampNtz { precision } => write!(formatter, "TIMESTAMP_NTZ({precision})"), Self::TimestampTz { precision } => write!(formatter, "TIMESTAMP_TZ({precision})"), Self::TimestampLtz { precision } => write!(formatter, "TIMESTAMP_LTZ({precision})"), + Self::Binary => formatter.write_str("BINARY"), Self::SemiStructured => formatter.write_str("VARIANT"), Self::Null => formatter.write_str("NULL"), } @@ -81,6 +84,6 @@ impl Display for SnowflakeType { pub struct CastTarget { /// The target type. pub data_type: SnowflakeType, - /// For VARCHAR(n) and CHAR(n): `n`; longer values raise an error. + /// For VARCHAR(n) and CHAR(n) characters, for BINARY(n) bytes: `n`; longer values raise. pub max_length: Option, } From 6a16e3e4d0881945b535c5dde06a64444d634a4a Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:10:34 +0000 Subject: [PATCH 06/18] Byte-wise LENGTH, LEFT/RIGHT/SUBSTR, LPAD/RPAD, POSITION and concatenation of BINARY Each family reads BINARY in bytes, as the t00x_binary receipts record: LENGTH, LEN and OCTET_LENGTH count bytes; LEFT, RIGHT, SUBSTR and SUBSTRING slice them; LPAD and RPAD pad with BINARY padding; POSITION and CHARINDEX match whole bytes; CONCAT, || and CONCAT_WS join BINARY parts. Values no BINARY receipt reaches (negative lengths, starts below 1, empty padding or search) raise instead of guessing, and BINARY beside another type stays refused. OCTET_LENGTH of VARCHAR counts UTF-8 bytes, as its receipts record. --- .../sqlquack-rules/src/catalogue/constants.rs | 49 +++- .../conversion/_helpers/expressions/bytes.rs | 211 ++++++++++++++++++ .../conversion/_helpers/expressions/calls.rs | 2 + .../conversion/_helpers/expressions/mod.rs | 1 + .../_helpers/expressions/strings.rs | 47 +++- .../_helpers/expressions/text_functions.rs | 45 +++- .../_helpers/expressions/text_search.rs | 17 +- .../src/conversion/constants.rs | 14 ++ 8 files changed, 359 insertions(+), 27 deletions(-) create mode 100644 crates/sqlquack-rules/src/conversion/_helpers/expressions/bytes.rs diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index cd76f7bc6..821603321 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -21,8 +21,9 @@ use crate::conversion::constants::{ ARRAY_CONSTRUCT_COMPACT_RULE, ARRAY_CONSTRUCT_RULE, ARRAY_CONTAINS_RULE, ARRAY_EXCEPT_RULE, ARRAY_EXTREME_RULE, ARRAY_FLATTEN_RULE, ARRAY_POSITION_RULE, ARRAY_RANGE_RULE, ARRAY_REVERSE_RULE, ARRAY_SET_RULE, ARRAY_SIZE_RULE, ARRAY_SLICE_RULE, ARRAY_SORT_RULE, - ARRAY_TO_STRING_RULE, ASOF_JOIN, AVERAGE, BASE64_BINARY, BETWEEN, BITWISE, CASE, CASE_MAPPING, - CAST_BOOLEAN, CAST_HEX_BINARY, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, + ARRAY_TO_STRING_RULE, ASOF_JOIN, AVERAGE, BASE64_BINARY, BETWEEN, BITWISE, BYTE_CONCAT, + BYTE_LENGTH, BYTE_PAD, BYTE_SEARCH, BYTE_SLICE, CASE, CASE_MAPPING, CAST_BOOLEAN, + CAST_HEX_BINARY, CAST_NUMERIC, CAST_PARSED_TEXT, CAST_SAME_TYPE, CAST_TEMPORAL, CAST_TEXT_LITERAL, CAST_TEXT_TIME, CAST_TEXT_TIMESTAMP, CAST_TO_TEXT, CAST_UTC_ZONED, COALESCE, COLUMN, COMMENT, COMMON_TABLE, COMPARISON, COMPUTED_SUBSTR_LENGTH, CONCAT, CONCAT_WS, CONSTANT_LITERAL, COUNT, CURRENT_TIME_RULE, DATE_ARITHMETIC, DATE_DAY_ARITHMETIC, @@ -43,7 +44,7 @@ use crate::conversion::constants::{ REGEXP_POSIX_SPACE, ROUNDING, SCALAR_SUBQUERY, SELECT, SET_OPERATION, SIGN_RULE, SPLIT_RULE, SPLIT_TO_TABLE_RULE, STAR, SUM, SUM_FLOAT, SYSTEM_TYPE_NAME, TEMPORAL_TEXT_FORMAT, TEXT_AFFIX, TEXT_BRANCHES, TEXT_CHR, TEXT_CONTAINS, TEXT_HEX, TEXT_HEX_DECODE, TEXT_INITCAP, TEXT_MD5, - TEXT_NUMBER_COMPARISON, TEXT_PAD, TEXT_POSITION, TEXT_REPEAT, TEXT_REPLACE, + TEXT_NUMBER_COMPARISON, TEXT_OCTET_LENGTH, TEXT_PAD, TEXT_POSITION, TEXT_REPEAT, TEXT_REPLACE, TEXT_SEARCH_POSITION, TEXT_SHA2, TEXT_TO_TIME_RULE, TEXT_TO_TIMESTAMP_RULE, TEXT_TRANSLATE, TIME_ARITHMETIC, TIME_CAST, TIME_ZONE_CONVERSION, TIMESTAMP_FORMAT_RULE, TIMESTAMP_TIME_RULE, TO_ARRAY_RULE, TO_BOOLEAN_RULE, TO_DATE_TEMPORAL, TO_TIMESTAMP_TEMPORAL, TO_VARIANT_RULE, TRIM, @@ -1247,6 +1248,48 @@ pub const RULES: &[RuleSpec] = &[ conditions: "VARCHAR, NUMBER, FLOAT, BOOLEAN or DATE operands; NUMBER, BOOLEAN and DATE are formatted as Snowflake does, FLOAT as its ::VARCHAR text, recorded over many rows; JSON-held VARIANTs as ::VARCHAR writes them, relying on json_fidelity. Text constants concatenate to a text constant.", failures: &[], }, + RuleSpec { + id: BYTE_LENGTH, + constructs: &["fn:LENGTH", "fn:LEN", "fn:OCTET_LENGTH"], + conditions: "A BINARY operand: its count of bytes, not of hex digits, typed NUMBER(18,0), as recorded; NULL for NULL.", + failures: &[], + }, + RuleSpec { + id: TEXT_OCTET_LENGTH, + constructs: &["fn:OCTET_LENGTH"], + conditions: "A VARCHAR operand: its count of UTF-8 bytes, typed NUMBER(18,0), as recorded for multi-byte and empty text; NULL for NULL.", + failures: &[], + }, + RuleSpec { + id: BYTE_SLICE, + constructs: &["fn:LEFT", "fn:RIGHT", "fn:SUBSTR", "fn:SUBSTRING"], + conditions: "A BINARY operand and NUMBER(p,0) positions and lengths, counted in bytes, as recorded: LEFT and RIGHT keep the first or last bytes, all of them for a longer length; SUBSTR starts at a byte position of 1 or more. A negative length or a start below 1, which no BINARY recording reaches, raises an error instead of a result; NULL arguments give NULL.", + failures: &[FailureMode { + kind: ErrorKind::NumericOverflow, + snowflake: &[NUMBER_OUT_OF_RANGE], + }], + }, + RuleSpec { + id: BYTE_PAD, + constructs: &["fn:LPAD", "fn:RPAD"], + conditions: "A BINARY operand and BINARY padding with an integer NUMBER length, counted in bytes: shorter bytes are padded with the padding repeated from its first byte, before them for LPAD and after them for RPAD, as recorded; longer bytes are cut to the length. A negative length or empty padding, which no BINARY recording reaches, raises an error; LPAD and RPAD of BINARY without padding are refused; NULL arguments give NULL.", + failures: &[FailureMode { + kind: ErrorKind::NumericOverflow, + snowflake: &[NUMBER_OUT_OF_RANGE], + }], + }, + RuleSpec { + id: BYTE_SEARCH, + constructs: &["fn:CHARINDEX", "fn:POSITION", "node:str_position"], + conditions: "Two BINARY operands, without a start position: the first byte position of one in the other counted from 1, matching whole bytes only, 0 when absent, typed NUMBER(9,0), as recorded; an empty search, which no BINARY recording reaches, raises an error; NULL operands give NULL.", + failures: &[], + }, + RuleSpec { + id: BYTE_CONCAT, + constructs: &["node:concat", "fn:CONCAT", "fn:CONCAT_WS"], + conditions: "BINARY operands only: their bytes in order, CONCAT_WS with the separator's bytes between parts, as recorded for CONCAT, || and CONCAT_WS; NULL when any operand is NULL. BINARY beside another type, and CONCAT_WS of a single BINARY part, are refused.", + failures: &[], + }, RuleSpec { id: COUNT, constructs: &["fn:COUNT"], diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/bytes.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/bytes.rs new file mode 100644 index 000000000..b533b9293 --- /dev/null +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/bytes.rs @@ -0,0 +1,211 @@ +//! LENGTH, LEFT, RIGHT, SUBSTR, LPAD, RPAD, POSITION and concatenation of BINARY, byte by byte. + +use serde_json::Value; +use sqlquack_core::classification::models::Construct; +use sqlquack_core::refusals::main::outside_conditions::outside_conditions; +use sqlquack_core::refusals::models::Refusal; +use sqlquack_duckdb::blocks::main::null_propagating_concat::null_propagating_concat; +use sqlquack_duckdb::raises::main::raised::raised; +use sqlquack_snowflake::typing::models::SnowflakeType; + +use crate::conversion::_helpers::expressions::scalar::convert_expression; +use crate::conversion::_helpers::expressions::strings::emitted_text_operand; +use crate::conversion::_helpers::expressions::text_functions::integer_operand; +use crate::conversion::_helpers::support::scope::Emitted; +use crate::conversion::_helpers::support::site::Site; +use crate::conversion::constants::{ + BYTE_CONCAT, BYTE_LENGTH, BYTE_PAD, BYTE_SEARCH, BYTE_SLICE, LENGTH_TYPE, TEXT_OCTET_LENGTH, + TEXT_POSITION_LIMIT, TEXT_SEARCH_POSITION_TYPE, UNRECORDED_BYTES, +}; + +/// The DuckDB type BINARY is held in. +const BLOB: &str = "BLOB"; + +/// LENGTH, LEN or OCTET_LENGTH of BINARY: its count of bytes, typed NUMBER(18,0). +pub(crate) fn byte_length(bytes: &Emitted, site: Site<'_>) -> Emitted { + let _rule = site.context.log.rule(BYTE_LENGTH); + Emitted::new( + format!("CAST(octet_length({}) AS DECIMAL(18,0))", bytes.sql), + LENGTH_TYPE, + ) +} + +/// OCTET_LENGTH: the count of bytes of BINARY, or of VARCHAR's UTF-8 bytes. +pub(crate) fn octet_length( + value: &Value, + construct: &Construct, + site: Site<'_>, +) -> Result { + let value = convert_expression(value, site)?; + if value.data_type == SnowflakeType::Binary { + return Ok(byte_length(&value, site)); + } + let _rule = site.context.log.rule(TEXT_OCTET_LENGTH); + let text = emitted_text_operand(value, construct, site)?; + Ok(Emitted::new( + format!("CAST(octet_length(encode({text})) AS DECIMAL(18,0))"), + LENGTH_TYPE, + )) +} + +/// LEFT or RIGHT of BINARY: its first or last `length` bytes; a negative length raises. +pub(crate) fn byte_left_right( + name: &str, + (bytes, length): (&Emitted, &Value), + construct: &Construct, + site: Site<'_>, +) -> Result { + let _rule = site.context.log.rule(BYTE_SLICE); + let length = integer_operand(length, construct, site)?; + Ok(Emitted::new( + format!( + "list_transform([struct_pack(b := {}, n := {length})], lambda p: CASE \ + WHEN p['n'] < 0 THEN {} \ + ELSE unhex({}(hex(p['b']), 2 * {})) END)[1]", + bytes.sql, + unrecorded("a negative length", BLOB), + name.to_ascii_lowercase(), + clamped("p['n']") + ), + SnowflakeType::Binary, + )) +} + +/// SUBSTR or SUBSTRING of BINARY from a byte position of 1 or more; other positions raise. +pub(crate) fn byte_substring( + (bytes, start, length): (&Emitted, &Value, Option<&Value>), + construct: &Construct, + site: Site<'_>, +) -> Result { + let _rule = site.context.log.rule(BYTE_SLICE); + let start = integer_operand(start, construct, site)?; + let (length, digits) = match length { + Some(length) => ( + integer_operand(length, construct, site)?, + format!(", 2 * {}", clamped("p['n']")), + ), + None => ("0".to_owned(), String::new()), + }; + Ok(Emitted::new( + format!( + "list_transform([struct_pack(b := {}, s := {start}, n := {length})], lambda p: CASE \ + WHEN p['s'] < 1 THEN {} WHEN p['n'] < 0 THEN {} \ + ELSE unhex(substring(hex(p['b']), 2 * {} - 1{digits})) \ + END)[1]", + bytes.sql, + unrecorded("a start position below 1", BLOB), + unrecorded("a negative length", BLOB), + clamped("p['s']"), + ), + SnowflakeType::Binary, + )) +} + +/// LPAD or RPAD of BINARY with BINARY padding, repeated from its first byte, or cut to the length. +pub(crate) fn byte_pad( + name: &str, + (bytes, length, filler): (&Emitted, &Value, Option<&Emitted>), + construct: &Construct, + site: Site<'_>, +) -> Result { + let _rule = site.context.log.rule(BYTE_PAD); + let Some(filler) = filler.filter(|filler| is_bytes(filler)) else { + return Err(outside_conditions( + construct.clone(), + format!("{name} of BINARY without BINARY padding, which no recording shows"), + )); + }; + let length = integer_operand(length, construct, site)?; + Ok(Emitted::new( + format!( + "list_transform([struct_pack(b := {}, n := {length}, f := {})], lambda p: CASE \ + WHEN p['n'] < 0 THEN {} WHEN octet_length(p['f']) = 0 THEN {} \ + ELSE unhex({}(hex(p['b']), CAST(2 * {} AS INTEGER), hex(p['f']))) \ + END)[1]", + bytes.sql, + filler.sql, + unrecorded("a negative length", BLOB), + unrecorded("empty padding", BLOB), + name.to_ascii_lowercase(), + clamped("p['n']") + ), + SnowflakeType::Binary, + )) +} + +/// The first byte position of BINARY `needle` in BINARY `haystack`, 0 when absent. +pub(crate) fn byte_position( + (needle, haystack): (&Emitted, &Emitted), + construct: &Construct, + site: Site<'_>, +) -> Result { + let _rule = site.context.log.rule(BYTE_SEARCH); + if !is_bytes(needle) || !is_bytes(haystack) { + return Err(mixed(construct)); + } + Ok(Emitted::new( + format!( + "list_transform([struct_pack(n := hex({}), h := hex({}))], lambda p: CASE \ + WHEN p['n'] IS NULL OR p['h'] IS NULL THEN NULL WHEN p['n'] = '' THEN {} \ + ELSE CAST(coalesce(list_position(list_transform(\ + range(0, (length(p['h']) - length(p['n'])) // 2 + 1), \ + lambda i: substring(p['h'], 2 * i + 1, length(p['n'])) = p['n']), true), 0) \ + AS DECIMAL(9,0)) END)[1]", + needle.sql, + haystack.sql, + unrecorded("an empty search", "DECIMAL(9,0)"), + ), + TEXT_SEARCH_POSITION_TYPE, + )) +} + +/// CONCAT, `||` or CONCAT_WS of BINARY parts: their bytes in order, NULL when any part is. +pub(crate) fn byte_concat( + parts: &[&Emitted], + construct: &Construct, + site: Site<'_>, +) -> Result { + let _rule = site.context.log.rule(BYTE_CONCAT); + if !parts.iter().all(|part| is_bytes(part)) { + return Err(mixed(construct)); + } + let parts: Vec = parts + .iter() + .map(|part| format!("CAST({} AS BLOB)", part.sql)) + .collect(); + Ok(Emitted::new( + null_propagating_concat(&parts, site.context.log), + SnowflakeType::Binary, + )) +} + +/// Whether `emitted` holds BINARY, or is an untyped NULL. +pub(crate) fn is_bytes(emitted: &Emitted) -> bool { + matches!( + emitted.data_type, + SnowflakeType::Binary | SnowflakeType::Null + ) +} + +fn mixed(construct: &Construct) -> Refusal { + outside_conditions( + construct.clone(), + "BINARY beside another type, which needs an implicit conversion no recording shows" + .to_owned(), + ) +} + +/// An error raised for BINARY values the recordings do not reach, rather than a guessed result. +fn unrecorded(what: &str, held: &str) -> String { + format!( + "CAST({} AS {held})", + raised(&format!("{UNRECORDED_BYTES}: {what}")) + ) +} + +/// A byte count or position cut to the largest Snowflake reads, NULL staying NULL. +fn clamped(count: &str) -> String { + format!( + "(CASE WHEN {count} > {TEXT_POSITION_LIMIT} THEN {TEXT_POSITION_LIMIT} ELSE {count} END)" + ) +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs index a10eb1062..83323f9cd 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/calls.rs @@ -21,6 +21,7 @@ use sqlquack_snowflake::typing::main::sum::sum; use sqlquack_snowflake::typing::models::SnowflakeType; use crate::conversion::_helpers::expressions::arrays::array_function; +use crate::conversion::_helpers::expressions::bytes::octet_length; use crate::conversion::_helpers::expressions::casts::zoned_text; use crate::conversion::_helpers::expressions::casts::{ to_boolean_call, to_double_call, to_number_call, to_varchar_call, @@ -172,6 +173,7 @@ pub(crate) fn convert_function(payload: &Value, site: Site<'_>) -> Result>(), &construct, site) } ("CONCAT_WS", parts) => concatenate_with_separator(parts, &construct, site), + ("OCTET_LENGTH", [value]) => octet_length(value, &construct, site), ("CONTAINS", parts) => text_contains(parts, &construct, site), ("EDITDISTANCE", parts) => edit_distance(parts, &construct, site), ("JAROWINKLER_SIMILARITY", parts) => jaro_winkler(parts, &construct, site), diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/mod.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/mod.rs index b12a65b7f..73245e0c8 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/mod.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod arrays; pub(crate) mod atoms; +pub(crate) mod bytes; pub(crate) mod calls; pub(crate) mod casts; pub(crate) mod conditionals; diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/strings.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/strings.rs index 2b88e4181..a30760417 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/strings.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/strings.rs @@ -30,6 +30,7 @@ use sqlquack_duckdb::text::main::hex_text::hex_text; use sqlquack_duckdb::text::main::title_case::title_case; use sqlquack_snowflake::typing::models::SnowflakeType; +use crate::conversion::_helpers::expressions::bytes::byte_concat; use crate::conversion::_helpers::expressions::scalar::convert_expression; use crate::conversion::_helpers::expressions::text_functions::integer_operand; use crate::conversion::_helpers::support::divergence::diverge; @@ -55,7 +56,15 @@ pub(crate) fn text_operand( construct: &Construct, site: Site<'_>, ) -> Result { - let emitted = convert_expression(node, site)?; + emitted_text_operand(convert_expression(node, site)?, construct, site) +} + +/// A converted VARCHAR (or NULL) operand's SQL. +pub(crate) fn emitted_text_operand( + emitted: Emitted, + construct: &Construct, + site: Site<'_>, +) -> Result { match emitted.data_type { SnowflakeType::Varchar | SnowflakeType::Null => Ok(emitted.sql), SnowflakeType::Date => Ok(format!("CAST({} AS VARCHAR)", emitted.sql)), @@ -255,18 +264,32 @@ pub(crate) fn concatenate_with_separator( construct: &Construct, site: Site<'_>, ) -> Result { - let _rule = site.context.log.rule(CONCAT_WS); let [separator, first, rest @ ..] = arguments else { return Err(outside_conditions( construct.clone(), "CONCAT_WS without a part to join".to_owned(), )); }; - let mut interleaved: Vec = vec![first.clone()]; + let separator = convert_expression(separator, site)?; + let mut interleaved: Vec = vec![convert_expression(first, site)?]; for part in rest { interleaved.push(separator.clone()); - interleaved.push(part.clone()); + interleaved.push(convert_expression(part, site)?); } + if separator.data_type == SnowflakeType::Binary + || interleaved + .iter() + .any(|part| part.data_type == SnowflakeType::Binary) + { + if rest.is_empty() { + return Err(outside_conditions( + construct.clone(), + "CONCAT_WS of a single BINARY part, which no recording shows".to_owned(), + )); + } + return byte_concat(&interleaved.iter().collect::>(), construct, site); + } + let _rule = site.context.log.rule(CONCAT_WS); if rest.is_empty() { interleaved.push(separator.clone()); let texts = operand_texts(&interleaved, construct, site)?; @@ -291,11 +314,17 @@ pub(crate) fn concatenate( construct: &Construct, site: Site<'_>, ) -> Result { - let _rule = site.context.log.rule(CONCAT); let emitted = parts .iter() .map(|part| convert_expression(part, site)) .collect::, Refusal>>()?; + if emitted + .iter() + .any(|part| part.data_type == SnowflakeType::Binary) + { + return byte_concat(&emitted.iter().collect::>(), construct, site); + } + let _rule = site.context.log.rule(CONCAT); let mut folded = String::new(); for part in &emitted { match &part.literal { @@ -328,17 +357,13 @@ fn text_constant(text: String, site: Site<'_>) -> Emitted { /// Each operand as text: VARCHAR as it is, others as Snowflake's `::VARCHAR` writes them. fn operand_texts( - parts: &[Value], + parts: &[Emitted], construct: &Construct, site: Site<'_>, ) -> Result, Refusal> { let mut texts: Vec = Vec::with_capacity(parts.len()); for part in parts { - texts.push(emitted_text( - &convert_expression(part, site)?, - construct, - site, - )?); + texts.push(emitted_text(part, construct, site)?); } Ok(texts) } diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_functions.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_functions.rs index 9d86ed47d..ced962cc3 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_functions.rs @@ -15,9 +15,12 @@ use sqlquack_duckdb::text::main::folded_text::folded_text; use sqlquack_duckdb::text::main::guarded_pattern::guarded_pattern; use sqlquack_snowflake::typing::models::SnowflakeType; +use crate::conversion::_helpers::expressions::bytes::{ + byte_left_right, byte_length, byte_pad, byte_substring, +}; use crate::conversion::_helpers::expressions::result_types::result_type; use crate::conversion::_helpers::expressions::scalar::{call_name, convert_expression}; -use crate::conversion::_helpers::expressions::strings::text_operand; +use crate::conversion::_helpers::expressions::strings::{emitted_text_operand, text_operand}; use crate::conversion::_helpers::support::divergence::diverge; use crate::conversion::_helpers::support::scope::{Emitted, Literal, resolve}; use crate::conversion::_helpers::support::site::Site; @@ -50,7 +53,6 @@ pub(crate) fn pad( construct: &Construct, site: Site<'_>, ) -> Result { - let _rule = site.context.log.rule(TEXT_PAD); let (text, length, filler) = match arguments { [text, length] => (text, length, None), [text, length, filler] => (text, length, Some(filler)), @@ -61,7 +63,16 @@ pub(crate) fn pad( )); } }; - let text = text_operand(text, construct, site)?; + let text = convert_expression(text, site)?; + if text.data_type == SnowflakeType::Binary { + let filler = match filler { + Some(filler) => Some(convert_expression(filler, site)?), + None => None, + }; + return byte_pad(name, (&text, length, filler.as_ref()), construct, site); + } + let _rule = site.context.log.rule(TEXT_PAD); + let text = emitted_text_operand(text, construct, site)?; let length = format!( "CAST({} AS INTEGER)", integer_operand(length, construct, site)? @@ -88,8 +99,12 @@ pub(crate) fn left_right( construct: &Construct, site: Site<'_>, ) -> Result { + let text = convert_expression(text, site)?; + if text.data_type == SnowflakeType::Binary { + return byte_left_right(name, (&text, length), construct, site); + } let _rule = site.context.log.rule(TEXT_POSITION); - let text = text_operand(text, construct, site)?; + let text = emitted_text_operand(text, construct, site)?; let length = integer_operand(length, construct, site)?; Ok(Emitted::new( format!( @@ -105,16 +120,22 @@ pub(crate) fn left_right( /// SUBSTR or SUBSTRING; start 0 counts as 1, and a negative length as 0. pub(crate) fn substring(payload: &Value, site: Site<'_>) -> Result { let construct = node_construct("substring"); - let _rule = site.context.log.rule(TEXT_POSITION); let payload = payload_object(payload, &construct)?; require_understood( payload, &["this", "start", "length", "from_for_syntax"], &construct, )?; - let text = text_operand(field(payload, "this", &construct)?, &construct, site)?; - let start = integer_operand(field(payload, "start", &construct)?, &construct, site)?; - let length = match payload.get("length").filter(|length| !length.is_null()) { + let text = convert_expression(field(payload, "this", &construct)?, site)?; + let start = field(payload, "start", &construct)?; + let length = payload.get("length").filter(|length| !length.is_null()); + if text.data_type == SnowflakeType::Binary { + return byte_substring((&text, start, length), &construct, site); + } + let _rule = site.context.log.rule(TEXT_POSITION); + let text = emitted_text_operand(text, &construct, site)?; + let start = integer_operand(start, &construct, site)?; + let length = match length { None => String::new(), Some(length) => { let emitted = convert_expression(length, site)?; @@ -147,10 +168,14 @@ pub(crate) fn substring(payload: &Value, site: Site<'_>) -> Result) -> Result { let construct = node_construct("length"); - let _rule = site.context.log.rule(TEXT_POSITION); let payload = payload_object(payload, &construct)?; require_understood(payload, &["this", "original_name"], &construct)?; - let text = text_operand(field(payload, "this", &construct)?, &construct, site)?; + let text = convert_expression(field(payload, "this", &construct)?, site)?; + if text.data_type == SnowflakeType::Binary { + return Ok(byte_length(&text, site)); + } + let _rule = site.context.log.rule(TEXT_POSITION); + let text = emitted_text_operand(text, &construct, site)?; Ok(Emitted::new( format!("CAST(length({text}) AS DECIMAL(18,0))"), LENGTH_TYPE, diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_search.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_search.rs index 69d52a638..c900379a2 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_search.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/text_search.rs @@ -5,7 +5,9 @@ use sqlquack_core::classification::models::Construct; use sqlquack_core::refusals::main::outside_conditions::outside_conditions; use sqlquack_core::refusals::models::Refusal; -use crate::conversion::_helpers::expressions::strings::text_operand; +use crate::conversion::_helpers::expressions::bytes::byte_position; +use crate::conversion::_helpers::expressions::scalar::convert_expression; +use crate::conversion::_helpers::expressions::strings::emitted_text_operand; use crate::conversion::_helpers::support::scope::Emitted; use crate::conversion::_helpers::support::site::Site; use crate::conversion::_helpers::support::tree::{ @@ -14,6 +16,7 @@ use crate::conversion::_helpers::support::tree::{ use crate::conversion::constants::{ POSITION_PAIR, TEXT_SEARCH_POSITION, TEXT_SEARCH_POSITION_TYPE, }; +use sqlquack_snowflake::typing::models::SnowflakeType; /// The name POSITION is called by, which converts as its CHARINDEX spelling. const POSITION_NAME: &str = "POSITION"; @@ -65,9 +68,17 @@ fn position_of( construct: &Construct, site: Site<'_>, ) -> Result { + let needle = convert_expression(needle, site)?; + let haystack = convert_expression(haystack, site)?; + if [&needle, &haystack] + .iter() + .any(|operand| operand.data_type == SnowflakeType::Binary) + { + return byte_position((&needle, &haystack), construct, site); + } let _rule = site.context.log.rule(TEXT_SEARCH_POSITION); - let needle = text_operand(needle, construct, site)?; - let haystack = text_operand(haystack, construct, site)?; + let needle = emitted_text_operand(needle, construct, site)?; + let haystack = emitted_text_operand(haystack, construct, site)?; Ok(Emitted::new( format!("CAST(strpos({haystack}, {needle}) AS DECIMAL(9,0))"), TEXT_SEARCH_POSITION_TYPE, diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index a1482d3a6..d062775e9 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -615,6 +615,20 @@ pub const CAST_TO_TEXT: &str = "rule.cast_to_text"; pub const CAST_BOOLEAN: &str = "rule.cast_boolean"; /// CAST and TRY_CAST of text to NUMBER, FLOAT or BOOLEAN. pub const CAST_PARSED_TEXT: &str = "rule.cast_parsed_text"; +/// LENGTH, LEN and OCTET_LENGTH of BINARY: its count of bytes. +pub const BYTE_LENGTH: &str = "rule.byte_length"; +/// LEFT, RIGHT, SUBSTR and SUBSTRING of BINARY, counted in bytes. +pub const BYTE_SLICE: &str = "rule.byte_slice"; +/// LPAD and RPAD of BINARY with BINARY padding. +pub const BYTE_PAD: &str = "rule.byte_pad"; +/// POSITION and CHARINDEX of BINARY in BINARY, at whole bytes. +pub const BYTE_SEARCH: &str = "rule.byte_search"; +/// CONCAT, `||` and CONCAT_WS of BINARY parts. +pub const BYTE_CONCAT: &str = "rule.byte_concat"; +/// OCTET_LENGTH of VARCHAR: its count of UTF-8 bytes. +pub const TEXT_OCTET_LENGTH: &str = "rule.text_octet_length"; +/// What a byte-wise rule raises for values no BINARY recording reaches. +pub const UNRECORDED_BYTES: &str = "BINARY values outside the recorded conditions"; /// VARCHAR to BINARY under the pinned BINARY_INPUT_FORMAT HEX. pub const CAST_HEX_BINARY: &str = "rule.cast_hex_binary"; /// Casts of string literals. From 29f1095d31f8104ece557d0c560a9f438a1e397c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:36:31 +0000 Subject: [PATCH 07/18] coverage: re-record the translation results baseline for BINARY BINARY as a data type and OCTET_LENGTH move to matching, and the seven new BINARY cast cases match Snowflake (three by raising the same error). ENCRYPT_RAW moves from unmeasured to refused, since its BINARY input column now types and its case refuses on the function. Nothing regresses. --- coverage/translation/results-baseline.json | 49 ++++++++++++---------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 1f2b8cd77..d9e3b88b8 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -9048,11 +9048,17 @@ "translation/functions/cast.cast-as.toml#t003_text": "matching", "translation/functions/cast.cast-as.toml#t004_precision": "matching_error", "translation/functions/cast.cast-as.toml#t005_null_source_expr": "matching", + "translation/functions/cast.cast-as.toml#t006_text_to_binary": "matching", + "translation/functions/cast.cast-as.toml#t007_binary_empty_and_null": "matching", + "translation/functions/cast.cast-as.toml#t008_binary_odd_length": "matching_error", + "translation/functions/cast.cast-as.toml#t009_binary_invalid_digit": "matching_error", + "translation/functions/cast.cast-as.toml#t010_binary_length": "matching_error", "translation/functions/cast.double-colon.toml#t001_scale": "matching", "translation/functions/cast.double-colon.toml#t002_float_to_integer": "matching", "translation/functions/cast.double-colon.toml#t003_text": "matching", "translation/functions/cast.double-colon.toml#t004_precision": "matching_error", "translation/functions/cast.double-colon.toml#t005_null_source_expr": "matching", + "translation/functions/cast.double-colon.toml#t006_text_to_binary": "matching", "translation/functions/cbrt.toml#t001_float": "refused", "translation/functions/cbrt.toml#t002_fixed": "refused", "translation/functions/cbrt.toml#t003_decfloat": "refused", @@ -9068,7 +9074,7 @@ "translation/functions/ceil.toml#t008_negatives": "refused", "translation/functions/charindex.toml#t001_varchar": "matching", "translation/functions/charindex.toml#t002_start_pos": "refused", - "translation/functions/charindex.toml#t003_binary": "refused", + "translation/functions/charindex.toml#t003_binary": "matching", "translation/functions/charindex.toml#t004_null_expr1": "matching", "translation/functions/charindex.toml#t005_null_expr2": "matching", "translation/functions/charindex.toml#t006_empty_needle": "matching", @@ -9114,16 +9120,16 @@ "translation/functions/compress.toml#t007_unknown_method": "refused", "translation/functions/concat-ws.toml#t001_varchar": "matching", "translation/functions/concat-ws.toml#t002_single": "matching", - "translation/functions/concat-ws.toml#t003_binary": "refused", + "translation/functions/concat-ws.toml#t003_binary": "matching", "translation/functions/concat-ws.toml#t004_null_separator": "matching", "translation/functions/concat-ws.toml#t005_null_expression": "matching", "translation/functions/concat-ws.toml#t006_empty": "matching", "translation/functions/concat.concat-subscript-dot.toml#t001_varchar": "matching", - "translation/functions/concat.concat-subscript-dot.toml#t002_binary": "refused", + "translation/functions/concat.concat-subscript-dot.toml#t002_binary": "matching", "translation/functions/concat.concat-subscript-dot.toml#t003_null_expr": "matching", "translation/functions/concat.concat-subscript-dot.toml#t004_empty": "matching", "translation/functions/concat.concat.toml#t001_varchar": "matching", - "translation/functions/concat.concat.toml#t002_binary": "refused", + "translation/functions/concat.concat.toml#t002_binary": "matching", "translation/functions/concat.concat.toml#t003_null_expr": "matching", "translation/functions/concat.concat.toml#t004_empty": "matching", "translation/functions/concat.concat.toml#t005_single": "matching", @@ -9836,16 +9842,16 @@ "translation/functions/least.toml#t004_date": "matching", "translation/functions/least.toml#t005_null_expr": "matching", "translation/functions/left.toml#t001_varchar": "matching", - "translation/functions/left.toml#t002_binary": "refused", + "translation/functions/left.toml#t002_binary": "matching", "translation/functions/left.toml#t003_null_string_expr": "matching", "translation/functions/left.toml#t004_null_length_expr": "matching", "translation/functions/left.toml#t005_negative": "matching", "translation/functions/length.len.toml#t001_varchar": "matching", - "translation/functions/length.len.toml#t002_binary": "refused", + "translation/functions/length.len.toml#t002_binary": "matching", "translation/functions/length.len.toml#t003_null_expression": "matching", "translation/functions/length.len.toml#t004_empty": "matching", "translation/functions/length.length.toml#t001_varchar": "matching", - "translation/functions/length.length.toml#t002_binary": "refused", + "translation/functions/length.length.toml#t002_binary": "matching", "translation/functions/length.length.toml#t003_null_expression": "matching", "translation/functions/length.length.toml#t004_empty": "matching", "translation/functions/like-all.toml#t001_patterns": "refused", @@ -9922,7 +9928,7 @@ "translation/functions/lpad.toml#t001_varchar": "matching", "translation/functions/lpad.toml#t002_truncate": "matching", "translation/functions/lpad.toml#t003_default_pad": "matching", - "translation/functions/lpad.toml#t004_binary": "refused", + "translation/functions/lpad.toml#t004_binary": "matching", "translation/functions/lpad.toml#t005_null_base": "matching", "translation/functions/lpad.toml#t006_null_length_expr": "matching", "translation/functions/lpad.toml#t007_null_pad": "matching", @@ -10129,10 +10135,10 @@ "translation/functions/object-pick.toml#t001_keys": "refused", "translation/functions/object-pick.toml#t002_array": "refused", "translation/functions/object-pick.toml#t003_null_object": "refused", - "translation/functions/octet-length.toml#t001_varchar": "refused", - "translation/functions/octet-length.toml#t002_binary": "refused", - "translation/functions/octet-length.toml#t003_null_string_or_binary": "refused", - "translation/functions/octet-length.toml#t004_empty": "refused", + "translation/functions/octet-length.toml#t001_varchar": "matching", + "translation/functions/octet-length.toml#t002_binary": "matching", + "translation/functions/octet-length.toml#t003_null_string_or_binary": "matching", + "translation/functions/octet-length.toml#t004_empty": "matching", "translation/functions/parse-ip.toml#t001_inet": "refused", "translation/functions/parse-ip.toml#t002_cidr": "refused", "translation/functions/parse-ip.toml#t003_permissive": "refused", @@ -10216,13 +10222,13 @@ "translation/functions/period-succeeds.toml#t002_null_period1": "refused", "translation/functions/pi.toml#t001_value": "refused", "translation/functions/position.position-in.toml#t001_varchar": "matching", - "translation/functions/position.position-in.toml#t002_binary": "refused", + "translation/functions/position.position-in.toml#t002_binary": "matching", "translation/functions/position.position-in.toml#t003_null_expr1": "matching", "translation/functions/position.position-in.toml#t004_null_expr2": "matching", "translation/functions/position.position-in.toml#t005_empty_needle": "matching", "translation/functions/position.position.toml#t001_varchar": "matching", "translation/functions/position.position.toml#t002_start_pos": "refused", - "translation/functions/position.position.toml#t003_binary": "refused", + "translation/functions/position.position.toml#t003_binary": "matching", "translation/functions/position.position.toml#t004_null_expr1": "matching", "translation/functions/position.position.toml#t005_null_expr2": "matching", "translation/functions/position.position.toml#t006_empty_needle": "matching", @@ -10465,7 +10471,7 @@ "translation/functions/reverse.toml#t003_null_subject": "refused", "translation/functions/reverse.toml#t004_empty": "refused", "translation/functions/right.toml#t001_varchar": "matching", - "translation/functions/right.toml#t002_binary": "refused", + "translation/functions/right.toml#t002_binary": "matching", "translation/functions/right.toml#t003_null_string_expr": "matching", "translation/functions/right.toml#t004_null_length_expr": "matching", "translation/functions/right.toml#t005_negative": "matching", @@ -10517,7 +10523,7 @@ "translation/functions/rpad.toml#t001_varchar": "matching", "translation/functions/rpad.toml#t002_truncate": "matching", "translation/functions/rpad.toml#t003_default_pad": "matching", - "translation/functions/rpad.toml#t004_binary": "refused", + "translation/functions/rpad.toml#t004_binary": "matching", "translation/functions/rpad.toml#t005_null_base": "matching", "translation/functions/rpad.toml#t006_null_length_expr": "matching", "translation/functions/rpad.toml#t007_null_pad": "matching", @@ -10997,7 +11003,7 @@ "translation/functions/strtok.toml#t007_out_of_range": "refused", "translation/functions/substr.substr.toml#t001_varchar": "matching", "translation/functions/substr.substr.toml#t002_no_length": "matching", - "translation/functions/substr.substr.toml#t003_binary": "refused", + "translation/functions/substr.substr.toml#t003_binary": "matching", "translation/functions/substr.substr.toml#t004_start_zero": "matching", "translation/functions/substr.substr.toml#t005_null_base_expr": "matching", "translation/functions/substr.substr.toml#t006_null_start_expr": "matching", @@ -11006,7 +11012,7 @@ "translation/functions/substr.substr.toml#t009_past_end": "matching", "translation/functions/substr.substring.toml#t001_varchar": "matching", "translation/functions/substr.substring.toml#t002_no_length": "matching", - "translation/functions/substr.substring.toml#t003_binary": "refused", + "translation/functions/substr.substring.toml#t003_binary": "matching", "translation/functions/substr.substring.toml#t004_start_zero": "matching", "translation/functions/substr.substring.toml#t005_null_base_expr": "matching", "translation/functions/substr.substring.toml#t006_null_start_expr": "matching", @@ -11340,6 +11346,7 @@ "translation/functions/try-cast.toml#t003_precision": "matching", "translation/functions/try-cast.toml#t004_number_input": "refused", "translation/functions/try-cast.toml#t005_null_source_string_expr": "matching", + "translation/functions/try-cast.toml#t006_text_to_binary": "matching", "translation/functions/try-decrypt-raw.toml#t001_round_trip": "refused", "translation/functions/try-decrypt-raw.toml#t002_wrong_aad": "refused", "translation/functions/try-decrypt-raw.toml#t003_null_value_to_decrypt": "refused", @@ -12952,7 +12959,7 @@ "sf.expressions.data-types-structured.array": "unmeasured", "sf.expressions.data-types-structured.map": "unmeasured", "sf.expressions.data-types-structured.object": "unmeasured", - "sf.expressions.data-types-text.binary": "unmeasured", + "sf.expressions.data-types-text.binary": "matching", "sf.expressions.data-types-text.char": "unmeasured", "sf.expressions.data-types-text.dollar-quoted-string-constant": "matching", "sf.expressions.data-types-text.single-quoted-string-constant": "refused", @@ -13467,7 +13474,7 @@ "sf.functions.embed-text-1024-snowflake-cortex": "not_translatable", "sf.functions.embed-text-snowflake-cortex": "not_translatable", "sf.functions.encrypt": "refused", - "sf.functions.encrypt-raw": "unmeasured", + "sf.functions.encrypt-raw": "refused", "sf.functions.endswith": "matching", "sf.functions.entity-sentiment-snowflake-cortex": "not_translatable", "sf.functions.equal-null": "refused", @@ -13757,7 +13764,7 @@ "sf.functions.object-insert": "refused", "sf.functions.object-keys": "refused", "sf.functions.object-pick": "refused", - "sf.functions.octet-length": "refused", + "sf.functions.octet-length": "matching", "sf.functions.online-feature-table-refresh-history": "not_translatable", "sf.functions.parse-document-snowflake-cortex": "not_translatable", "sf.functions.parse-ip": "refused", From 13a2797c7c54a091efee1792e13019e3ee3b54bc Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:42:11 +0000 Subject: [PATCH 08/18] Declare obligations for the VARCHAR to BINARY cast cases CAST, :: and TRY_CAST to BINARY under hex input: case-insensitive hex, empty text and NULL, odd-length and non-hex text, and a value longer than the declared BINARY length. Where the pages are silent (odd-length hex, BINARY as a TRY_CAST target) the obligation says the receipt decides. --- .../translation/obligations/functions.toml | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/coverage/translation/obligations/functions.toml b/coverage/translation/obligations/functions.toml index e622cc5e4..1a49a4cca 100644 --- a/coverage/translation/obligations/functions.toml +++ b/coverage/translation/obligations/functions.toml @@ -5692,6 +5692,62 @@ reading = "the page: converts the expression to the target type; a scale too sma cases = ["cases/translation/functions/cast.cast-as.toml#t005_null_source_expr"] required = true +[[obligation]] +id = "sf.functions.cast.cast-as.t006" +form = "sf.functions.cast.cast-as" +kind = "type_combination" +target = "VARCHAR to BINARY" +note = "Hex text in upper, lower and mixed case cast to BINARY under BINARY_INPUT_FORMAT HEX." +catches = "the text's UTF-8 bytes taken instead of its hex digits, or the hex read case-sensitively" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.cast-as.toml#t006_text_to_binary"] +required = true + +[[obligation]] +id = "sf.functions.cast.cast-as.t007" +form = "sf.functions.cast.cast-as" +kind = "boundary" +subkind = "empty_string" +target = "empty_string" +note = "Empty text and NULL cast to BINARY." +catches = "empty text read as NULL, or NULL as an empty value" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.cast-as.toml#t007_binary_empty_and_null"] +required = true + +[[obligation]] +id = "sf.functions.cast.cast-as.t008" +form = "sf.functions.cast.cast-as" +kind = "error_parity" +target = "error" +note = "Hex text of odd length cast to BINARY. No page says what an odd digit count does; each byte being two digits, the receipt shows Snowflake raises (100115)." +catches = "a padded or truncated byte returned instead of the error" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.cast-as.toml#t008_binary_odd_length"] +required = true + +[[obligation]] +id = "sf.functions.cast.cast-as.t009" +form = "sf.functions.cast.cast-as" +kind = "error_parity" +target = "error" +note = "Text with a non-hex character cast to BINARY, which Snowflake raises (100115)." +catches = "the character skipped or read as 0" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.cast-as.toml#t009_binary_invalid_digit"] +required = true + +[[obligation]] +id = "sf.functions.cast.cast-as.t010" +form = "sf.functions.cast.cast-as" +kind = "error_parity" +target = "error" +note = "Two- and three-byte hex text cast to BINARY(2); the three-byte value raises (100145) and fails the query." +catches = "the excess bytes silently truncated, or the declared length ignored" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the text data types page: BINARY(N) declares the maximum number of bytes; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.cast-as.toml#t010_binary_length"] +required = true + [[obligation]] id = "sf.functions.cast.double-colon.t001" form = "sf.functions.cast.double-colon" @@ -5748,6 +5804,17 @@ reading = "the page: converts the expression to the target type; a scale too sma cases = ["cases/translation/functions/cast.double-colon.toml#t005_null_source_expr"] required = true +[[obligation]] +id = "sf.functions.cast.double-colon.t006" +form = "sf.functions.cast.double-colon" +kind = "type_combination" +target = "VARCHAR to BINARY" +note = "Hex text cast to BINARY with ::, beside NULL." +catches = ":: read differently from CAST, or the NULL not kept" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/cast.double-colon.toml#t006_text_to_binary"] +required = true + [[obligation]] id = "sf.functions.cbrt.t001" form = "sf.functions.cbrt" @@ -30934,6 +31001,17 @@ reading = "the page: like CAST, but NULL where the conversion fails; only string cases = ["cases/translation/functions/try-cast.toml#t005_null_source_string_expr"] required = true +[[obligation]] +id = "sf.functions.try-cast.t006" +form = "sf.functions.try-cast" +kind = "type_combination" +target = "VARCHAR to BINARY" +note = "Valid hex, odd-length hex, a non-hex character and NULL under TRY_CAST to BINARY. The page does not list BINARY among the targets; the receipt shows Snowflake accepts it and returns NULL for the two invalid values." +catches = "an error raised instead of NULL, or a partial value" +reading = "the page: like CAST, but NULL where the conversion fails; only string expressions are accepted, to VARCHAR, NUMBER, DOUBLE, BOOLEAN, DATE, TIME or a timestamp type; the binary input and output page: under BINARY_INPUT_FORMAT HEX (the default) each byte is two hex digits, 0 to 9 and A to F, and text to binary is case-insensitive" +cases = ["cases/translation/functions/try-cast.toml#t006_text_to_binary"] +required = true + [[obligation]] id = "sf.functions.try-decrypt.t001" form = "sf.functions.try-decrypt" From d45bb0e5c7f2a2e4fc23f343a63773abc3a06a17 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:53:10 +0000 Subject: [PATCH 09/18] Refuse BINARY to BINARY(n) and out-of-range BINARY lengths (#129 review round 1) A value already BINARY cast to BINARY(n) fell through to the same-type cast and lost the length check Snowflake applies (100145). No recording shows that path, so it now refuses; hex text to BINARY(n) keeps its recorded check. BINARY(0) and lengths over 67108864 refuse as targets. The odd-length case description no longer cites a page that says nothing about odd digit counts. --- cases/translation/functions/cast.cast-as.toml | 2 +- .../src/conversion/_helpers/expressions/casts.rs | 3 +++ .../src/typing/_helpers/targets.rs | 7 +++++-- crates/sqlquack-snowflake/src/typing/constants.rs | 2 ++ .../sqlquack/src/analysis/tests/test_analyze.rs | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cases/translation/functions/cast.cast-as.toml b/cases/translation/functions/cast.cast-as.toml index 2b539d6ac..e0ae23ac2 100644 --- a/cases/translation/functions/cast.cast-as.toml +++ b/cases/translation/functions/cast.cast-as.toml @@ -88,7 +88,7 @@ tags = ["boundary"] [[case]] id = "t008_binary_odd_length" -description = "Hex text of odd length cast to BINARY, which the page says is an error. Catches: a padded or truncated byte" +description = "Hex text of odd length cast to BINARY; no page says what an odd digit count does, so the receipt decides. Catches: a padded or truncated byte" columns = [{ name = "v", type = "VARCHAR" }] rows = [ ["'ABC'"], diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs index deac0b68f..69a6cc420 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs @@ -496,6 +496,9 @@ fn strict_cast( let _rule = cast_rule(CAST_TO_TEXT, log); Ok(bounded(cast(&operand.sql, to, log)?, target, log)) } + (SnowflakeType::Binary, SnowflakeType::Binary) if target.max_length.is_some() => { + Err("BINARY to BINARY(n), whose length check no recording shows".to_owned()) + } _ if from == to => { let _rule = cast_rule(CAST_SAME_TYPE, log); cast(&operand.sql, to, log) diff --git a/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs b/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs index 3f2f87e2f..0276c5909 100644 --- a/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs +++ b/crates/sqlquack-snowflake/src/typing/_helpers/targets.rs @@ -1,7 +1,7 @@ //! Parsing the arguments of a Snowflake type name. use crate::typing::_helpers::numbers::number; -use crate::typing::constants::{DEFAULT_FRACTIONAL_PRECISION, MAX_PRECISION}; +use crate::typing::constants::{DEFAULT_FRACTIONAL_PRECISION, MAX_BINARY_LENGTH, MAX_PRECISION}; use crate::typing::models::{CastTarget, SnowflakeType}; pub(crate) fn parse_arguments(rest: &str, name: &str) -> Result, String> { @@ -57,10 +57,13 @@ pub(crate) fn binary_target(arguments: &[u32], name: &str) -> Result Ok(CastTarget { + [length] if (1..=MAX_BINARY_LENGTH).contains(length) => Ok(CastTarget { data_type: SnowflakeType::Binary, max_length: Some(*length), }), + [length] => Err(format!( + "`{name}` declares {length} bytes, outside 1 to {MAX_BINARY_LENGTH}" + )), _ => Err(format!("too many arguments in `{name}`")), } } diff --git a/crates/sqlquack-snowflake/src/typing/constants.rs b/crates/sqlquack-snowflake/src/typing/constants.rs index 15edbcde8..6a3bcbcd3 100644 --- a/crates/sqlquack-snowflake/src/typing/constants.rs +++ b/crates/sqlquack-snowflake/src/typing/constants.rs @@ -18,6 +18,8 @@ pub const AVERAGE_EXTRA_DIGITS: u8 = 12; pub const COUNT_PRECISION: u8 = 18; /// Precision of timestamps and times without a declared precision. pub const DEFAULT_FRACTIONAL_PRECISION: u8 = 9; +/// The most bytes BINARY(n) declares, as recorded for BINARY results: BINARY(67108864). +pub const MAX_BINARY_LENGTH: u32 = 67_108_864; /// `NUMBER(38,0)`, which integer type names denote. pub const NUMBER_38_0: SnowflakeType = SnowflakeType::Number { diff --git a/crates/sqlquack/src/analysis/tests/test_analyze.rs b/crates/sqlquack/src/analysis/tests/test_analyze.rs index 84eccf154..58d356067 100644 --- a/crates/sqlquack/src/analysis/tests/test_analyze.rs +++ b/crates/sqlquack/src/analysis/tests/test_analyze.rs @@ -36,6 +36,21 @@ fn given_sql_and_a_schema_when_analyzing_then_it_converts_or_refuses_with_a_cate sql: "SELECT l.amount FROM TABLE(large_orders(CAST(10 AS NUMBER(10,2)))) AS l", expected_summary: converted(), }, + AnalyzeTestCase { + description: "hex text cast to BINARY(n) converts, raising for longer bytes as recorded", + sql: "SELECT CAST('AABB' AS BINARY(2)) AS b", + expected_summary: converted(), + }, + AnalyzeTestCase { + description: "BINARY cast to BINARY(n) is refused, since no recording shows its length check", + sql: "SELECT CAST(CAST('AABBCC' AS BINARY) AS BINARY(2)) AS b", + expected_summary: refused(RefusalCategory::TypeCondition), + }, + AnalyzeTestCase { + description: "BINARY(0) is refused as a length Snowflake does not declare", + sql: "SELECT CAST('' AS BINARY(0)) AS b", + expected_summary: refused(RefusalCategory::TypeCondition), + }, AnalyzeTestCase { description: "an ORDER BY expression naming an alias that shadows a column uses the alias", sql: "SELECT amount * 2 AS amount FROM orders ORDER BY amount + 1", From 7928dad0de6dbc8c7ced3806e527c55719440ecc Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:14:32 +0000 Subject: [PATCH 10/18] Check the length of a BINARY value cast to BINARY(n) (#129 review round 1) A value already BINARY cast to BINARY(n) now goes through the same length check as hex text, raising 100145 for more bytes than the target holds instead of returning them all. TRY_CAST of BINARY to BINARY(n) stays refused. The same-type cast rule declares the BINARY failure. --- .../sqlquack-rules/src/catalogue/constants.rs | 6 +++--- .../conversion/_helpers/expressions/casts.rs | 14 +++++-------- .../src/analysis/tests/test_analyze.rs | 5 ----- .../src/analysis/tests/test_coverage_batch.rs | 20 +++++++++++++++++++ 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 821603321..2f04350aa 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -460,7 +460,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: CAST_HEX_BINARY, constructs: &["node:cast", "node:try_cast"], - conditions: "VARCHAR to BINARY or BINARY(n) under the session profile's pinned BINARY_INPUT_FORMAT HEX: whole pairs of hexadecimal digits of either case, as recorded; empty text gives empty bytes and NULL gives NULL. An odd count of digits or any other character fails with error 100115, or is NULL under TRY_CAST. More bytes than BINARY(n) holds fail the query with error 100145 rather than being truncated, as recorded; TRY_CAST to BINARY(n) is refused, since no recording shows it.", + conditions: "VARCHAR to BINARY or BINARY(n) under the session profile's pinned BINARY_INPUT_FORMAT HEX: whole pairs of hexadecimal digits of either case, as recorded; empty text gives empty bytes and NULL gives NULL. An odd count of digits or any other character fails with error 100115, or is NULL under TRY_CAST. More bytes than BINARY(n) holds fail the query with error 100145 rather than being truncated, as recorded for text; a value already BINARY cast to BINARY(n) is checked the same way. TRY_CAST to BINARY(n) is refused, since no recording shows it.", failures: &[ FailureMode { kind: ErrorKind::InvalidArgument, @@ -1212,10 +1212,10 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: CAST_SAME_TYPE, constructs: &["node:cast", "node:try_cast"], - conditions: "The operand already has the target type, or is NULL. TRY_CAST of a NUMBER to its own NUMBER type is the value, as recorded; to another NUMBER type Snowflake rejects it (error 1065), which refuses.", + conditions: "The operand already has the target type, or is NULL. TRY_CAST of a NUMBER to its own NUMBER type is the value, as recorded; to another NUMBER type Snowflake rejects it (error 1065), which refuses. A BINARY value cast to a shorter BINARY(n) fails with error 100145, as text does with 100078.", failures: &[FailureMode { kind: ErrorKind::StringTooLong, - snowflake: &[STRING_TOO_LONG], + snowflake: &[STRING_TOO_LONG, BINARY_TOO_LONG], }], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs index 69a6cc420..3807ff0b2 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/casts.rs @@ -471,12 +471,11 @@ fn nanosecond_range( } } -/// Text cut to a VARCHAR(n) target's length raises an error, as in Snowflake. +/// Text or bytes longer than a VARCHAR(n) or BINARY(n) target raise an error, as in Snowflake. fn bounded(sql: String, target: CastTarget, log: &ProvenanceLog) -> String { - match target.max_length { - Some(max_length) if target.data_type == SnowflakeType::Varchar => { - varchar_length_check(&sql, max_length, log) - } + match (target.max_length, target.data_type) { + (Some(max_length), SnowflakeType::Varchar) => varchar_length_check(&sql, max_length, log), + (Some(max_length), SnowflakeType::Binary) => binary_length_check(&sql, max_length, log), _ => sql, } } @@ -496,12 +495,9 @@ fn strict_cast( let _rule = cast_rule(CAST_TO_TEXT, log); Ok(bounded(cast(&operand.sql, to, log)?, target, log)) } - (SnowflakeType::Binary, SnowflakeType::Binary) if target.max_length.is_some() => { - Err("BINARY to BINARY(n), whose length check no recording shows".to_owned()) - } _ if from == to => { let _rule = cast_rule(CAST_SAME_TYPE, log); - cast(&operand.sql, to, log) + Ok(bounded(cast(&operand.sql, to, log)?, target, log)) } (SnowflakeType::Number { .. }, SnowflakeType::Number { .. } | SnowflakeType::Float) | (SnowflakeType::Float, SnowflakeType::Number { scale: 0, .. }) => { diff --git a/crates/sqlquack/src/analysis/tests/test_analyze.rs b/crates/sqlquack/src/analysis/tests/test_analyze.rs index 58d356067..7bd7fc2ac 100644 --- a/crates/sqlquack/src/analysis/tests/test_analyze.rs +++ b/crates/sqlquack/src/analysis/tests/test_analyze.rs @@ -41,11 +41,6 @@ fn given_sql_and_a_schema_when_analyzing_then_it_converts_or_refuses_with_a_cate sql: "SELECT CAST('AABB' AS BINARY(2)) AS b", expected_summary: converted(), }, - AnalyzeTestCase { - description: "BINARY cast to BINARY(n) is refused, since no recording shows its length check", - sql: "SELECT CAST(CAST('AABBCC' AS BINARY) AS BINARY(2)) AS b", - expected_summary: refused(RefusalCategory::TypeCondition), - }, AnalyzeTestCase { description: "BINARY(0) is refused as a length Snowflake does not declare", sql: "SELECT CAST('' AS BINARY(0)) AS b", diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index 6e187592b..e4f47f3b3 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -77,6 +77,26 @@ fn given_computed_subscripts_when_converting_then_they_read_as_get() { sql: "SELECT PARSE_JSON('{\"1\":\"x\"}')[note]::VARCHAR AS v FROM customers", expected_outcome: Ok("x"), }, + FirstValueTestCase { + description: "BINARY cast to a BINARY(n) it fits keeps its bytes", + sql: "SELECT OCTET_LENGTH(CAST(CAST('AABB' AS BINARY) AS BINARY(2))) AS v", + expected_outcome: Ok("2"), + }, + FirstValueTestCase { + description: "BINARY cast to a shorter BINARY(n) raises 100145 rather than truncating", + sql: "SELECT OCTET_LENGTH(CAST(CAST('AABBCC' AS BINARY) AS BINARY(2))) AS v", + expected_outcome: Err("too long and would be truncated"), + }, + FirstValueTestCase { + description: "BINARY with :: to a shorter BINARY(n) raises too", + sql: "SELECT OCTET_LENGTH(CAST('AABBCC' AS BINARY)::BINARY(1)) AS v", + expected_outcome: Err("too long and would be truncated"), + }, + FirstValueTestCase { + description: "TRY_CAST of BINARY to BINARY(n) stays refused without a recording", + sql: "SELECT OCTET_LENGTH(TRY_CAST(CAST('AABBCC' AS BINARY) AS BINARY(2))) AS v", + expected_outcome: Err("has no proven conversion"), + }, FirstValueTestCase { description: "a negative constant index stays refused", sql: "SELECT PARSE_JSON('[1]')[-1] AS v", From 57820dead52b10726c82b7d7d6f6937f645399db Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:14:53 +0000 Subject: [PATCH 11/18] cases: BINARY values cast to BINARY(n), fitting and too long Records whether a value already BINARY raises 100145 when cast to a shorter BINARY(n), as hex text does, before #129 relies on it. --- cases/translation/functions/cast.cast-as.toml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cases/translation/functions/cast.cast-as.toml b/cases/translation/functions/cast.cast-as.toml index e0ae23ac2..1123fc5c6 100644 --- a/cases/translation/functions/cast.cast-as.toml +++ b/cases/translation/functions/cast.cast-as.toml @@ -119,3 +119,26 @@ rows = [ query = "SELECT CAST(v AS BINARY(2)) AS r, LENGTH(CAST(v AS BINARY(2))) AS l FROM input ORDER BY ordinal" order = "ordered" tags = ["error"] + +[[case]] +id = "t011_binary_to_binary_fits" +description = "A BINARY value cast to a BINARY(n) it fits, with its length. Catches: the bytes changed or re-read as hex text" +columns = [{ name = "b", type = "BINARY" }] +rows = [ + ["'AABB'"], + ["NULL"], +] +query = "SELECT CAST(b AS BINARY(2)) AS r, LENGTH(CAST(b AS BINARY(2))) AS l FROM input ORDER BY ordinal" +order = "ordered" +tags = ["boundary"] + +[[case]] +id = "t012_binary_to_shorter_binary" +description = "A three-byte BINARY value cast to BINARY(2); the cast of hex text raises 100145 here, and this records whether a BINARY value does too. Catches: the excess byte silently truncated" +columns = [{ name = "b", type = "BINARY" }] +rows = [ + ["'AABBCC'"], +] +query = "SELECT CAST(b AS BINARY(2)) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["error"] From 5c904d290425fbe7bd6062aec1086300ef9b5ac6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:53:43 +0000 Subject: [PATCH 12/18] evidence: record BINARY values cast to BINARY(n) Snowflake results for cast.cast-as t011 and t012 at 57820dea; a value longer than n raises 100145/22000 rather than truncating. --- ...e09a251682ed52a4a0b8e4908f0968882437f.json | 55 ++++++++++++++ ...1d2eb7402b4645ddf70c7358b5f58446a132.arrow | Bin 0 -> 1682 bytes ...81d2eb7402b4645ddf70c7358b5f58446a132.json | 70 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 evidence/results/14fad7b933237055472cc650853e09a251682ed52a4a0b8e4908f0968882437f.json create mode 100644 evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.arrow create mode 100644 evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.json diff --git a/evidence/results/14fad7b933237055472cc650853e09a251682ed52a4a0b8e4908f0968882437f.json b/evidence/results/14fad7b933237055472cc650853e09a251682ed52a4a0b8e4908f0968882437f.json new file mode 100644 index 000000000..c9d1437ea --- /dev/null +++ b/evidence/results/14fad7b933237055472cc650853e09a251682ed52a4a0b8e4908f0968882437f.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "14fad7b933237055472cc650853e09a251682ed52a4a0b8e4908f0968882437f", + "columns": [], + "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": { + "errno": 100145, + "message": "Binary value 'AABBCC' is too long and would be truncated", + "sqlstate": "22000" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "9fd392f850a14987982d6985734f262aea76525d2b6792fde553f96378b6f445", + "recorded_at": "2026-11-06T18:51:05.161808+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "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/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.arrow b/evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.arrow new file mode 100644 index 0000000000000000000000000000000000000000..15fcb998ee3732e350f425f02f0f158b3871f40d GIT binary patch literal 1682 zcmeHIF;Bu!5WZrm6&uqSVi+A992`U$9L1=Sgs5pq#1RQXlLiQ|knlqs9GIQlb#P=P z&i)qb_x1I~reYjj_>wF4?tORf-p3Wno_BRq6bXT8M#L2&CyrQ>6&oI4m{3`RuWKf@5jOdft7F)~#xMB!Iq7HkHa!X~g9Y!AkK7y0DVC!c$3uzSDT2x{Yg z3w|lZIYNy6-Hk`BYOC8GbwuhZ&VI~kb{YfCnJnOB!5cOk0nd3%?DLLA9K>GwgI3cY z`n@jvH*k1Y_RHM!O8x8e`kKXt5gUfi2@050OnNAYvg-U0ul|LKr4% z78dB|Ax{0~uqiC9-!;C!zw{fWI0vYc*6-mLPEx#g{Dwk#N_NR^}*wh-OL(UL$2uHS{3n$fyw3HPS+KO;C6mnRrvv}74MxEG~27A}w z>=EpX;P*qYCZY4Tqm`Ofy1Vh3wKd4qd|fxa)^X+}`A;vZir&|GDynp#&+{CpVMPkK oalfXN5Z{4OGuC*H3(VmY-})@y>*af`emnp3y`Et{%h7+)7xB{KQ~&?~ literal 0 HcmV?d00001 diff --git a/evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.json b/evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.json new file mode 100644 index 000000000..c6e4f1cb8 --- /dev/null +++ b/evidence/results/f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "f0973b6991b5d2505425752e83b110fa3c535bb1172677afd1eb477cd52f000f", + "case_hash": "f235c5b2398f85bfff572de26c581d2eb7402b4645ddf70c7358b5f58446a132", + "columns": [ + { + "internal_size": 2, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 11, + "type_name": "BINARY" + }, + { + "internal_size": null, + "name": "L", + "nullable": true, + "precision": 18, + "scale": 0, + "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": "1bac77d37cebe6cc9dc8ef54277d906b1e0b215fce7ee305a2de2376ee53b45a", + "recorded_at": "2026-11-06T18:51:05.161808+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "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 6a58b71e14d4fcc4794dd481e67bf6a28e36698f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:56:42 +0000 Subject: [PATCH 13/18] Declare obligations for BINARY values cast to BINARY(n) A BINARY value that fits BINARY(2), with its length and NULL, and one that is a byte too long, which raises as the cast of hex text does. --- .../translation/obligations/functions.toml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/coverage/translation/obligations/functions.toml b/coverage/translation/obligations/functions.toml index 1a49a4cca..8e38fbe50 100644 --- a/coverage/translation/obligations/functions.toml +++ b/coverage/translation/obligations/functions.toml @@ -5748,6 +5748,28 @@ reading = "the page: converts the expression to the target type; a scale too sma cases = ["cases/translation/functions/cast.cast-as.toml#t010_binary_length"] required = true +[[obligation]] +id = "sf.functions.cast.cast-as.t011" +form = "sf.functions.cast.cast-as" +kind = "type_combination" +target = "BINARY to BINARY(n)" +note = "A two-byte BINARY value and NULL cast to BINARY(2), which it exactly fills, with the length." +catches = "the bytes changed or re-read as hex text, the length not 2, or the NULL lost" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the text data types page: BINARY(N) declares the maximum number of bytes" +cases = ["cases/translation/functions/cast.cast-as.toml#t011_binary_to_binary_fits"] +required = true + +[[obligation]] +id = "sf.functions.cast.cast-as.t012" +form = "sf.functions.cast.cast-as" +kind = "error_parity" +target = "error" +note = "A three-byte BINARY value cast to BINARY(2); the receipt shows Snowflake raises 100145 (22000), as it does for hex text (t010)." +catches = "the excess byte silently truncated instead of the error" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the text data types page: BINARY(N) declares the maximum number of bytes" +cases = ["cases/translation/functions/cast.cast-as.toml#t012_binary_to_shorter_binary"] +required = true + [[obligation]] id = "sf.functions.cast.double-colon.t001" form = "sf.functions.cast.double-colon" From e0fec44316aab51c8b861671cf8148ff8b6d775d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:00:12 +0000 Subject: [PATCH 14/18] cases: say that a BINARY value cast to a shorter BINARY raises --- cases/translation/functions/cast.cast-as.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cases/translation/functions/cast.cast-as.toml b/cases/translation/functions/cast.cast-as.toml index 1123fc5c6..b21db451d 100644 --- a/cases/translation/functions/cast.cast-as.toml +++ b/cases/translation/functions/cast.cast-as.toml @@ -134,7 +134,7 @@ tags = ["boundary"] [[case]] id = "t012_binary_to_shorter_binary" -description = "A three-byte BINARY value cast to BINARY(2); the cast of hex text raises 100145 here, and this records whether a BINARY value does too. Catches: the excess byte silently truncated" +description = "A three-byte BINARY value cast to BINARY(2); a BINARY value raises 100145 here, exactly as the cast of hex text does (t010). Catches: the excess byte silently truncated" columns = [{ name = "b", type = "BINARY" }] rows = [ ["'AABBCC'"], From 5b4dead93b882f691d7c393c698e6d1155195c1a Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:54:49 +0000 Subject: [PATCH 15/18] cases: BINARY values cast with :: to BINARY(n), fitting and too long --- .../functions/cast.double-colon.toml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cases/translation/functions/cast.double-colon.toml b/cases/translation/functions/cast.double-colon.toml index ad88008cc..daf03a96a 100644 --- a/cases/translation/functions/cast.double-colon.toml +++ b/cases/translation/functions/cast.double-colon.toml @@ -72,3 +72,26 @@ rows = [ query = "SELECT v::BINARY AS r FROM input ORDER BY ordinal" order = "ordered" tags = ["type-combination"] + +[[case]] +id = "t007_binary_to_binary_fits" +description = "A BINARY value cast with :: to a BINARY(n) it fits, with its length, beside NULL. Catches: :: read differently from CAST, or the bytes re-read as hex text" +columns = [{ name = "b", type = "BINARY" }] +rows = [ + ["'AABB'"], + ["NULL"], +] +query = "SELECT b::BINARY(2) AS r, LENGTH(b::BINARY(2)) AS l FROM input ORDER BY ordinal" +order = "ordered" +tags = ["boundary"] + +[[case]] +id = "t008_binary_to_shorter_binary" +description = "A three-byte BINARY value cast with :: to BINARY(2); this records whether :: raises 100145 as CAST does (cast-as t012). Catches: the excess byte silently truncated" +columns = [{ name = "b", type = "BINARY" }] +rows = [ + ["'AABBCC'"], +] +query = "SELECT b::BINARY(2) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["error"] From bc5361088b354c18e6c0987eb9d32fbb52b75b50 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:10:12 +0000 Subject: [PATCH 16/18] evidence: record BINARY values cast with :: to BINARY(n) Snowflake results for cast.double-colon t007 and t008 at 5b4dead9; as with CAST, a value longer than n raises 100145/22000 rather than truncating. --- ...1e7437cb31f7fe5c26ac348b91292585b1d7.arrow | Bin 0 -> 1682 bytes ...31e7437cb31f7fe5c26ac348b91292585b1d7.json | 70 ++++++++++++++++++ ...3661ded5e33f4292d0ae9b8fdbb96639afb8c.json | 55 ++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.arrow create mode 100644 evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.json create mode 100644 evidence/results/1bc7db6a2375637aec95474a4683661ded5e33f4292d0ae9b8fdbb96639afb8c.json diff --git a/evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.arrow b/evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.arrow new file mode 100644 index 0000000000000000000000000000000000000000..15fcb998ee3732e350f425f02f0f158b3871f40d GIT binary patch literal 1682 zcmeHIF;Bu!5WZrm6&uqSVi+A992`U$9L1=Sgs5pq#1RQXlLiQ|knlqs9GIQlb#P=P z&i)qb_x1I~reYjj_>wF4?tORf-p3Wno_BRq6bXT8M#L2&CyrQ>6&oI4m{3`RuWKf@5jOdft7F)~#xMB!Iq7HkHa!X~g9Y!AkK7y0DVC!c$3uzSDT2x{Yg z3w|lZIYNy6-Hk`BYOC8GbwuhZ&VI~kb{YfCnJnOB!5cOk0nd3%?DLLA9K>GwgI3cY z`n@jvH*k1Y_RHM!O8x8e`kKXt5gUfi2@050OnNAYvg-U0ul|LKr4% z78dB|Ax{0~uqiC9-!;C!zw{fWI0vYc*6-mLPEx#g{Dwk#N_NR^}*wh-OL(UL$2uHS{3n$fyw3HPS+KO;C6mnRrvv}74MxEG~27A}w z>=EpX;P*qYCZY4Tqm`Ofy1Vh3wKd4qd|fxa)^X+}`A;vZir&|GDynp#&+{CpVMPkK oalfXN5Z{4OGuC*H3(VmY-})@y>*af`emnp3y`Et{%h7+)7xB{KQ~&?~ literal 0 HcmV?d00001 diff --git a/evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.json b/evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.json new file mode 100644 index 000000000..4c39e7454 --- /dev/null +++ b/evidence/results/0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "f0973b6991b5d2505425752e83b110fa3c535bb1172677afd1eb477cd52f000f", + "case_hash": "0cebc9733a2f1c105d336d26e8431e7437cb31f7fe5c26ac348b91292585b1d7", + "columns": [ + { + "internal_size": 2, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 11, + "type_name": "BINARY" + }, + { + "internal_size": null, + "name": "L", + "nullable": true, + "precision": 18, + "scale": 0, + "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": "770305dc8afe05385c83dc636dd2b43dfadb273d630c5ea0a23050f5349bcbc2", + "recorded_at": "2026-11-06T20:05:23.650305+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 2, + "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/1bc7db6a2375637aec95474a4683661ded5e33f4292d0ae9b8fdbb96639afb8c.json b/evidence/results/1bc7db6a2375637aec95474a4683661ded5e33f4292d0ae9b8fdbb96639afb8c.json new file mode 100644 index 000000000..3ff5c6c7f --- /dev/null +++ b/evidence/results/1bc7db6a2375637aec95474a4683661ded5e33f4292d0ae9b8fdbb96639afb8c.json @@ -0,0 +1,55 @@ +{ + "arrow_sha256": null, + "case_hash": "1bc7db6a2375637aec95474a4683661ded5e33f4292d0ae9b8fdbb96639afb8c", + "columns": [], + "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": { + "errno": 100145, + "message": "Binary value 'AABBCC' is too long and would be truncated", + "sqlstate": "22000" + }, + "format": 1, + "outcome": "error", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "290fcb78207f7c40fe313b82e377dba1bad988bca7fe7bab7b6016425c199a3e", + "recorded_at": "2026-11-06T20:05:23.650305+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "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 572b3eacca027d366d9b4300f3fe9f694dc32b6d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:12:52 +0000 Subject: [PATCH 17/18] cases: say that a BINARY value cast with :: to a shorter BINARY raises --- cases/translation/functions/cast.double-colon.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cases/translation/functions/cast.double-colon.toml b/cases/translation/functions/cast.double-colon.toml index daf03a96a..70cca7bcb 100644 --- a/cases/translation/functions/cast.double-colon.toml +++ b/cases/translation/functions/cast.double-colon.toml @@ -87,7 +87,7 @@ tags = ["boundary"] [[case]] id = "t008_binary_to_shorter_binary" -description = "A three-byte BINARY value cast with :: to BINARY(2); this records whether :: raises 100145 as CAST does (cast-as t012). Catches: the excess byte silently truncated" +description = "A three-byte BINARY value cast with :: to BINARY(2); :: raises 100145 here, exactly as CAST does (cast-as t012). Catches: the excess byte silently truncated" columns = [{ name = "b", type = "BINARY" }] rows = [ ["'AABBCC'"], From 0ce7f74b0666d549ae3dde89b51d77beed680b4e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:29:57 +0000 Subject: [PATCH 18/18] Declare obligations for BINARY values cast to BINARY(n) with :: The same two cases as CAST: a BINARY value that fits BINARY(2), with its length and NULL, and one a byte too long, which raises. --- .../translation/obligations/functions.toml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/coverage/translation/obligations/functions.toml b/coverage/translation/obligations/functions.toml index 8e38fbe50..11d4262d2 100644 --- a/coverage/translation/obligations/functions.toml +++ b/coverage/translation/obligations/functions.toml @@ -5837,6 +5837,28 @@ reading = "the page: converts the expression to the target type; a scale too sma cases = ["cases/translation/functions/cast.double-colon.toml#t006_text_to_binary"] required = true +[[obligation]] +id = "sf.functions.cast.double-colon.t007" +form = "sf.functions.cast.double-colon" +kind = "type_combination" +target = "BINARY to BINARY(n)" +note = "A two-byte BINARY value and NULL cast with :: to BINARY(2), which it exactly fills, with the length." +catches = ":: read differently from CAST, the bytes re-read as hex text, the length not 2, or the NULL lost" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the text data types page: BINARY(N) declares the maximum number of bytes" +cases = ["cases/translation/functions/cast.double-colon.toml#t007_binary_to_binary_fits"] +required = true + +[[obligation]] +id = "sf.functions.cast.double-colon.t008" +form = "sf.functions.cast.double-colon" +kind = "error_parity" +target = "error" +note = "A three-byte BINARY value cast with :: to BINARY(2); the receipt shows Snowflake raises 100145 (22000), as CAST does (cast-as t012)." +catches = "the excess byte silently truncated instead of the error" +reading = "the page: converts the expression to the target type; a scale too small to hold the value rounds it; a precision too small raises an error; the text data types page: BINARY(N) declares the maximum number of bytes" +cases = ["cases/translation/functions/cast.double-colon.toml#t008_binary_to_shorter_binary"] +required = true + [[obligation]] id = "sf.functions.cbrt.t001" form = "sf.functions.cbrt"