From 7b4d9d230a1e50a9c2185eca2e9cb713789d4274 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:08:11 +0000 Subject: [PATCH 01/15] cases: POWER(2, n) and TO_CHAR month names for the dbt launch shapes POWER(2, n) for whole exponents from -52 to 64, dbt_utils.date_spine's sum of a * POWER(2, k), the double's range edges, overflow and NULL. TO_CHAR(date, 'MON') and 'MMMM' for every month, beside other fields, on a timestamp, and for NULL. --- .../functions/pow.power-of-two.toml | 57 +++++++++++++++++++ .../functions/to-char.month-names.toml | 47 +++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 cases/translation/functions/pow.power-of-two.toml create mode 100644 cases/translation/functions/to-char.month-names.toml diff --git a/cases/translation/functions/pow.power-of-two.toml b/cases/translation/functions/pow.power-of-two.toml new file mode 100644 index 000000000..048e07077 --- /dev/null +++ b/cases/translation/functions/pow.power-of-two.toml @@ -0,0 +1,57 @@ +category = "translation" +construct = "sf.functions.pow.power" +session_profile = "utc-v1" + +[[case]] +id = "t001_whole_exponents" +description = "POWER(2, n) for whole exponents from -52 to 64, as dbt_utils.date_spine writes it for each bit. Catches: an inexact power of two, an integer result, or a lost low bit past 2^53" +columns = [{ name = "n", type = "NUMBER(38,0)" }] +rows = [ + ["0"], ["1"], ["2"], ["3"], ["4"], ["5"], ["6"], ["7"], ["8"], ["9"], ["10"], ["11"], + ["31"], ["32"], ["52"], ["53"], ["62"], ["63"], ["64"], + ["-1"], ["-2"], ["-52"], +] +query = "SELECT POWER(2, n) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t002_literal_exponents_in_a_sum" +description = "The dbt_utils.date_spine shape: a NUMBER times POWER(2, k) for literal k, summed, plus 1. Catches: the result typed NUMBER instead of FLOAT, or a power other than 2^k" +columns = [{ name = "a", type = "NUMBER(38,0)" }, { name = "b", type = "NUMBER(38,0)" }] +rows = [ + ["0", "0"], + ["1", "0"], + ["1", "1"], + ["NULL", "1"], +] +query = "SELECT a * POWER(2, 0) + b * POWER(2, 1) + 1 AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t003_range_edges" +description = "The largest and smallest finite powers of two in a double, 2^1023 and 2^-1074, and 2^-1075 below the smallest. Catches: 2^1023 overflowing, or the subnormal 2^-1074 flushed to 0" +columns = [{ name = "n", type = "NUMBER(38,0)" }] +rows = [["1023"], ["-1074"], ["-1075"]] +query = "SELECT POWER(2, n) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t004_overflow" +description = "2^1024, past the largest double. Catches: a finite value or an error where Snowflake gives its overflow result" +columns = [{ name = "n", type = "NUMBER(38,0)" }] +rows = [["1024"]] +query = "SELECT POWER(2, n) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t005_null_exponent" +description = "POWER(2, NULL). Catches: 1 or 2 returned instead of NULL" +columns = [{ name = "n", type = "NUMBER(38,0)" }] +rows = [["NULL"], ["3"]] +query = "SELECT POWER(2, n) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null"] diff --git a/cases/translation/functions/to-char.month-names.toml b/cases/translation/functions/to-char.month-names.toml new file mode 100644 index 000000000..cc75f140d --- /dev/null +++ b/cases/translation/functions/to-char.month-names.toml @@ -0,0 +1,47 @@ +category = "translation" +construct = "sf.functions.to-char.to-char" +session_profile = "utc-v1" + +[[case]] +id = "t001_mon_every_month" +description = "TO_CHAR(date, 'MON') for a date in every month, as dbt_date.get_date_dimension writes month_name_short. Catches: the month number written, the wrong letter case, or a locale's abbreviation" +columns = [{ name = "d", type = "DATE" }] +rows = [ + ["'2024-01-15'"], ["'2024-02-29'"], ["'2024-03-15'"], ["'2024-04-15'"], ["'2024-05-15'"], ["'2024-06-15'"], + ["'2024-07-15'"], ["'2024-08-15'"], ["'2024-09-15'"], ["'2024-10-15'"], ["'2024-11-15'"], ["'2024-12-31'"], +] +query = "SELECT TO_CHAR(d, 'MON') AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t002_mmmm_every_month" +description = "TO_CHAR(date, 'MMMM') for a date in every month, as dbt_date.get_date_dimension writes month_name. Catches: MMMM read as MM twice ('0101'), the wrong letter case, or an abbreviation" +columns = [{ name = "d", type = "DATE" }] +rows = [ + ["'2024-01-15'"], ["'2024-02-29'"], ["'2024-03-15'"], ["'2024-04-15'"], ["'2024-05-15'"], ["'2024-06-15'"], + ["'2024-07-15'"], ["'2024-08-15'"], ["'2024-09-15'"], ["'2024-10-15'"], ["'2024-11-15'"], ["'2024-12-31'"], +] +query = "SELECT TO_CHAR(d, 'MMMM') AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t003_month_names_in_a_format" +description = "MON and MMMM beside other fields and on a timestamp. Catches: a field next to the name misread, or the timestamp's time leaking into the name" +columns = [{ name = "d", type = "DATE" }, { name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["'2024-09-05'", "'2024-05-01 23:59:59'"], +] +query = "SELECT TO_CHAR(d, 'DD MON YYYY') AS a, TO_CHAR(d, 'MMMM DD, YYYY') AS b, TO_CHAR(t, 'MON') AS c, TO_CHAR(t, 'MMMM') AS e FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t004_null" +description = "A NULL date. Catches: a month name for NULL" +columns = [{ name = "d", type = "DATE" }] +rows = [["NULL"], ["'2024-03-01'"]] +query = "SELECT TO_CHAR(d, 'MON') AS a, TO_CHAR(d, 'MMMM') AS b FROM input ORDER BY ordinal" +order = "ordered" +tags = ["null"] From 16d072b8282f08369a0fb9f37cc4a5c6f984bacd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:15:22 +0000 Subject: [PATCH 02/15] cases: FLATTEN PATH reaching an object, a scalar, and keys by case The recorded PATH cases reach arrays only. These record an object under the path (KEY, VALUE and the prefixed PATH, a key with a space), PATH keys matched by case, and a scalar, JSON null or empty array at the path. --- cases/translation/functions/flatten.path.toml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 cases/translation/functions/flatten.path.toml diff --git a/cases/translation/functions/flatten.path.toml b/cases/translation/functions/flatten.path.toml new file mode 100644 index 000000000..535025a17 --- /dev/null +++ b/cases/translation/functions/flatten.path.toml @@ -0,0 +1,39 @@ +category = "translation" +construct = "sf.functions.flatten" +session_profile = "utc-v1" + +[[case]] +id = "t001_object_under_path" +description = "PATH reaching an object: one row per key, with KEY, VALUE and a PATH prefixed with the given path, for a plain key and one with a space. Catches: the prefix joined without a dot, or a quoted key written as a plain one" +columns = [{ name = "a", type = "VARCHAR" }] +rows = [ + ["'{\"x\":{\"y\":{\"b\":1,\"a c\":2}}}'"], +] +query = "SELECT i.ordinal, f.key, f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x.y') f ORDER BY i.ordinal, f.key" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t002_path_key_case" +description = "PATH keys match case-sensitively: 'Items' finds nothing where the key is items, and one-step 'items' reaches the array. Catches: keys matched ignoring case" +columns = [{ name = "a", type = "VARCHAR" }] +rows = [ + ["'{\"items\":[10,20]}'"], +] +query = "SELECT 'lower' AS k, f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'items') f UNION ALL SELECT 'upper', f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'Items') f ORDER BY 1, 2" +order = "ordered" +tags = ["type-combination"] + +[[case]] +id = "t003_scalar_and_null_at_path" +description = "PATH reaching a scalar, a JSON null and an empty array gives no rows. Catches: a scalar flattened into one row, or a JSON null giving a row" +columns = [{ name = "a", type = "VARCHAR" }] +rows = [ + ["'{\"x\":5}'"], + ["'{\"x\":null}'"], + ["'{\"x\":[]}'"], + ["'{\"x\":[7]}'"], +] +query = "SELECT i.ordinal, f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x') f ORDER BY i.ordinal, f.index" +order = "ordered" +tags = ["type-combination"] From 02187c57148722f6d6b61568d73fa21a59469aa1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:15:22 +0000 Subject: [PATCH 03/15] Convert LATERAL FLATTEN with a PATH of dotted keys FLATTEN(input => v, path => 'x.y') flattens the value at v:x.y, as the recordings show: the same INDEX and VALUE rows, PATH prefixed with the path ('x.y[0]'), and no rows where the path is missing. The path must be a literal of plain dot-separated keys; PATH with RECURSIVE or OUTER stays refused, unrecorded. flatten.toml#t002_path and flatten_recursive.toml#flatten_path_option now pass; dbt's lateral flatten(input => e.payload, path => 'items') converts. --- .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../conversion/_helpers/queries/flatten.rs | 53 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..b7919e972 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -993,7 +993,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: FLATTEN_RULE, constructs: &["node:lateral", "fn:FLATTEN"], - conditions: "LATERAL FLATTEN of a VARIANT input after another FROM source or after JOIN ... ON clauses, as a comma or CROSS JOIN source, with OUTER => a boolean literal at most: INDEX and PATH [i] for array elements, KEY and PATH for object keys (raising for keys that are not plain names), VALUE and THIS; scalars, JSON null and NULL give no rows. Under OUTER => TRUE an input with no rows gives one row: PATH '' and THIS the input for an empty container MODE takes, every column NULL otherwise (JSON null, NULL, a missing path, a scalar, a kind MODE skips), as recorded. MODE => 'ARRAY' or 'OBJECT' without RECURSIVE takes only that kind. RECURSIVE => TRUE walks every depth with keys in byte order, writing a key of letters, digits, `_`, spaces and dots that is not a plain name as ['key'] and raising for any other, MODE => 'BOTH', 'OBJECT' or 'ARRAY' choosing what it descends into, a scalar input giving one row of itself under BOTH and raising under OBJECT or ARRAY; RECURSIVE with OUTER, SEQ and PATH => are not converted.", + conditions: "LATERAL FLATTEN of a VARIANT input after another FROM source or after JOIN ... ON clauses, as a comma or CROSS JOIN source, with OUTER => a boolean literal at most: INDEX and PATH [i] for array elements, KEY and PATH for object keys (raising for keys that are not plain names), VALUE and THIS; scalars, JSON null and NULL give no rows. Under OUTER => TRUE an input with no rows gives one row: PATH '' and THIS the input for an empty container MODE takes, every column NULL otherwise (JSON null, NULL, a missing path, a scalar, a kind MODE skips), as recorded. MODE => 'ARRAY' or 'OBJECT' without RECURSIVE takes only that kind. RECURSIVE => TRUE walks every depth with keys in byte order, writing a key of letters, digits, `_`, spaces and dots that is not a plain name as ['key'] and raising for any other, MODE => 'BOTH', 'OBJECT' or 'ARRAY' choosing what it descends into, a scalar input giving one row of itself under BOTH and raising under OBJECT or ARRAY; PATH => a literal of dot-separated plain keys flattens the value at that path, PATH prefixed with it ('x.y[0]'), a missing path giving no rows, as recorded; other PATHs, PATH with RECURSIVE or OUTER, RECURSIVE with OUTER, and SEQ are not converted.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/flatten.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/flatten.rs index 48fe37a56..817742496 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/flatten.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/flatten.rs @@ -75,6 +75,7 @@ pub(crate) fn lateral_flatten( keep_empty, recursive, mode, + path, } = arguments(call, &function)?; let scope = Scope::new(earlier.to_vec(), outer); let site = Site { @@ -97,10 +98,27 @@ pub(crate) fn lateral_flatten( diverge(context, JSON_FIDELITY, &function)?; let log = context.log; let json = read_json(&input, context); + let json = match path.is_empty() { + true => json, + false => format!( + "json_extract({json}, '${}')", + path.iter() + .map(|key| format!(".\"{key}\"")) + .collect::() + ), + }; let rows = match (recursive, mode) { (true, mode) => json_flatten_recursive(&json, mode, log), (false, mode) => json_flatten(&json, (mode, keep_empty), log), }; + let rows = match path.join(".") { + prefix if prefix.is_empty() => rows, + prefix => format!( + "SELECT \"INDEX\", \"KEY\", CASE WHEN starts_with(\"PATH\", '[') \ + THEN '{prefix}' || \"PATH\" ELSE '{prefix}.' || \"PATH\" END AS \"PATH\", \ + \"VALUE\", \"THIS\" FROM ({rows})" + ), + }; let flattened = format!("LATERAL ({rows}) AS {}", quoted_identifier(&alias, log)); let sql = format!(" CROSS JOIN {flattened}"); let columns: Vec = FLATTEN_COLUMNS @@ -126,21 +144,23 @@ pub(crate) fn lateral_flatten( }) } -/// A FLATTEN call's input, OUTER and RECURSIVE flags, and MODE. +/// A FLATTEN call's input, OUTER and RECURSIVE flags, MODE and PATH keys. struct Arguments<'a> { input: &'a Value, keep_empty: bool, recursive: bool, mode: FlattenMode, + path: Vec, } -/// The input, OUTER, RECURSIVE and MODE; PATH and OUTER with RECURSIVE are refused. +/// The input, OUTER, RECURSIVE, MODE and a PATH of dotted keys; OUTER with RECURSIVE or PATH is refused. fn arguments<'a>(call: &'a Payload, construct: &Construct) -> Result, Refusal> { let refuse = |reason: String| outside_conditions(construct.clone(), reason); let mut input: Option<&Value> = None; let mut keep_empty = false; let mut recursive = false; let mut mode: Option = None; + let mut path: Vec = Vec::new(); for (position, argument) in list(call, "args").iter().enumerate() { let Some(named) = argument.get("named_argument") else { if position > 0 { @@ -179,21 +199,50 @@ fn arguments<'a>(call: &'a Payload, construct: &Construct) -> Result return Err(refuse("a MODE other than a literal mode".to_owned())), }); } + "PATH" => { + let written = value + .get("literal") + .and_then(|literal| literal.get("value")) + .and_then(Value::as_str) + .unwrap_or_default(); + path = written.split('.').map(str::to_owned).collect(); + if !path.iter().all(|key| plain_key(key)) { + return Err(refuse( + "a PATH other than literal dot-separated keys, which no recording shows" + .to_owned(), + )); + } + } other => return Err(refuse(format!("FLATTEN with {other}"))), } } if recursive && keep_empty { return Err(refuse("FLATTEN with RECURSIVE and OUTER".to_owned())); } + if !path.is_empty() && (recursive || keep_empty) { + return Err(refuse( + "FLATTEN with PATH and RECURSIVE or OUTER, which no recording shows".to_owned(), + )); + } let input = input.ok_or_else(|| refuse("FLATTEN without an input".to_owned()))?; Ok(Arguments { input, keep_empty, recursive, mode: mode.unwrap_or(FlattenMode::Both), + path, }) } +/// Whether a PATH step is a key needing no quotes: a letter or `_`, then letters, digits or `_`. +fn plain_key(key: &str) -> bool { + let mut characters = key.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && characters.all(|next| next.is_ascii_alphanumeric() || next == '_') +} + /// The value of a boolean literal. fn boolean_literal(value: &Value) -> Option { value From ad268f189f30dc5e30a33179f9cdda13f348dbdf Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:18:34 +0000 Subject: [PATCH 04/15] Convert POWER(2, n) of a whole NUMBER exponent dbt_utils.date_spine sums a * POWER(2, k) for each bit, and POWER of a base other than 10 refused. A double holds every whole power of two from 2^-1074 to 2^1023 exactly, so DuckDB's pow(2, n) gives the value Snowflake records with no rounding question, unlike other bases. POWER(2, n) of a fractional exponent stays refused. Its cases are pow.power-of-two.toml, pending Snowflake receipts. --- .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../_helpers/expressions/numeric_functions.rs | 23 +++++++++++++------ .../src/conversion/constants.rs | 4 +++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index b7919e972..43374202a 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -904,7 +904,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: POWER_RULE, constructs: &["fn:POWER"], - conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", + conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. POWER of the constant 2 to a whole NUMBER exponent is the exact power of two a double holds, from 2^-1074 to 2^1023, as recorded; NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/numeric_functions.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/numeric_functions.rs index 41da7c1d1..d2ff42418 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/numeric_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/numeric_functions.rs @@ -21,7 +21,8 @@ use crate::conversion::_helpers::support::site::Site; use crate::conversion::constants::{ BITWISE, HALF_AWAY_FROM_ZERO, HALF_TO_EVEN, LN_NAME, LOGARITHM, NUMERIC_FUNCTION, POWER_BASE, - POWER_RULE, ROUND_FUNCTION, ROUND_WITH_MODE_ARGUMENTS, ROUNDING, SIGN_RULE, SQRT_NAME, + POWER_BASE_TWO, POWER_RULE, ROUND_FUNCTION, ROUND_WITH_MODE_ARGUMENTS, ROUNDING, SIGN_RULE, + SQRT_NAME, }; use sqlquack_policy::policy::constants::NUMERIC_RANGE; @@ -86,7 +87,7 @@ pub(crate) fn logarithm( Ok(Emitted::new(sql, SnowflakeType::Float)) } -/// POWER(10, n) of a whole NUMBER exponent, as the FLOAT nearest the power; other bases refuse. +/// POWER(10, n) or POWER(2, n) of a whole NUMBER exponent, as a FLOAT; other bases refuse. pub(crate) fn power( arguments: &[Value], construct: &Construct, @@ -98,19 +99,27 @@ pub(crate) fn power( return Err(refuse("POWER with these arguments".to_owned())); }; let base = convert_expression(base, site)?; - if base.literal != Some(Literal::Number(POWER_BASE.to_owned())) { + let two = base.literal == Some(Literal::Number(POWER_BASE_TWO.to_owned())); + if base.literal != Some(Literal::Number(POWER_BASE.to_owned())) && !two { return Err(refuse( - "POWER of a base other than the constant 10, whose rounding no recording shows" + "POWER of a base other than the constant 10 or 2, whose rounding no recording shows" .to_owned(), )); } let exponent = convert_expression(exponent, site)?; - match exponent.data_type { - SnowflakeType::Number { scale: 0, .. } | SnowflakeType::Null => Ok(Emitted::new( + match (exponent.data_type, two) { + (SnowflakeType::Number { scale: 0, .. } | SnowflakeType::Null, true) => Ok(Emitted::new( + format!("pow(CAST(2 AS DOUBLE), CAST({} AS DOUBLE))", exponent.sql), + SnowflakeType::Float, + )), + (SnowflakeType::Number { scale: 0, .. } | SnowflakeType::Null, false) => Ok(Emitted::new( power_of_ten(&exponent.sql, site.context.log), SnowflakeType::Float, )), - other => Err(refuse(format!("POWER(10, n) of a {other} exponent"))), + (other, _) => Err(refuse(format!( + "POWER({}, n) of a {other} exponent", + if two { POWER_BASE_TWO } else { POWER_BASE } + ))), } } diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 6269405a0..4af804885 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -1272,8 +1272,10 @@ pub const NULL_IF_ZERO_RULE: &str = "rule.null_if_zero"; pub const LOGARITHM: &str = "rule.logarithm"; /// POWER(10, n) of a whole exponent. pub const POWER_RULE: &str = "rule.power"; -/// The one base POWER converts, as its literal is written. +/// The base POWER converts as the FLOAT nearest the power, as its literal is written. pub(crate) const POWER_BASE: &str = "10"; +/// The other POWER base converted: 2, whose whole powers a double holds exactly. +pub(crate) const POWER_BASE_TWO: &str = "2"; /// The natural logarithm's name, beside EXP. pub(crate) const LN_NAME: &str = "LN"; /// ARRAY_SORT of one array. From d1c319997806c3a780796b9b4879a86323e298c6 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:18 +0000 Subject: [PATCH 05/15] evidence: POWER(2, n), TO_CHAR month names and REGEXP_SUBSTR past the end Recorded from test/d250-regexp-position 4b84bcbd (fix/d250-launch-blockers 7b4d9d23 plus the REGEXP_SUBSTR probe): 10 cases, utc-v1, no failures. POWER(2, 1024) returns inf rather than an error; POWER(2, -1075) is 0. TO_CHAR 'MMMM' gives the full English month name, 'MON' the three-letter one. REGEXP_SUBSTR('abc', 'x*', n) gives '' at 3 and 4 and NULL at 5. --- ...db725eb0a620227173256d8a3bcbebb9f681.arrow | Bin 0 -> 1098 bytes ...4db725eb0a620227173256d8a3bcbebb9f681.json | 61 ++++++++++++ ...eac2f91ecaeb07f4338d82bdb8d957d665c6.arrow | Bin 0 -> 978 bytes ...eeac2f91ecaeb07f4338d82bdb8d957d665c6.json | 61 ++++++++++++ ...4205ecd27e9871d05fd90c74f296e28eecbb.arrow | Bin 0 -> 1738 bytes ...64205ecd27e9871d05fd90c74f296e28eecbb.json | 70 ++++++++++++++ ...bbcc29ca8c6dca96aaed915c2113ad3e40cb.arrow | Bin 0 -> 1010 bytes ...0bbcc29ca8c6dca96aaed915c2113ad3e40cb.json | 61 ++++++++++++ ...73a14f1e662b2ef390f15181fee3125e489f.arrow | Bin 0 -> 1754 bytes ...c73a14f1e662b2ef390f15181fee3125e489f.json | 70 ++++++++++++++ ...82f11fe3aa59aca4a00b83bfadd793aec4de.arrow | Bin 0 -> 994 bytes ...782f11fe3aa59aca4a00b83bfadd793aec4de.json | 61 ++++++++++++ ...d81732b6ba2195a071939f5ce2f88670b347.arrow | Bin 0 -> 1146 bytes ...cd81732b6ba2195a071939f5ce2f88670b347.json | 61 ++++++++++++ ...8b56ff6faee43e265a388b225d400ecb6963.arrow | Bin 0 -> 1138 bytes ...98b56ff6faee43e265a388b225d400ecb6963.json | 61 ++++++++++++ ...12ae893c2597b5b190d6684fd0010b8e5472.arrow | Bin 0 -> 994 bytes ...412ae893c2597b5b190d6684fd0010b8e5472.json | 61 ++++++++++++ ...a0c565139cfa9c9f12810cd431a7693bbaa9.arrow | Bin 0 -> 2546 bytes ...0a0c565139cfa9c9f12810cd431a7693bbaa9.json | 88 ++++++++++++++++++ 20 files changed, 655 insertions(+) create mode 100644 evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.arrow create mode 100644 evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.json create mode 100644 evidence/results/4cf76346bdfb14872198308f8d0eeac2f91ecaeb07f4338d82bdb8d957d665c6.arrow create mode 100644 evidence/results/4cf76346bdfb14872198308f8d0eeac2f91ecaeb07f4338d82bdb8d957d665c6.json create mode 100644 evidence/results/6b44d9301fbc0b7b7232f8c211164205ecd27e9871d05fd90c74f296e28eecbb.arrow create mode 100644 evidence/results/6b44d9301fbc0b7b7232f8c211164205ecd27e9871d05fd90c74f296e28eecbb.json create mode 100644 evidence/results/75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb.arrow create mode 100644 evidence/results/75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb.json create mode 100644 evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.arrow create mode 100644 evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.json create mode 100644 evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.arrow create mode 100644 evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.json create mode 100644 evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.arrow create mode 100644 evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.json create mode 100644 evidence/results/b30082406f48e628889819d888598b56ff6faee43e265a388b225d400ecb6963.arrow create mode 100644 evidence/results/b30082406f48e628889819d888598b56ff6faee43e265a388b225d400ecb6963.json create mode 100644 evidence/results/defe2a2cd18d2f6fecd30623029412ae893c2597b5b190d6684fd0010b8e5472.arrow create mode 100644 evidence/results/defe2a2cd18d2f6fecd30623029412ae893c2597b5b190d6684fd0010b8e5472.json create mode 100644 evidence/results/ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9.arrow create mode 100644 evidence/results/ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9.json diff --git a/evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.arrow b/evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.arrow new file mode 100644 index 0000000000000000000000000000000000000000..9ff4f14d984865a2e68c127eb987526e5da14a66 GIT binary patch literal 1098 zcmeHHy-LGS6h5_St+im0Dg{9h92^|%5458JbqKA6TF}9vX}q?9kcPAsy7>SO9UUDV zeE=WD!8fVjcauO7!O6`7-}$-co`bJNBXHunBI8b8`pmk?5V@a1_}Dyz$UQN+ufwqN literal 0 HcmV?d00001 diff --git a/evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.json b/evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.json new file mode 100644 index 000000000..00adaaf40 --- /dev/null +++ b/evidence/results/4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "22d7b4c2e340590c789afa703a11590edb2f4e174820cc20f3e557f41d0dc063", + "case_hash": "4a4ffeb793d992e24f24607e47b4db725eb0a620227173256d8a3bcbebb9f681", + "columns": [ + { + "internal_size": 134217728, + "name": "R", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "c8793ceda3b4b379c3e382d4e758875b843de63feb6d9166898e6bcef3f32537", + "recorded_at": "2026-11-06T10:22:52.631570+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 12, + "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/4cf76346bdfb14872198308f8d0eeac2f91ecaeb07f4338d82bdb8d957d665c6.arrow b/evidence/results/4cf76346bdfb14872198308f8d0eeac2f91ecaeb07f4338d82bdb8d957d665c6.arrow new file mode 100644 index 0000000000000000000000000000000000000000..63cb7c5aea504aed8296b037e2bbb2e442fb0d6f GIT binary patch literal 978 zcmeHGF;2rk5S%2AF~LQIB1Az+Ndba_g2n}1B#;$}+E^yBWI0jdf=EcTDJd!Q5FWt; zcmzL4X6`PE6+ubIN^`e+JG-~LK1XqU_izEMD9;U`3rPnde6$fzqcl9)pcQcS+{$BG z#{_W5UA{fT>!QaKBds2{6B6aMxouob+b2GWCGkQGh%>???pcrc(shq~`@~aTCdFVr z$*5O5oNJz`-p6^B^|NwVjez$Z&bi~Hqhz|_eEh&^lQ&C~Lic>t{7u@Iyhyc7rdgWL z@^MK$VCR~Q`c%)VTZnI?J~tc?#dw%ArK{d2*Ecf&nr8kBauh2Gapb;0Xl9C*XJ)PZ zRywrpo$C39(7ZIOji}b&WShsv)Z?}>->+U?K4V9DD#rM@JvP z2XJs;Wbh$;0_*p+oTL^As{@C8_ndq0*K>Nmo*T;5>g9D_#D{%sh-8G87F$x16bli| zFfkX*ER5!C6aigs4Us)~ksWvoPi+R=jAXGwGm=@14tRVz%#T&XRUidSfj8g@xB(6U z6R>}LpM1vT*MKeHzSC`ZwQ=7?oCAmT$>CnUcvQ%jN`+&@eD}NY$US$vtx+4fS8$u{ z#z47`vD!K0N&>@X!(-jo$Ub!_q9E({2X3=7?DV>bKf&SI`EEq5Qs)t58}NFq4pIfD zFrfnXRYRH6P&Sxne}RS);01U9SVT3v!gpPz;V8yIJIrbK9OFKoPUeE}z` z*;izLnPwKchaJXVQSF%H^sHVvfsboPz6HI=hZ!>;_A?fkgJ#PvbYe%__`wop(64w`x=nEf57g+8x4I@df2Gpw%b%2N+%ea5U~ yIk}h^AUb73`1Cb}q-R4{ZKLFl=4e$&Mfm6T-9#9YYw3&yoJ>apZ z(sH<(=dkM?&K34p?|4<`gS?v5Q<2vl&Y9(8({$lDZ@+L_;4QPXWS);7@do@0Bw{V| zMV=MQVphQp(K#o>KG`$tCX%~&fJOF!ayBV&rL8^yH_$T>Y3TW{!Qokol0(G!1(cqt zG<`km6BCB+z&APB^EKe+)QeU!e|Mlr@(DUR1RuGQNcYuuVOva5MF++%Kq o>e-co$M?cpFQJdMW$s?v1TPFjzWe!qU;GAt^}aTkd#wNUFDJ2eBme*a literal 0 HcmV?d00001 diff --git a/evidence/results/75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb.json b/evidence/results/75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb.json new file mode 100644 index 000000000..9c2491570 --- /dev/null +++ b/evidence/results/75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "a8392d2cbb0f52541b29d00c3df5ef7e3769f95674c2164018ba2d7ff089a455", + "case_hash": "75f342293e8afc81b5ef4599d2b0bbcc29ca8c6dca96aaed915c2113ad3e40cb", + "columns": [ + { + "internal_size": null, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 1, + "type_name": "REAL" + } + ], + "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": "136cb8969daef54beaf8834457cc42437c827f2b701b90d985f3a15c2897218a", + "recorded_at": "2026-11-06T10:22:52.631570+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/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.arrow b/evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.arrow new file mode 100644 index 0000000000000000000000000000000000000000..8c8ce95d8a539c7c7a7f2e7509b6c805b615c7a0 GIT binary patch literal 1754 zcmeHIu}%U(5FO+=5h;ivhLV!PLcjz{FtNj!5E3nH&hvyz2oRu<#80rW^fN3e_y8+w z3JX3&J>T2gjTZue(t=55cXsFP%GK_^?*ym6iI)}bNpP{$V6DZGreFp*Yw25y)51qNXYzyq38M|zu$vB1BQ2JZPeDq4X+@F&~Cr&Bb2&@1s3QRIg%V$vSnX! zn~^MbC?SW16&%*#C5H>>Dh_M-4Wsv1#o;|L+j97_0UPCTwv>CB!_SC+9fwIU!R})3 zY{#XgxeWVvuFqjYq+@W!4o)k6C2oOlks6&!Qdo!i*gH>9a>v??kZU9CNeXMD@Oz*z zCM6zDlGn4r7xdcTt9s4;GQylDuQ|7FZO)i{m(Eaa8NBhE`0%^<9MtzsAMcn3yS8E` sqwiq}>te0!P1f+P11?}wYHIu&c5gxWo9*6$@a_FCZ@~h6TUY;Sze~3ANdN!< literal 0 HcmV?d00001 diff --git a/evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.json b/evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.json new file mode 100644 index 000000000..ecd526792 --- /dev/null +++ b/evidence/results/7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "80cf863d6d9160db65f335f0cf488168401fe0d3f79512a20e36a8f130564535", + "case_hash": "7a3cabb7e435bb7053d9288848fc73a14f1e662b2ef390f15181fee3125e489f", + "columns": [ + { + "internal_size": 134217728, + "name": "A", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "B", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "62028b264e190cd013031746c2d023719a494f02d91e07328240388414d3e2f3", + "recorded_at": "2026-11-06T10:22:52.631570+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/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.arrow b/evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.arrow new file mode 100644 index 0000000000000000000000000000000000000000..a845366e3c699bfcaf0704964f1411f957560911 GIT binary patch literal 994 zcmeH`u}%U(5QYah&J(X7h8PPA3rh=)78Vv>VMn65kXR!*;SvG_C?xR#C@n2{5Ff$^ z@Lk~h_iiI0wsubP@6PP}yF2sWUXrE{PnTwE?6qRnG+D!93$1Pu9##p6jVNLa%8_NF zV_Ww9NR(-(-HrdA7_ z)`i|;G>D5k)$=WoUaHUAwl583s!t}aH|0Fn^L*?%#e8~)6l?j~A5*Qmh>7$*)bTar gne&S8wk!{PR~q}N|NlMdeMslOcuy;IPu#z~0f||2i~s-t literal 0 HcmV?d00001 diff --git a/evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.json b/evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.json new file mode 100644 index 000000000..e9de14e7a --- /dev/null +++ b/evidence/results/9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "b80fd64cd3f72687bced5e63ffc2750e2b77755033c53279500926f3cc18f6de", + "case_hash": "9e3cd6c4430556325c9cfe03efb782f11fe3aa59aca4a00b83bfadd793aec4de", + "columns": [ + { + "internal_size": null, + "name": "R", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 1, + "type_name": "REAL" + } + ], + "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": "b99bea907058469b8764caf3efeec676d07d0db73482b3cacd382686aa6693dd", + "recorded_at": "2026-11-06T10:22:52.631570+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/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.arrow b/evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.arrow new file mode 100644 index 0000000000000000000000000000000000000000..46e216e9942bf4d477e32f21b53ad5a799d4e917 GIT binary patch literal 1146 zcmeHHu}%U(5FOw+PrQN{LM$vSEG;lvSdhrY*pWysB(~z^gi8nzppeAEl9JMrPv8Um z2tL4qPq;t8_jZRsh?aIv^7hT#%q$sq4_~J@lJb18-gIExG z7#$t)8PN$Katvnfz|<9`gfGT&CD17fnYC-%c_CsKSORn44QK)vfCDtp59{f3ALb5$ zm!#L~HYbA^{&=tE2H)84^JE;?<6e8*5&77wsc22qX$?0upFh;(Q8$WOUGDR>lg}c) z!ba?6FpQ&Ql=OS>J)E4%@SHp|`zFHspbnBFpxbXJSW>%pQR^BFM6yQzDQY;E=1W;` zz&Dyo&M{gqY#L($vE7qrt^(An3~Y?O{$|^CtiHSRwsX>3^=p=k%4fEtMBlT+y0yseum_sjiQR@0K@ljq%)g_PkR* YD%i}D?CSCV&iNw$=AF->dtm-K--Wh@Gynhq literal 0 HcmV?d00001 diff --git a/evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.json b/evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.json new file mode 100644 index 000000000..b0761bd06 --- /dev/null +++ b/evidence/results/a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "f90cee47a945998de57d6f5dc0a98d8014b71652539778d92f066de0e2bc001d", + "case_hash": "a746e0056cd8546feb423cf4f00cd81732b6ba2195a071939f5ce2f88670b347", + "columns": [ + { + "internal_size": null, + "name": "R", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 1, + "type_name": "REAL" + } + ], + "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": "9dd1ddc1509a6ff9f38e23d11027090bdfeaffff4abf943f7a9a1323eca8d9ca", + "recorded_at": "2026-11-06T10:22:52.631570+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 22, + "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/b30082406f48e628889819d888598b56ff6faee43e265a388b225d400ecb6963.arrow b/evidence/results/b30082406f48e628889819d888598b56ff6faee43e265a388b225d400ecb6963.arrow new file mode 100644 index 0000000000000000000000000000000000000000..9babb7c8fbc75b31551913511264c1e30795acbd GIT binary patch literal 1138 zcmeHHJxjw-6uq_ShqYjlA_@);4k8Zr11e5d)SD|*YMT=krimhGCHpyMcimf6`{6~$Y@kMdaeUM4SWEvz$0)4>;eXGh# zB5-57rqc~a3idT}*q=F!l`7S8sa7lR!{*$*&{s|6_Wgm#?=2nu1=KLWF;M(u05}5TdJWSJOfNiDJ&(F&de)#m@@%JNhK;~gjlc=(K|dJ#=W67u z+n(~;mOq3#8{TR6SXo-5D1tbOKS){Om zPFc@(IM?i{-pBbY?WDurtZ(+d!#OWFNk5)!I3GW7YVf8>JW!smQoM?PNg~xUo}@`O z%|=7kbvlQg5-C1XB0dl@)}q~wm*Fz8^&j819OLpiB-r&F!x=9Yv4SX`oo9sXS6(t zIk%8^b4yF^GGyj((5RnpdZ&l2hR6$i8K3*em14DmNv6TaXNN}z_2Wvr=}8K?42rqm zZh0o=3VfL-y7668*1u}MmPRD+G6~vvPNNCT>GoeJ;az@bauU_<7Qpn8b!o{ z#g=!n@0~l@#rdD+;62)^w~hM`9oGYWs%mfJ^b)A3jgwHdw}N`i!EP`l^?}zkfNC$Q b5{7U5@Qvq9;Pn{3@&D(IchL8R;9u_>wX%kE literal 0 HcmV?d00001 diff --git a/evidence/results/ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9.json b/evidence/results/ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9.json new file mode 100644 index 000000000..a59f9c365 --- /dev/null +++ b/evidence/results/ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9.json @@ -0,0 +1,88 @@ +{ + "arrow_sha256": "8086f8fbfe39b17fd73bda0af5d77f1426ed09fe11d43523d1cdc8a7c38c4f31", + "case_hash": "ffd83d3f865bb76030682a49bf90a0c565139cfa9c9f12810cd431a7693bbaa9", + "columns": [ + { + "internal_size": 134217728, + "name": "A", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "B", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "C", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": 134217728, + "name": "E", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "350b4b42fbfb5717c4013fed76fec786661ffa6441acb4c82cc9a8ff8e968e45", + "recorded_at": "2026-11-06T10:22:52.631570+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 1, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} From 685f2ef7e5d8be8081de7592b8b24a7de808f59f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:11:52 +0000 Subject: [PATCH 06/15] cases: REGEXP_SUBSTR of an empty-matching pattern at and past the end 'x*' on 'abc' at positions 3, 4 and 5, for #112's position handling. --- .../regexp-substr.empty-match-position.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 cases/translation/functions/regexp-substr.empty-match-position.toml diff --git a/cases/translation/functions/regexp-substr.empty-match-position.toml b/cases/translation/functions/regexp-substr.empty-match-position.toml new file mode 100644 index 000000000..0603193bd --- /dev/null +++ b/cases/translation/functions/regexp-substr.empty-match-position.toml @@ -0,0 +1,16 @@ +category = "translation" +construct = "sf.functions.regexp-substr" +session_profile = "utc-v1" + +[[case]] +id = "t001_empty_match_at_and_past_the_end" +description = "REGEXP_SUBSTR(a, 'x*', n) on 'abc' with n at the last character, one past it and two past it, where 'x*' matches the empty string. Catches: '' where Snowflake gives NULL, or an error past the end where Snowflake answers" +columns = [{ name = "a", type = "VARCHAR" }, { name = "n", type = "NUMBER(38,0)" }] +rows = [ + ["'abc'", "3"], + ["'abc'", "4"], + ["'abc'", "5"], +] +query = "SELECT n, REGEXP_SUBSTR(a, 'x*', n) AS r FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] From deb61cd5d38a1bfee92080d7edb69217b5e14828 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:39:49 +0000 Subject: [PATCH 07/15] evidence: FLATTEN PATH reaching an object, by key case, and at a scalar or null Recorded from fix/d250-launch-blockers 16d072b8 (3 cases, utc-v1, no failures). Under an object, PATH is prefixed with the input path and a key with a space is bracketed: x.y['a c'], x.y.b. --- ...e8f478b725dfbb8c3fe7114338442f3de75f.arrow | Bin 0 -> 3866 bytes ...be8f478b725dfbb8c3fe7114338442f3de75f.json | 97 ++++++++++++++++++ ...987170b1f3a3241203a9df4f15c5a9790734.arrow | Bin 0 -> 3154 bytes ...d987170b1f3a3241203a9df4f15c5a9790734.json | 88 ++++++++++++++++ ...839cb1e9ee3c16e176b3ef1398e5bc883207.arrow | Bin 0 -> 3122 bytes ...9839cb1e9ee3c16e176b3ef1398e5bc883207.json | 88 ++++++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.arrow create mode 100644 evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.json create mode 100644 evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.arrow create mode 100644 evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.json create mode 100644 evidence/results/f94b375853ed0dad32ea9ca9fe29839cb1e9ee3c16e176b3ef1398e5bc883207.arrow create mode 100644 evidence/results/f94b375853ed0dad32ea9ca9fe29839cb1e9ee3c16e176b3ef1398e5bc883207.json diff --git a/evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.arrow b/evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.arrow new file mode 100644 index 0000000000000000000000000000000000000000..fe9b0802d95778603c86fa7c79afca5548f16d3c GIT binary patch literal 3866 zcmeHKzi(1e6h5>RuwrbQ#xOD%+o5TMHZ3bjF(o#%A*dKN5eOENhLV#DUa8 zA~QmnmZ*eeN+QTX0zs4or3k)3_bX@@xeAJWm=tMFh@=8+6=_^K4mwTEAsp`WoOS{3}-ML}evJ8W?-`uZO z>Ie0`2J#EQaLt?>wU8-t333yS4%#QJcS z&8=sOE@u}=&!9(8IzeOXlV@Ijoc;7*IQPVAZ*0xMfA;zre_oDKeK7Q`##GVM?#Iy) zFv=18KFAg`>qzaGVx&xiCC1A=sK~6uunR*HHv4!6yK&akU*Dr#0|nbz9%h~njJF8* z_pX_|+WT`6m{H@cI0WnE^0j5#j<mrcfb>GoG5;f$sl4@{qO-Ec&>22>v~J^N?GeiO*xd>OEPF&p`Uae?WMzK^B8>o1#(MefK8qN literal 0 HcmV?d00001 diff --git a/evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.json b/evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.json new file mode 100644 index 000000000..36fad24f5 --- /dev/null +++ b/evidence/results/a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f.json @@ -0,0 +1,97 @@ +{ + "arrow_sha256": "e4a81b8efcf75c725966f5d14b4ab0f67e600ab54a9668c7b4705afc76a94451", + "case_hash": "a0b4fb649137078b6c464dd859dbe8f478b725dfbb8c3fe7114338442f3de75f", + "columns": [ + { + "internal_size": null, + "name": "ORDINAL", + "nullable": false, + "precision": 1, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": 134217728, + "name": "KEY", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "INDEX", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 5, + "type_name": "VARIANT" + }, + { + "internal_size": 134217728, + "name": "PATH", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "41ae8fc8fd0bc87b05816d2c1d416563423a60ca681abc8b6923ab242ed36b1b", + "recorded_at": "2026-11-06T10:37:41.742721+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/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.arrow b/evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.arrow new file mode 100644 index 0000000000000000000000000000000000000000..078ce6f64a4d5cbb756d1656d5a6022936019c3f GIT binary patch literal 3154 zcmeHKJC71k6h64T#3{&TV<;>rHdYo4ySkK4vKWY)m5@W(Ncx;RKkZepO zEtHg45|@}H(Sk&x=nHyDl+kg?mdN><$g3+Ng-FnU^UL=!AhrQp7T-96Z3^)f$1SlP zhkk*+gPuX_&|6Sq-v01??t^ViAujYP^jW*x=(xv69@-y($)F~NHhXJ3Z}05n3utrC zPmhP*p4UAbwnV-y!8TiszQ$&LPOqKAT%bh;%|?go77RPwmMtvE^^f{qvps0{x@gyd zq0ihK)h*WOf?R`kdWUUHrMz%~`}BLl*Jg+>uKUQ@dssw$8DAI1o)+=-9hkr6>jq+Y z8P?>h1gsgWFkdahzKE|gU^HI|<#V;{?7K63MZp636j_%HGAk|#m6vH`*t+^2NpPOb z93IR2yTux84&9=8a?Tm21Ua)Zsj%*C{YZ-)}-Se=@_LsB^3NBNr6r?vltH-emfIT6R{WKof(h{Sr1t z-YJ$`2QR!SEtQM17DaA~yi`^eDfEN2=*I<=JaJ!I7_UWQjSU<_;T)8#Dkp{R>-z4* zq*}VK=c|nE!Fdwl*)T=-H6GWf61@+0IM(!o-~P4FG0a`#Ks6Sz%6ug==a* z>>RTy5XM&Tw>|Wp51!->g4#Y{9EqacLNwzlg6SZJm~9*1H=&%DYv#B~NM*c%NqkrD lq6c19@8b0RzIqpfGj0A3TfK|(f6M+4?_vq}qoe*y`~jpS+1UU9 literal 0 HcmV?d00001 diff --git a/evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.json b/evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.json new file mode 100644 index 000000000..459f132d5 --- /dev/null +++ b/evidence/results/a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734.json @@ -0,0 +1,88 @@ +{ + "arrow_sha256": "20c42704e723a2616fe63b2770dfe18ccfd8f5d3c3992e96beec75bf0aec4952", + "case_hash": "a25a12c6b772eeced9362e53613d987170b1f3a3241203a9df4f15c5a9790734", + "columns": [ + { + "internal_size": 5, + "name": "K", + "nullable": false, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + }, + { + "internal_size": null, + "name": "INDEX", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 5, + "type_name": "VARIANT" + }, + { + "internal_size": 134217728, + "name": "PATH", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "134071d486165bf60d696709f14d386ad0cfea85a4e3fb5a2a2c5d149d62b6c1", + "recorded_at": "2026-11-06T10:37:41.742721+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/f94b375853ed0dad32ea9ca9fe29839cb1e9ee3c16e176b3ef1398e5bc883207.arrow b/evidence/results/f94b375853ed0dad32ea9ca9fe29839cb1e9ee3c16e176b3ef1398e5bc883207.arrow new file mode 100644 index 0000000000000000000000000000000000000000..bd9fa339f51a0bee139f9ab78833e2e2bfc99145 GIT binary patch literal 3122 zcmeHKJ#Q015FID>*%%k62t_VaAVfi7ClPi*!AUlXCCiDd11td&#+byC{_&3>zzeAxh~309zCoKp{D$YR z(RQGppzokZ(5KKlP-5PE3j(f#Hd2TWy#{^N8?^iW{!SP6dtmYyiD9g`vbtO>m6pq} zx#k!9!*0Dh*c@(&e7OSK*=p}9*8TqH+y&$UEwa~X_c?FDh-2Hbf(beQ&ThBU+v{x) zVEe#u&s-bTueP`axeo1bZ}yNX+(i;m#y|d?%o*o?;w}gF zose-i;v7iw%{}NE*Lb*)`=bT^K9A`6?2en=ajXfuSI_Ra@g6<9 Date: Fri, 6 Nov 2026 10:43:33 +0000 Subject: [PATCH 08/15] TO_CHAR: write MON and MMMM, and refuse a format fields would match twice TO_CHAR(date, 'MMMM') converted as MM twice ('0909' for September), where Snowflake writes the month's full name: the format table had no MMMM, and the shorter MM matched both halves. MMMM and MON are now fields, writing the English month name and abbreviation in title case as recorded for every month (receipts d66ba9a0); written in another letter case they refuse, unrecorded. The tokenizer also refuses a format where two adjacent fields spell one longer run of a letter (MMMMMM, DDDD, SSSS, YYYYYYYY), so an element missing from the table cannot silently match as shorter ones. A test walks every documented format element: each converts as recorded or refuses. TO_DATE matches whole recorded formats and TO_TIMESTAMP's reader rejects a repeated element, so neither had the double match. --- .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../expressions/temporal_functions.rs | 20 +- .../src/conversion/constants.rs | 4 + .../src/analysis/tests/test_coverage_batch.rs | 223 ++++++++++++++++++ 4 files changed, 243 insertions(+), 6 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 43374202a..389679327 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -1169,7 +1169,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: TEMPORAL_TEXT_FORMAT, constructs: &["fn:TO_VARCHAR", "fn:TO_CHAR"], - conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it.", + conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it. MON and MMMM, written in upper case, write the English month's abbreviation (Jan) and full name (January) in title case, as recorded for every month. A format where two adjacent fields would spell a longer run of one letter (MMMMMM, DDDD, SSSS) is not converted, nor any element other than these.", failures: &[], }, RuleSpec { 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 3cee47429..bcc8c232c 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -61,11 +61,11 @@ use crate::conversion::constants::{ BARE_FRACTION, BARE_FRACTION_PATTERNS, CLOCK_FORMAT_FIELDS, CURRENT_TIME_RULE, DATE_ARITHMETIC, 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, - 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, + MONTH_NAME_FIELDS, 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, }; use sqlquack_duckdb::raises::main::raised::raised; use sqlquack_policy::policy::constants::{ @@ -1024,7 +1024,9 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { let upper = format.to_ascii_uppercase(); let mut rest = upper.as_str(); let mut pattern = String::new(); + let mut last_letter: Option = None; while !rest.is_empty() { + let written = &format[format.len() - rest.len()..]; if let Some(after) = rest.strip_prefix(BARE_FRACTION) && !after.starts_with(|next: char| next.is_ascii_digit()) { @@ -1033,6 +1035,7 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { .find(|(digits, _)| Some(*digits) == precision)?; pattern.push_str(digits); rest = after; + last_letter = None; continue; } let field = TEMPORAL_TEXT_FIELDS @@ -1040,10 +1043,17 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { .find(|(token, _, time)| rest.starts_with(token) && (with_time || !time)); match field { Some((token, replacement, _)) => { + if last_letter.is_some_and(|letter| token.starts_with(letter)) + || (MONTH_NAME_FIELDS.contains(token) && !written.starts_with(token)) + { + return None; + } pattern.push_str(replacement); rest = &rest[token.len()..]; + last_letter = token.chars().last().filter(char::is_ascii_alphabetic); } None => { + last_letter = None; let separator = rest.chars().next()?; if !TEMPORAL_TEXT_SEPARATORS.contains(separator) { return None; diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 4af804885..7855fddca 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -882,6 +882,8 @@ pub const TEMPORAL_TEXT_FORMAT: &str = "rule.temporal_text_format"; /// Format fields: the Snowflake token, its strftime spelling, and whether it is a time field. pub const TEMPORAL_TEXT_FIELDS: &[(&str, &str, bool)] = &[ ("YYYY", "%Y", false), + ("MMMM", "%B", false), + ("MON", "%b", false), ("MM", "%m", false), ("DD", "%d", false), ("HH24", "%H", true), @@ -891,6 +893,8 @@ pub const TEMPORAL_TEXT_FIELDS: &[(&str, &str, bool)] = &[ ("FF6", "%f", true), ("FF3", "%g", true), ]; +/// Fields writing an English month name, recorded only as written in upper case. +pub(crate) const MONTH_NAME_FIELDS: &[&str] = &["MMMM", "MON"]; /// The format element writing as many fraction digits as the timestamp's precision. pub(crate) const BARE_FRACTION: &str = "FF"; /// What FF writes for each precision recorded: none for 0, then milli-, micro- and nanoseconds. diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index bf4bad852..9302b28e7 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -1386,3 +1386,226 @@ fn given_urls_when_converting_parse_url_then_parts_and_parameters_are_read_as_re ); } } + +#[test] +fn given_each_documented_format_element_when_writing_text_then_it_converts_as_recorded_or_refuses() +{ + let test_cases = [ + FirstValueTestCase { + description: "the four-digit year", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'YYYY') AS v", + expected_outcome: Ok("2024"), + }, + FirstValueTestCase { + description: "the zero-padded month", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MM') AS v", + expected_outcome: Ok("09"), + }, + FirstValueTestCase { + description: "the zero-padded day", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'DD') AS v", + expected_outcome: Ok("05"), + }, + FirstValueTestCase { + description: "MON, the month's abbreviation in title case", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MON') AS v", + expected_outcome: Ok("Sep"), + }, + FirstValueTestCase { + description: "MMMM, the month's full name, not MM twice", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MMMM') AS v", + expected_outcome: Ok("September"), + }, + FirstValueTestCase { + description: "MON between other fields", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'DD MON YYYY') AS v", + expected_outcome: Ok("05 Sep 2024"), + }, + FirstValueTestCase { + description: "adjacent numeric fields", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'YYYYMMDD') AS v", + expected_outcome: Ok("20240905"), + }, + FirstValueTestCase { + description: "the 24-hour hour", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH24') AS v", + expected_outcome: Ok("13"), + }, + FirstValueTestCase { + description: "the minutes", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'MI') AS v", + expected_outcome: Ok("04"), + }, + FirstValueTestCase { + description: "the seconds", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'SS') AS v", + expected_outcome: Ok("05"), + }, + FirstValueTestCase { + description: "milliseconds", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'FF3') AS v", + expected_outcome: Ok("123"), + }, + FirstValueTestCase { + description: "FF at the timestamp's precision", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'FF') AS v", + expected_outcome: Ok("123"), + }, + FirstValueTestCase { + description: "MON of a timestamp", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'MON') AS v", + expected_outcome: Ok("Sep"), + }, + FirstValueTestCase { + description: "MON in lower case, whose letter case no recording shows", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'mon') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MMMM in mixed case", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'Mmmm') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "YY", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'YY') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "D", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'D') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "DY", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'DY') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "UUUU", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'UUUU') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "HH12", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH12') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "HH", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "H24", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'H24') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "H12", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'H12') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "H", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'H') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "AM", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH12 AM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "PM", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH12 PM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "A", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH12 A') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "P", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'HH12 P') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "FF0", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'FF0') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "FF1", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'FF1') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "FF7", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'FF7') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "TZH:TZM", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'TZH:TZM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "TZHTZM", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'TZHTZM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "TZH", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'TZH') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "TZM", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'TZM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MMMMMM, which MMMM and MM would spell", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MMMMMM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MM then MON, a run of M", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MMMON') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "DDDD, which DD would match twice", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'DDDD') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "YYYYYYYY, which YYYY would match twice", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'YYYYYYYY') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "SSSS, which SS would match twice", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'SSSS') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "time fields of a DATE", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'HH24 MI') AS v", + expected_outcome: Err("which no recording shows"), + }, + ]; + for test_case in test_cases { + assert_eq!( + shop_first_outcome(test_case.sql, test_case.expected_outcome), + test_case + .expected_outcome + .map(str::to_owned) + .map_err(str::to_owned), + "{}", + test_case.description + ); + } +} From be38679f3e651977de26077ea17838c0a374f842 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:58:51 +0000 Subject: [PATCH 09/15] Translation baseline: D250's POWER, TO_CHAR and FLATTEN statements Against main's baseline (e9f002d6): 0 regressions. 15 statements match: pow.power-of-two (5), to-char.month-names t001, t002, t004, flatten.path (3), flatten.toml t002_path, flatten_recursive flatten_path_option and to-char/to-varchar t002_date_format. Two new statements refuse (month-names t003 on its ',' separator, the REGEXP_SUBSTR empty-match probe). verify-recorded: 11607 cases, 7650 passed, 0 failed, 220 of 220 verified. --- coverage/translation/results-baseline.json | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index f86754a5e..2b00cc2ea 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -6914,7 +6914,7 @@ "semi-structured/flatten_outer.toml#flatten_top_level_outer_plain": "matching", "semi-structured/flatten_outer.toml#flatten_top_level_outer_recursive": "refused", "semi-structured/flatten_outer.toml#lateral_subquery_without_rows": "matching", - "semi-structured/flatten_recursive.toml#flatten_path_option": "refused", + "semi-structured/flatten_recursive.toml#flatten_path_option": "matching", "semi-structured/flatten_recursive.toml#flatten_recursive": "matching", "semi-structured/flatten_recursive.toml#flatten_recursive_deep": "matching", "semi-structured/flatten_recursive.toml#flatten_recursive_filtered": "matching", @@ -9324,8 +9324,11 @@ "translation/functions/first-value.toml#t002_ignore_nulls": "matching", "translation/functions/first-value.toml#t003_frame": "matching", "translation/functions/first-value.toml#t004_null_expr": "matching", + "translation/functions/flatten.path.toml#t001_object_under_path": "matching", + "translation/functions/flatten.path.toml#t002_path_key_case": "matching", + "translation/functions/flatten.path.toml#t003_scalar_and_null_at_path": "matching", "translation/functions/flatten.toml#t001_default": "matching", - "translation/functions/flatten.toml#t002_path": "refused", + "translation/functions/flatten.toml#t002_path": "matching", "translation/functions/flatten.toml#t003_outer": "matching", "translation/functions/flatten.toml#t004_recursive": "matching", "translation/functions/flatten.toml#t005_mode": "matching", @@ -10163,6 +10166,11 @@ "translation/functions/pow.pow.toml#t007_overflow_inf": "refused", "translation/functions/pow.pow.toml#t008_neg_base": "refused", "translation/functions/pow.pow.toml#t009_neg_frac": "refused", + "translation/functions/pow.power-of-two.toml#t001_whole_exponents": "matching", + "translation/functions/pow.power-of-two.toml#t002_literal_exponents_in_a_sum": "matching", + "translation/functions/pow.power-of-two.toml#t003_range_edges": "matching", + "translation/functions/pow.power-of-two.toml#t004_overflow": "matching", + "translation/functions/pow.power-of-two.toml#t005_null_exponent": "matching", "translation/functions/pow.power.toml#t001_int": "refused", "translation/functions/pow.power.toml#t002_dec": "refused", "translation/functions/pow.power.toml#t003_float": "refused", @@ -10255,6 +10263,7 @@ "translation/functions/regexp-substr-all.toml#t009_null_regex_parameters": "refused", "translation/functions/regexp-substr-all.toml#t010_null_group_num": "refused", "translation/functions/regexp-substr-all.toml#t011_empty": "matching", + "translation/functions/regexp-substr.empty-match-position.toml#t001_empty_match_at_and_past_the_end": "refused", "translation/functions/regexp-substr.toml#t001_varchar": "matching", "translation/functions/regexp-substr.toml#t002_position_occurrence": "refused", "translation/functions/regexp-substr.toml#t003_group": "refused", @@ -11090,13 +11099,17 @@ "translation/functions/to-boolean.toml#t003_invalid_string": "matching_error", "translation/functions/to-boolean.toml#t004_nan": "refused", "translation/functions/to-boolean.toml#t005_null_string_or_numeric_expr": "matching", + "translation/functions/to-char.month-names.toml#t001_mon_every_month": "matching", + "translation/functions/to-char.month-names.toml#t002_mmmm_every_month": "matching", + "translation/functions/to-char.month-names.toml#t003_month_names_in_a_format": "refused", + "translation/functions/to-char.month-names.toml#t004_null": "matching", "translation/functions/to-char.to-char.toml#t001_number_format": "refused", - "translation/functions/to-char.to-char.toml#t002_date_format": "refused", + "translation/functions/to-char.to-char.toml#t002_date_format": "matching", "translation/functions/to-char.to-char.toml#t003_binary_format": "refused", "translation/functions/to-char.to-char.toml#t004_variant": "refused", "translation/functions/to-char.to-char.toml#t005_null_expr": "matching", "translation/functions/to-char.to-varchar.toml#t001_number_format": "refused", - "translation/functions/to-char.to-varchar.toml#t002_date_format": "refused", + "translation/functions/to-char.to-varchar.toml#t002_date_format": "matching", "translation/functions/to-char.to-varchar.toml#t003_binary_format": "refused", "translation/functions/to-char.to-varchar.toml#t004_variant": "matching", "translation/functions/to-char.to-varchar.toml#t005_null_expr": "matching", From 56751b0e279973187bf0521ea9ff27f71bbe4262 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:01:42 +0000 Subject: [PATCH 10/15] POWER(2, n): state the recorded inf and 0 out of range; case THIS under PATH Review: the conditions named the exact range only; 2^1024 = inf and 2^-1075 = 0 are recorded (pow.power-of-two t004, t003). FLATTEN's THIS under PATH has no receipt yet, so a case records it. --- cases/translation/functions/flatten.path.toml | 11 +++++++++++ crates/sqlquack-rules/src/catalogue/constants.rs | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cases/translation/functions/flatten.path.toml b/cases/translation/functions/flatten.path.toml index 535025a17..057721526 100644 --- a/cases/translation/functions/flatten.path.toml +++ b/cases/translation/functions/flatten.path.toml @@ -37,3 +37,14 @@ rows = [ query = "SELECT i.ordinal, f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x') f ORDER BY i.ordinal, f.index" order = "ordered" tags = ["type-combination"] + +[[case]] +id = "t004_this_under_path" +description = "THIS under PATH is the value at the path, not the whole input. Catches: THIS giving the input FLATTEN was called on" +columns = [{ name = "a", type = "VARCHAR" }] +rows = [ + ["'{\"x\":{\"y\":[1,2]},\"z\":3}'"], +] +query = "SELECT f.index, f.this FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x.y') f ORDER BY f.index" +order = "ordered" +tags = ["type-combination"] diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 389679327..8ca97dbb3 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -904,7 +904,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: POWER_RULE, constructs: &["fn:POWER"], - conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. POWER of the constant 2 to a whole NUMBER exponent is the exact power of two a double holds, from 2^-1074 to 2^1023, as recorded; NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", + conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. POWER of the constant 2 to a whole NUMBER exponent is the exact power of two a double holds, from 2^-1074 to 2^1023, inf above that range and 0 below it, as recorded (2^1024 and 2^-1075); NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", failures: &[], }, RuleSpec { From 926536fe2221ae5dce47fffe529937ab1dbf28f2 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:08:11 +0000 Subject: [PATCH 11/15] Revert "POWER(2, n): state the recorded inf and 0 out of range; case THIS under PATH" This reverts commit 56751b0e279973187bf0521ea9ff27f71bbe4262. --- cases/translation/functions/flatten.path.toml | 11 ----------- crates/sqlquack-rules/src/catalogue/constants.rs | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/cases/translation/functions/flatten.path.toml b/cases/translation/functions/flatten.path.toml index 057721526..535025a17 100644 --- a/cases/translation/functions/flatten.path.toml +++ b/cases/translation/functions/flatten.path.toml @@ -37,14 +37,3 @@ rows = [ query = "SELECT i.ordinal, f.index, f.value, f.path FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x') f ORDER BY i.ordinal, f.index" order = "ordered" tags = ["type-combination"] - -[[case]] -id = "t004_this_under_path" -description = "THIS under PATH is the value at the path, not the whole input. Catches: THIS giving the input FLATTEN was called on" -columns = [{ name = "a", type = "VARCHAR" }] -rows = [ - ["'{\"x\":{\"y\":[1,2]},\"z\":3}'"], -] -query = "SELECT f.index, f.this FROM input i, LATERAL FLATTEN(INPUT => PARSE_JSON(i.a), PATH => 'x.y') f ORDER BY f.index" -order = "ordered" -tags = ["type-combination"] diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 8ca97dbb3..389679327 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -904,7 +904,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: POWER_RULE, constructs: &["fn:POWER"], - conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. POWER of the constant 2 to a whole NUMBER exponent is the exact power of two a double holds, from 2^-1074 to 2^1023, inf above that range and 0 below it, as recorded (2^1024 and 2^-1075); NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", + conditions: "POWER of the constant 10 to a whole NUMBER exponent, as the FLOAT nearest the power, ties to even, as recorded from 10^-3 to 10^38 (10^23 is the tie DuckDB's pow rounds the other way); far out of range inf or 0. NULL is NULL. POWER of the constant 2 to a whole NUMBER exponent is the exact power of two a double holds, from 2^-1074 to 2^1023, as recorded; NULL is NULL. Other bases refuse: DuckDB's pow is not correctly rounded, and no recording shows Snowflake's rounding.", failures: &[], }, RuleSpec { From f2a981da02d2d267b867c643f58a05b8d618b6be Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:50:20 +0000 Subject: [PATCH 12/15] test: use base 3 for the POWER base that still refuses This branch converts POWER(2, n), so the row pinning another base's refusal named a base that now converts. It names base 3. --- crates/sqlquack/src/analysis/tests/test_coverage_batch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index 9302b28e7..c586a9e2f 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -820,7 +820,7 @@ fn given_powers_of_ten_when_converting_power_then_the_nearest_double_is_kept() { }, FirstValueTestCase { description: "another base refuses", - sql: "SELECT POWER(2, 3) AS v", + sql: "SELECT POWER(3, 3) AS v", expected_outcome: Err("other than the constant 10"), }, FirstValueTestCase { From a13a0be48005c18a2abbfcf9269a3dd6ae0d5e9c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:24:24 +0000 Subject: [PATCH 13/15] cases: TO_CHAR MONMM and MMMMDD, with A's receipt month-names t007 and its result from evidence/d250-followup 07fb230a: Snowflake writes MONMM as 'September' and MMMMDD as 'September05'. --- .../functions/to-char.month-names.toml | 9 +++ ...6bdba317fbc5726b98b2d4bdaa7a64e67d3f.arrow | Bin 0 -> 1450 bytes ...46bdba317fbc5726b98b2d4bdaa7a64e67d3f.json | 70 ++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 evidence/results/f73a4be69ccd2128d8b01b2f33c46bdba317fbc5726b98b2d4bdaa7a64e67d3f.arrow create mode 100644 evidence/results/f73a4be69ccd2128d8b01b2f33c46bdba317fbc5726b98b2d4bdaa7a64e67d3f.json diff --git a/cases/translation/functions/to-char.month-names.toml b/cases/translation/functions/to-char.month-names.toml index cc75f140d..d9c52e7be 100644 --- a/cases/translation/functions/to-char.month-names.toml +++ b/cases/translation/functions/to-char.month-names.toml @@ -45,3 +45,12 @@ rows = [["NULL"], ["'2024-03-01'"]] query = "SELECT TO_CHAR(d, 'MON') AS a, TO_CHAR(d, 'MMMM') AS b FROM input ORDER BY ordinal" order = "ordered" tags = ["null"] + +[[case]] +id = "t007_names_beside_month_and_day" +description = "MONMM and MMMMDD, a name directly before another field. Catches: the name's letters read as part of the next field" +columns = [{ name = "d", type = "DATE" }] +rows = [["'2024-09-05'"]] +query = "SELECT TO_CHAR(d, 'MONMM') AS a, TO_CHAR(d, 'MMMMDD') AS b FROM input ORDER BY ordinal" +order = "ordered" +tags = ["type-combination"] diff --git a/evidence/results/f73a4be69ccd2128d8b01b2f33c46bdba317fbc5726b98b2d4bdaa7a64e67d3f.arrow b/evidence/results/f73a4be69ccd2128d8b01b2f33c46bdba317fbc5726b98b2d4bdaa7a64e67d3f.arrow new file mode 100644 index 0000000000000000000000000000000000000000..45e72b05726b2e24ced078b91b5de7eed71e8a7d GIT binary patch literal 1450 zcmeHHy-or_5dM(kM7)9+LMSaM0Wr`*Vn>2O5-s`31x!#0(vVnK_yCqZgoO`7LCKSd z-?w{9F2n>LfJtU|W_E67zS-GpbUN+(GO!^PPk<~$83gc=LYfjO;ZX;*G*>TL0lLFW z$O7c~Jsndy$}6%wZ7f`%IjX^7RHn1jVUbTK{FtvVGiNp7{92J3XZc-;mx z=f3d)E>IvkAE(AIiySQ?i+xRL7kh&?#s4Dr$PW8Eb03QRo4$u&U+#$+$DSqv-KMK| ze-FH<2Xl;~p*K9cQo$$}D8bxl`tca8& zPEL1zm(ihS#F6GUo>%r=T{h*o3#aq&X%Nn2h)A)v_R_VnvhAFIiz#4Nd{!C|q27&d hzs|L2B%7 Date: Fri, 6 Nov 2026 12:24:25 +0000 Subject: [PATCH 14/15] TO_CHAR: refuse a field directly after MON Snowflake writes MONMM as the full month name, not MON then MM, so the converted 'Sep09' was a wrong answer. A field right after MON now refuses. MMMM before a field stays converted, as t007 records (MMMMDD is September05). The format walk pins MONMM, MONDD and MMMMDD, and the translation baseline records t007 as refused. --- coverage/translation/results-baseline.json | 1 + crates/sqlquack-rules/src/catalogue/constants.rs | 2 +- .../_helpers/expressions/temporal_functions.rs | 16 +++++++++++----- .../sqlquack-rules/src/conversion/constants.rs | 2 ++ .../src/analysis/tests/test_coverage_batch.rs | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 2b00cc2ea..b0637a692 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -11103,6 +11103,7 @@ "translation/functions/to-char.month-names.toml#t002_mmmm_every_month": "matching", "translation/functions/to-char.month-names.toml#t003_month_names_in_a_format": "refused", "translation/functions/to-char.month-names.toml#t004_null": "matching", + "translation/functions/to-char.month-names.toml#t007_names_beside_month_and_day": "refused", "translation/functions/to-char.to-char.toml#t001_number_format": "refused", "translation/functions/to-char.to-char.toml#t002_date_format": "matching", "translation/functions/to-char.to-char.toml#t003_binary_format": "refused", diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 389679327..03d25ed9e 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -1169,7 +1169,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: TEMPORAL_TEXT_FORMAT, constructs: &["fn:TO_VARCHAR", "fn:TO_CHAR"], - conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it. MON and MMMM, written in upper case, write the English month's abbreviation (Jan) and full name (January) in title case, as recorded for every month. A format where two adjacent fields would spell a longer run of one letter (MMMMMM, DDDD, SSSS) is not converted, nor any element other than these.", + conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it. MON and MMMM, written in upper case, write the English month's abbreviation (Jan) and full name (January) in title case, as recorded for every month. MMMM directly before another field writes the name then the field (MMMMDD is September05), as recorded. A format where two adjacent fields would spell a longer run of one letter (MMMMMM, DDDD, SSSS), or where a field follows MON directly (MONMM writes the full name, as recorded), is not converted, nor any element other than these.", failures: &[], }, RuleSpec { 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 bcc8c232c..296822e8e 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -61,11 +61,12 @@ use crate::conversion::constants::{ BARE_FRACTION, BARE_FRACTION_PATTERNS, CLOCK_FORMAT_FIELDS, CURRENT_TIME_RULE, DATE_ARITHMETIC, 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, 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, + MONTH_ABBREVIATION, MONTH_NAME_FIELDS, 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, }; use sqlquack_duckdb::raises::main::raised::raised; use sqlquack_policy::policy::constants::{ @@ -1025,6 +1026,7 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { let mut rest = upper.as_str(); let mut pattern = String::new(); let mut last_letter: Option = None; + let mut after_abbreviation = false; while !rest.is_empty() { let written = &format[format.len() - rest.len()..]; if let Some(after) = rest.strip_prefix(BARE_FRACTION) @@ -1036,6 +1038,7 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { pattern.push_str(digits); rest = after; last_letter = None; + after_abbreviation = false; continue; } let field = TEMPORAL_TEXT_FIELDS @@ -1044,6 +1047,7 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { match field { Some((token, replacement, _)) => { if last_letter.is_some_and(|letter| token.starts_with(letter)) + || after_abbreviation || (MONTH_NAME_FIELDS.contains(token) && !written.starts_with(token)) { return None; @@ -1051,9 +1055,11 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { pattern.push_str(replacement); rest = &rest[token.len()..]; last_letter = token.chars().last().filter(char::is_ascii_alphabetic); + after_abbreviation = *token == MONTH_ABBREVIATION; } None => { last_letter = None; + after_abbreviation = false; let separator = rest.chars().next()?; if !TEMPORAL_TEXT_SEPARATORS.contains(separator) { return None; diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 7855fddca..823e34f08 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -895,6 +895,8 @@ pub const TEMPORAL_TEXT_FIELDS: &[(&str, &str, bool)] = &[ ]; /// Fields writing an English month name, recorded only as written in upper case. pub(crate) const MONTH_NAME_FIELDS: &[&str] = &["MMMM", "MON"]; +/// The month abbreviation, which Snowflake reads differently before another field (MONMM writes the full name). +pub(crate) const MONTH_ABBREVIATION: &str = "MON"; /// The format element writing as many fraction digits as the timestamp's precision. pub(crate) const BARE_FRACTION: &str = "FF"; /// What FF writes for each precision recorded: none for 0, then milli-, micro- and nanoseconds. diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index c586a9e2f..84745b6b6 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -1591,6 +1591,21 @@ fn given_each_documented_format_element_when_writing_text_then_it_converts_as_re sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'SSSS') AS v", expected_outcome: Err("which no recording shows"), }, + FirstValueTestCase { + description: "MMMM directly before DD, as recorded", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MMMMDD') AS v", + expected_outcome: Ok("September05"), + }, + FirstValueTestCase { + description: "MONMM, which Snowflake writes as the full name, not MON then MM", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MONMM') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MON directly before DD, unrecorded", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MONDD') AS v", + expected_outcome: Err("which no recording shows"), + }, FirstValueTestCase { description: "time fields of a DATE", sql: "SELECT TO_CHAR(DATE '2024-09-05', 'HH24 MI') AS v", From a7cf74354dbf663858f1b6f6d1465944c32c0188 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:54:50 +0000 Subject: [PATCH 15/15] TO_CHAR: convert month-name adjacency only where recorded Bare FF directly after MON converted ('MONFF' gave 'Sep123'), because the FF branch ran before the MON check. Adjacency is now one table of recorded followers: nothing after MON, only DD after MMMM (MMMMDD is September05), and no month name directly after another field. MMMMYYYY, MMMMHH24, MMMMFF and DDMON refuse until a recording shows them. The format walk pins MONFF, MMMMYYYY, MMMMFF, DDMON and MON-MM. --- .../sqlquack-rules/src/catalogue/constants.rs | 2 +- .../expressions/temporal_functions.rs | 29 +++++++++++++++---- .../src/conversion/constants.rs | 4 +-- .../src/analysis/tests/test_coverage_batch.rs | 25 ++++++++++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index 03d25ed9e..1185eb24f 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -1169,7 +1169,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: TEMPORAL_TEXT_FORMAT, constructs: &["fn:TO_VARCHAR", "fn:TO_CHAR"], - conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it. MON and MMMM, written in upper case, write the English month's abbreviation (Jan) and full name (January) in title case, as recorded for every month. MMMM directly before another field writes the name then the field (MMMMDD is September05), as recorded. A format where two adjacent fields would spell a longer run of one letter (MMMMMM, DDDD, SSSS), or where a field follows MON directly (MONMM writes the full name, as recorded), is not converted, nor any element other than these.", + conditions: "A TIME and a format of only HH24, MI, SS and FF1 to FF6 fields, truncating, as recorded; a DATE or TIMESTAMP_NTZ and a literal format of YYYY, MM and DD and, for timestamps, HH24, MI, SS and FF, FF3, FF6 and FF9 fields (each truncating, as recorded; FF writes as many digits as the TIMESTAMP_NTZ precision, recorded for 0, 3, 6 and 9), in any case, between the separators - _ / : . and space: each field zero-padded, as strftime writes it. MON and MMMM, written in upper case, write the English month's abbreviation (Jan) and full name (January) in title case, as recorded for every month. MMMM directly before DD writes the name then the day (MMMMDD is September05), as recorded. A format where two adjacent fields would spell a longer run of one letter (MMMMMM, DDDD, SSSS), where a field follows MON directly (MONMM writes the full name, as recorded), where a field other than DD follows MMMM directly, or where a month name directly follows another field, is not converted, nor any element other than these.", failures: &[], }, RuleSpec { 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 296822e8e..37b8073a9 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/temporal_functions.rs @@ -61,7 +61,7 @@ use crate::conversion::constants::{ BARE_FRACTION, BARE_FRACTION_PATTERNS, CLOCK_FORMAT_FIELDS, CURRENT_TIME_RULE, DATE_ARITHMETIC, 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_ABBREVIATION, MONTH_NAME_FIELDS, NULL_BRANCHES, NUMBER_TEXT_FORMAT, OFFSET_FREE_UNITS, + 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, @@ -1026,19 +1026,22 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { let mut rest = upper.as_str(); let mut pattern = String::new(); let mut last_letter: Option = None; - let mut after_abbreviation = false; + let mut previous: Option<&str> = None; while !rest.is_empty() { let written = &format[format.len() - rest.len()..]; if let Some(after) = rest.strip_prefix(BARE_FRACTION) && !after.starts_with(|next: char| next.is_ascii_digit()) { + if !adjacency_recorded(previous, BARE_FRACTION) { + return None; + } let (_, digits) = BARE_FRACTION_PATTERNS .iter() .find(|(digits, _)| Some(*digits) == precision)?; pattern.push_str(digits); rest = after; last_letter = None; - after_abbreviation = false; + previous = Some(BARE_FRACTION); continue; } let field = TEMPORAL_TEXT_FIELDS @@ -1047,7 +1050,7 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { match field { Some((token, replacement, _)) => { if last_letter.is_some_and(|letter| token.starts_with(letter)) - || after_abbreviation + || !adjacency_recorded(previous, token) || (MONTH_NAME_FIELDS.contains(token) && !written.starts_with(token)) { return None; @@ -1055,11 +1058,11 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { pattern.push_str(replacement); rest = &rest[token.len()..]; last_letter = token.chars().last().filter(char::is_ascii_alphabetic); - after_abbreviation = *token == MONTH_ABBREVIATION; + previous = Some(token); } None => { last_letter = None; - after_abbreviation = false; + previous = None; let separator = rest.chars().next()?; if !TEMPORAL_TEXT_SEPARATORS.contains(separator) { return None; @@ -1072,6 +1075,20 @@ fn strftime_pattern(format: &str, precision: Option) -> Option { Some(pattern) } +/// Whether `next` may follow the field `previous` with no separator between, as recordings show. +fn adjacency_recorded(previous: Option<&str>, next: &str) -> bool { + match previous { + None => true, + Some(previous) => match MONTH_NAME_FOLLOWERS + .iter() + .find(|(name, _)| *name == previous) + { + Some((_, followers)) => followers.contains(&next), + None => !MONTH_NAME_FIELDS.contains(&next), + }, + } +} + /// The value of up to two decimal digits. fn small_integer(digits: &str) -> Option { if digits.is_empty() || digits.len() > SCALE_DIGITS { diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 823e34f08..1fdc34b4b 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -895,8 +895,8 @@ pub const TEMPORAL_TEXT_FIELDS: &[(&str, &str, bool)] = &[ ]; /// Fields writing an English month name, recorded only as written in upper case. pub(crate) const MONTH_NAME_FIELDS: &[&str] = &["MMMM", "MON"]; -/// The month abbreviation, which Snowflake reads differently before another field (MONMM writes the full name). -pub(crate) const MONTH_ABBREVIATION: &str = "MON"; +/// The fields recorded directly after each month name: none after MON, whose MONMM writes the full name. +pub(crate) const MONTH_NAME_FOLLOWERS: &[(&str, &[&str])] = &[("MON", &[]), ("MMMM", &["DD"])]; /// The format element writing as many fraction digits as the timestamp's precision. pub(crate) const BARE_FRACTION: &str = "FF"; /// What FF writes for each precision recorded: none for 0, then milli-, micro- and nanoseconds. diff --git a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs index 84745b6b6..750ad4fad 100644 --- a/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs +++ b/crates/sqlquack/src/analysis/tests/test_coverage_batch.rs @@ -1606,6 +1606,31 @@ fn given_each_documented_format_element_when_writing_text_then_it_converts_as_re sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MONDD') AS v", expected_outcome: Err("which no recording shows"), }, + FirstValueTestCase { + description: "bare FF directly after MON", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'MONFF') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MMMM directly before YYYY, unrecorded", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MMMMYYYY') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "bare FF directly after MMMM, unrecorded", + sql: "SELECT TO_CHAR('2024-09-05 13:04:05.123'::TIMESTAMP_NTZ(3), 'MMMMFF') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MON directly after DD, unrecorded", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'DDMON') AS v", + expected_outcome: Err("which no recording shows"), + }, + FirstValueTestCase { + description: "MON after a separator still converts", + sql: "SELECT TO_CHAR(DATE '2024-09-05', 'MON-MM') AS v", + expected_outcome: Ok("Sep-09"), + }, FirstValueTestCase { description: "time fields of a DATE", sql: "SELECT TO_CHAR(DATE '2024-09-05', 'HH24 MI') AS v",