diff --git a/cases/temporal/subquery_operands.toml b/cases/temporal/subquery_operands.toml new file mode 100644 index 000000000..d944465a7 --- /dev/null +++ b/cases/temporal/subquery_operands.toml @@ -0,0 +1,31 @@ +category = "temporal" +construct = "fn:DATEADD" +session_profile = "utc-v1" + +[[case]] +id = "dateadd_subquery_lookback" +description = "An incremental lookback: the rows within three days of the latest, through DATEADD of a scalar subquery." +columns = [{ name = "k", type = "NUMBER(38,0)" }, { name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["1", "'2026-01-10 00:00:00.123456789'"], + ["2", "'2026-01-02 00:00:00'"], + ["3", "'2026-01-07 00:00:00.123456789'"], + ["4", "'2026-01-07 00:00:00.123456788'"], + ["5", "NULL"], +] +query = "SELECT k FROM input WHERE t >= DATEADD(DAY, -3, (SELECT MAX(t) FROM input)) ORDER BY k" +order = "ordered" +tags = ["null", "boundary"] + +[[case]] +id = "dateadd_subquery_nested" +description = "DATEADD of DATEADD of a scalar subquery keeps the nanoseconds of the latest row." +columns = [{ name = "k", type = "NUMBER(38,0)" }, { name = "t", type = "TIMESTAMP_NTZ(9)" }] +rows = [ + ["1", "'2026-01-10 00:00:00.123456789'"], + ["2", "'2026-01-02 00:00:00'"], + ["3", "NULL"], +] +query = "SELECT k, DATEADD(HOUR, 37, DATEADD(DAY, -3, (SELECT MAX(t) FROM input))) AS cutoff FROM input ORDER BY k" +order = "ordered" +tags = ["null", "boundary"] diff --git a/coverage/translation/results-baseline.json b/coverage/translation/results-baseline.json index 5db61ba35..9e7b25139 100644 --- a/coverage/translation/results-baseline.json +++ b/coverage/translation/results-baseline.json @@ -7480,6 +7480,8 @@ "temporal/parse_followups.toml#pre_epoch_nanos_to_timestamp_ntz": "matching", "temporal/parse_followups.toml#pre_epoch_nanos_try_to_timestamp_ntz": "matching", "temporal/parse_followups.toml#try_parse_date_words": "matching", + "temporal/subquery_operands.toml#dateadd_subquery_lookback": "matching", + "temporal/subquery_operands.toml#dateadd_subquery_nested": "matching", "temporal/system_typeof.toml#system_typeof_date_add_widening": "matching", "temporal/system_typeof.toml#system_typeof_temporal_stripped": "matching", "temporal/time_batch2.toml#dateadd_milliseconds_to_time": "matching", diff --git a/crates/sqlquack-core/src/refusals/constants.rs b/crates/sqlquack-core/src/refusals/constants.rs index fbef1586a..fa89bf905 100644 --- a/crates/sqlquack-core/src/refusals/constants.rs +++ b/crates/sqlquack-core/src/refusals/constants.rs @@ -2,3 +2,5 @@ /// The words before the divergence id in a refused divergence's reason. pub const NEEDS_DIVERGENCE: &str = " needs known divergence `"; +/// The words after the divergence id in a refused divergence's reason. +pub const REFUSED_IN_MODE: &str = "`, which the policy refuses in this mode"; diff --git a/crates/sqlquack-core/src/refusals/main/outside_conditions.rs b/crates/sqlquack-core/src/refusals/main/outside_conditions.rs index c2a503e5b..5c0d11a2a 100644 --- a/crates/sqlquack-core/src/refusals/main/outside_conditions.rs +++ b/crates/sqlquack-core/src/refusals/main/outside_conditions.rs @@ -1,14 +1,19 @@ //! `outside_conditions`: the refusal of a construct outside its rule's operand types. use crate::classification::models::Construct; +use crate::refusals::constants::{NEEDS_DIVERGENCE, REFUSED_IN_MODE}; use crate::refusals::models::{Refusal, RefusalCategory}; -/// A rule covers the construct, but not for these operand types. +/// A rule covers the construct, but not these operands; a refused divergence's reason keeps its code. pub fn outside_conditions(construct: Construct, reason: String) -> Refusal { + let category = match reason.contains(NEEDS_DIVERGENCE) && reason.ends_with(REFUSED_IN_MODE) { + true => RefusalCategory::DivergenceRefused, + false => RefusalCategory::TypeCondition, + }; Refusal { construct: Some(construct), location: None, - category: RefusalCategory::TypeCondition, + category, reason, } } diff --git a/crates/sqlquack-core/src/refusals/main/refused_divergence.rs b/crates/sqlquack-core/src/refusals/main/refused_divergence.rs index bb40b4e10..6c8c867af 100644 --- a/crates/sqlquack-core/src/refusals/main/refused_divergence.rs +++ b/crates/sqlquack-core/src/refusals/main/refused_divergence.rs @@ -1,14 +1,14 @@ //! `refused_divergence`: the refusal of a construct whose conversion needs a refused divergence. use crate::classification::models::Construct; -use crate::refusals::constants::NEEDS_DIVERGENCE; +use crate::refusals::constants::{NEEDS_DIVERGENCE, REFUSED_IN_MODE}; use crate::refusals::models::{Refusal, RefusalCategory}; /// The construct converts only by relying on `divergence`, which the policy refuses in this mode. pub fn refused_divergence(construct: Construct, divergence: &str) -> Refusal { Refusal { reason: format!( - "{}{NEEDS_DIVERGENCE}{divergence}`, which the policy refuses in this mode", + "{}{NEEDS_DIVERGENCE}{divergence}{REFUSED_IN_MODE}", construct.key() ), construct: Some(construct), diff --git a/crates/sqlquack-duckdb/src/checking/_helpers/lambda_bodies.rs b/crates/sqlquack-duckdb/src/checking/_helpers/lambda_bodies.rs new file mode 100644 index 000000000..2e45ffeca --- /dev/null +++ b/crates/sqlquack-duckdb/src/checking/_helpers/lambda_bodies.rs @@ -0,0 +1,148 @@ +//! Lambda bodies in printed SQL, and the words they read, outside literals and quoted names. + +use std::ops::Range; + +use crate::checking::constants::LAMBDA_KEYWORD; + +/// One lambda body: its byte range and the parameters it binds. +pub(crate) struct Body { + pub(crate) range: Range, + pub(crate) parameters: Vec, +} + +/// The lambda bodies in `sql` holding byte `at`. +pub(crate) fn bodies_around(bodies: &[Body], at: usize) -> Vec<&Body> { + bodies + .iter() + .filter(|body| body.range.contains(&at)) + .collect() +} + +/// Whether `name` stands as a whole word in `sql`, outside string literals and quoted identifiers. +pub(crate) fn reads_word(sql: &str, name: &str) -> bool { + let quoted = quoted_ranges(sql); + sql.match_indices(name) + .any(|(start, _)| whole_word(sql, start..start + name.len(), "ed)) +} + +/// Whether `range` of `sql` is a whole word outside every range in `quoted`. +fn whole_word(sql: &str, range: Range, quoted: &[Range]) -> bool { + let word = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_'; + let bytes = sql.as_bytes(); + !bytes[..range.start].last().copied().is_some_and(word) + && !bytes.get(range.end).copied().is_some_and(word) + && !quoted.iter().any(|span| span.contains(&range.start)) +} + +/// The byte ranges of string literals and quoted identifiers in `sql`. +fn quoted_ranges(sql: &str) -> Vec> { + let mut ranges: Vec> = Vec::new(); + let mut open: Option<(u8, usize)> = None; + for (index, byte) in sql.bytes().enumerate() { + match (open, byte) { + (Some((quote, start)), _) if byte == quote => { + ranges.push(start..index + 1); + open = None; + } + (None, b'\'' | b'"') => open = Some((byte, index)), + _ => {} + } + } + ranges +} + +/// The lambda bodies in `sql`, outside string literals and quoted identifiers. +pub(crate) fn lambda_bodies(sql: &str) -> Vec { + let bytes = sql.as_bytes(); + let mut scan = Scan::default(); + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + match (scan.quote, byte) { + (Some(open), _) => scan.quote = (byte != open).then_some(open), + (None, b'\'' | b'"') => scan.quote = Some(byte), + (None, b'(' | b'[' | b'{') => scan.depth += 1, + (None, b')' | b']' | b'}') => { + scan.close(index); + scan.depth = scan.depth.saturating_sub(1); + } + (None, b',') if scan.pending.is_none() => scan.close(index), + (None, b':') if scan.pending.is_some_and(|(depth, _)| depth == scan.depth) => { + scan.open_body(sql, index); + } + (None, _) if keyword_at(sql, index) => { + index += LAMBDA_KEYWORD.len(); + scan.pending = Some((scan.depth, index)); + continue; + } + (None, _) => {} + } + index += 1; + } + scan.close_all(bytes.len()); + scan.bodies +} + +/// Whether the lambda keyword starts at byte `at` of `sql`, as a word. +fn keyword_at(sql: &str, at: usize) -> bool { + sql[at..].starts_with(LAMBDA_KEYWORD) + && !sql[..at] + .bytes() + .next_back() + .is_some_and(|before| before.is_ascii_alphanumeric() || before == b'_') +} + +/// The state of a scan for lambda bodies. +#[derive(Default)] +struct Scan { + /// The quote character of the literal or identifier the scan is in. + quote: Option, + /// How many brackets are open. + depth: usize, + /// The depth of a lambda whose parameters' colon is still to come, and where they start. + pending: Option<(usize, usize)>, + /// The depth, start and parameters of each body still open, innermost last. + open: Vec<(usize, usize, Vec)>, + bodies: Vec, +} + +impl Scan { + /// Opens the body of the pending lambda after its colon at byte `colon` of `sql`. + fn open_body(&mut self, sql: &str, colon: usize) { + if let Some((depth, start)) = self.pending.take() { + let parameters: Vec = sql[start..colon] + .split(',') + .map(|name| name.trim().to_owned()) + .collect(); + self.open.push((depth, colon + 1, parameters)); + } + } + + /// Ends the bodies open at the current depth, at byte `end`. + fn close(&mut self, end: usize) { + while self + .open + .last() + .is_some_and(|(depth, _, _)| *depth == self.depth) + { + self.end_innermost(end); + } + } + + /// Ends every body still open, at byte `end`. + fn close_all(&mut self, end: usize) { + while !self.open.is_empty() { + self.end_innermost(end); + } + } + + /// Ends the innermost open body at byte `end`. + fn end_innermost(&mut self, end: usize) { + if let Some((_, start, parameters)) = self.open.pop() { + self.bodies.push(Body { + range: start..end, + parameters, + }); + } + } +} diff --git a/crates/sqlquack-duckdb/src/checking/_helpers/mod.rs b/crates/sqlquack-duckdb/src/checking/_helpers/mod.rs index 0f5190c22..c9ec5ce73 100644 --- a/crates/sqlquack-duckdb/src/checking/_helpers/mod.rs +++ b/crates/sqlquack-duckdb/src/checking/_helpers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod binding; pub(crate) mod dotted_parameters; #[cfg(feature = "engine")] pub(crate) mod engine; +pub(crate) mod lambda_bodies; pub(crate) mod lexing; #[cfg(feature = "engine")] pub(crate) mod tables; diff --git a/crates/sqlquack-duckdb/src/checking/main/bound_outside_lambdas.rs b/crates/sqlquack-duckdb/src/checking/main/bound_outside_lambdas.rs new file mode 100644 index 000000000..b191fb468 --- /dev/null +++ b/crates/sqlquack-duckdb/src/checking/main/bound_outside_lambdas.rs @@ -0,0 +1,36 @@ +//! `bound_outside_lambdas`: a value DuckDB cannot bind inside a lambda, bound once outside them. + +use crate::checking::_helpers::lambda_bodies::{Body, bodies_around, lambda_bodies, reads_word}; +use crate::checking::constants::LAMBDA_KEYWORD; + +/// `sql` with `value` bound outside the lambdas holding it, if any; or the lambda parameter it reads. +pub fn bound_outside_lambdas( + sql: &str, + value: &str, + parameter: &str, +) -> Result, String> { + let bodies = lambda_bodies(sql); + let starts: Vec = sql.match_indices(value).map(|(at, _)| at).collect(); + let enclosing: Vec<&Body> = starts + .iter() + .flat_map(|at| bodies_around(&bodies, *at)) + .collect(); + if enclosing.is_empty() { + return Ok(None); + } + let own: Vec = lambda_bodies(value) + .into_iter() + .flat_map(|body| body.parameters) + .collect(); + let read = enclosing + .iter() + .flat_map(|body| &body.parameters) + .find(|name| !own.contains(name) && reads_word(value, name)); + match read { + Some(name) => Err(name.clone()), + None => Ok(Some(format!( + "list_transform([{value}], {LAMBDA_KEYWORD}{parameter}: {})[1]", + sql.replace(value, parameter) + ))), + } +} diff --git a/crates/sqlquack-duckdb/src/checking/main/mod.rs b/crates/sqlquack-duckdb/src/checking/main/mod.rs index 008a8d8ff..524668cd3 100644 --- a/crates/sqlquack-duckdb/src/checking/main/mod.rs +++ b/crates/sqlquack-duckdb/src/checking/main/mod.rs @@ -1,3 +1,4 @@ //! Entry points. +pub mod bound_outside_lambdas; pub mod check_printed; diff --git a/crates/sqlquack-duckdb/src/checking/tests.rs b/crates/sqlquack-duckdb/src/checking/tests.rs index 554179272..4e9b80cb6 100644 --- a/crates/sqlquack-duckdb/src/checking/tests.rs +++ b/crates/sqlquack-duckdb/src/checking/tests.rs @@ -1,5 +1,7 @@ #[path = "tests/helpers.rs"] mod helpers; +#[path = "tests/test_bound_outside_lambdas.rs"] +mod test_bound_outside_lambdas; #[path = "tests/test_check_printed.rs"] mod test_check_printed; #[path = "tests/test_types.rs"] diff --git a/crates/sqlquack-duckdb/src/checking/tests/test_bound_outside_lambdas.rs b/crates/sqlquack-duckdb/src/checking/tests/test_bound_outside_lambdas.rs new file mode 100644 index 000000000..a7576e26a --- /dev/null +++ b/crates/sqlquack-duckdb/src/checking/tests/test_bound_outside_lambdas.rs @@ -0,0 +1,98 @@ +use crate::checking::errors::CheckStage; +use crate::checking::main::bound_outside_lambdas::bound_outside_lambdas; +use crate::checking::tests::helpers::failed_stage; +use crate::checking::tests::test_types::BindingTestCase; + +const LATEST: &str = "(SELECT max(\"PLACED\") FROM \"ORDERS\")"; + +#[test] +fn given_a_subquery_in_a_lambda_body_when_bound_outside_lambdas_then_duckdb_binds_it() { + let test_cases = [ + BindingTestCase { + description: "a subquery read in a lambda body is bound outside it", + sql: "list_transform([1], lambda n: (SELECT max(\"PLACED\") FROM \"ORDERS\") \ + + to_days(n))", + value: LATEST, + expected_binding: Ok(true), + expected_stages: (Some(CheckStage::Bind), None), + }, + BindingTestCase { + description: "inner lambdas read the outer parameter, two levels deep", + sql: "list_transform([1], lambda n: list_transform([n], lambda m: \ + (SELECT max(\"PLACED\") FROM \"ORDERS\") + to_days(m))[1])[1]", + value: LATEST, + expected_binding: Ok(true), + expected_stages: (Some(CheckStage::Bind), None), + }, + BindingTestCase { + description: "a subquery only in the list a lambda reads is left as it is", + sql: "list_transform([(SELECT max(\"PLACED\") FROM \"ORDERS\")], lambda n: \ + n)[1]", + value: LATEST, + expected_binding: Ok(false), + expected_stages: (None, None), + }, + BindingTestCase { + description: "a comma at the lambda's depth ends its body, before the initial value", + sql: "list_reduce([1, 2], lambda a, b: a + b, \ + epoch_us((SELECT max(\"PLACED\") FROM \"ORDERS\")))", + value: "epoch_us((SELECT max(\"PLACED\") FROM \"ORDERS\"))", + expected_binding: Ok(false), + expected_stages: (None, None), + }, + BindingTestCase { + description: "lambda text and brackets inside literals and quoted names do not count", + sql: "'lambda x: (' AS \"lambda y: [\", (SELECT max(\"PLACED\") FROM \"ORDERS\") \ + ", + value: LATEST, + expected_binding: Ok(false), + expected_stages: (None, None), + }, + BindingTestCase { + description: "a subquery reading the parameter of the lambda around it cannot move", + sql: "list_transform([1], lambda n: (SELECT n + 1))[1]", + value: "(SELECT n + 1)", + expected_binding: Err("n"), + expected_stages: (Some(CheckStage::Bind), Some(CheckStage::Bind)), + }, + BindingTestCase { + description: "a lambda parameter list's comma does not end the body around it", + sql: "list_transform([1], lambda n: list_reduce([n, 2], lambda a, b: a + b + \ + epoch_us((SELECT max(\"PLACED\") FROM \"ORDERS\"))))[1]", + value: "(SELECT max(\"PLACED\") FROM \"ORDERS\")", + expected_binding: Ok(true), + expected_stages: (Some(CheckStage::Bind), None), + }, + BindingTestCase { + description: "a parameter the value binds itself is not read from outside", + sql: "list_transform([1], lambda n: (SELECT list_transform([1], lambda n: n)[1]))[1]", + value: "(SELECT list_transform([1], lambda n: n)[1])", + expected_binding: Ok(true), + expected_stages: (Some(CheckStage::Bind), None), + }, + ]; + for test_case in test_cases { + let binding = bound_outside_lambdas(test_case.sql, test_case.value, "subquery_value_1"); + assert_eq!( + binding + .as_ref() + .map(Option::is_some) + .map_err(String::as_str), + test_case.expected_binding, + "{}", + test_case.description + ); + let printed = binding + .ok() + .flatten() + .unwrap_or_else(|| test_case.sql.to_owned()); + let checked = + |expression: &str| failed_stage(&format!("SELECT {expression} FROM \"ORDERS\"")); + assert_eq!( + (checked(test_case.sql), checked(&printed)), + test_case.expected_stages, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-duckdb/src/checking/tests/test_types.rs b/crates/sqlquack-duckdb/src/checking/tests/test_types.rs index 4e5c89aba..7f522ff98 100644 --- a/crates/sqlquack-duckdb/src/checking/tests/test_types.rs +++ b/crates/sqlquack-duckdb/src/checking/tests/test_types.rs @@ -12,3 +12,14 @@ pub(crate) struct RelationsTestCase { pub(crate) relations: &'static [&'static str], pub(crate) expected_stage: Option, } + +/// A printed expression and the subquery value in it, and how the value must be bound. +pub(crate) struct BindingTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) value: &'static str, + /// Whether the value moves outside the lambdas, or the lambda parameter that keeps it in. + pub(crate) expected_binding: Result, + /// DuckDB's verdict on the SQL as printed, and then as rewritten. + pub(crate) expected_stages: (Option, Option), +} diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs index 978468802..e42fe157a 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/atoms.rs @@ -249,6 +249,7 @@ pub(crate) fn scalar_subquery(payload: &Value, site: Site<'_>) -> Result Ok(Emitted { held: column.holding, @@ -256,7 +257,7 @@ pub(crate) fn scalar_subquery(payload: &Value, site: Site<'_>) -> Result Err(outside_conditions( construct, diff --git a/crates/sqlquack-rules/src/conversion/_helpers/expressions/predicates.rs b/crates/sqlquack-rules/src/conversion/_helpers/expressions/predicates.rs index 30a1befad..a70f4f289 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/expressions/predicates.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/expressions/predicates.rs @@ -100,10 +100,8 @@ pub(crate) fn exists(payload: &Value, site: Site<'_>) -> Result, held: bool) -> Result, held: bool) -> Result Ok(emitted), (true, true) => { @@ -96,6 +105,33 @@ fn converted(node: &Value, site: Site<'_>, held: bool) -> Result Result { + subqueries + .since(mark) + .into_iter() + .try_fold(sql, |sql, value| { + let refused = |reason: String| Refusal { + construct: Some(node_construct("subquery")), + location: value.location, + category: RefusalCategory::NotExpressible, + reason, + }; + match bound_outside_lambdas(&sql, &value.sql, &subqueries.parameter()) { + Ok(None) => Ok(sql), + Err(_parameter) => Err(refused(SUBQUERY_READS_LAMBDA.to_owned())), + Ok(Some(_)) if value.volatile => { + Err(refused(VOLATILE_SUBQUERY_IN_LAMBDA.to_owned())) + } + Ok(Some(bound)) => Ok(bound), + } + }) +} + /// Whether `node` passes an unordered value up as it is: a column read or a branch choice. fn keeps_order(node: &Value) -> bool { matches!(node_kind(node), Some(("column", _))) || passes_order(node) @@ -177,9 +213,50 @@ fn located(node: &Value, site: Site<'_>) -> Result { ); let mut emitted = converted.map_err(|refusal| locator.placed(refusal, node))?; emitted.sql = locator.identified(&emitted.sql, node); + if reads_subquery(node) { + site.context.subqueries.record(SubqueryValue { + sql: emitted.sql.clone(), + volatile: calls_volatile(node, site.context), + location: locator.locate(node), + }); + } Ok(emitted) } +/// Whether `node` is a value DuckDB computes with a subquery: a scalar subquery, EXISTS or IN one. +fn reads_subquery(node: &Value) -> bool { + match node_kind(node) { + Some(("subquery" | "exists", _)) => true, + Some(("in", payload)) => payload.get("query").is_some_and(|query| !query.is_null()), + _ => false, + } +} + +/// Whether `node` may call a non-deterministic built-in, a volatile project function or an override. +fn calls_volatile(node: &Value, context: &Context<'_>) -> bool { + match node { + Value::Object(fields) => fields.iter().any(|(kind, value)| { + let called = (kind == CALL_KIND || kind == AGGREGATE_CALL_KIND) + .then(|| call_name(value)) + .is_some_and(|name| volatile_call(&name, context)); + called || calls_volatile(value, context) + }), + Value::Array(items) => items.iter().any(|item| calls_volatile(item, context)), + _ => false, + } +} + +/// Whether a call to `name` may return another value each time. +fn volatile_call(name: &str, context: &Context<'_>) -> bool { + NONDETERMINISTIC_FUNCTIONS.contains(&name) + || context.options.overrides.contains_key(name) + || context + .schema + .functions + .get(name) + .is_some_and(|spec| spec.traits.volatility != Volatility::Immutable) +} + /// The construct a VARIANT-held value was built by, which a reader cannot take. fn held_construct(node: &Value) -> Construct { match node_kind(node) { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs index 711a8fa09..84fca326e 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs @@ -1,5 +1,6 @@ //! Name resolution: relations in scope, CTEs, select-list aliases and outer queries. +use sqlquack_core::parsing::models::SourceLocation; use sqlquack_core::provenance::models::ProvenanceLog; use sqlquack_core::refusals::main::outside_conditions::outside_conditions; use sqlquack_core::refusals::models::Refusal; @@ -14,6 +15,7 @@ use sqlquack_snowflake::typing::models::SnowflakeType; use crate::conversion::_helpers::support::locations::Locator; use crate::conversion::_helpers::support::tree::node_construct; +use crate::conversion::constants::SUBQUERY_PARAMETER_PREFIX; use crate::conversion::models::ConversionOptions; use sqlquack_semantics::types::models::{ColumnSpec, Holding, Schema}; @@ -166,6 +168,8 @@ pub(crate) struct Context<'a> { pub(crate) typed: &'a TypedLeaves, /// Unordered values passed up unchecked so far, to tell what a conversion passed up. pub(crate) order: &'a OrderTrack, + /// The subquery values emitted so far, which DuckDB cannot bind inside a lambda body. + pub(crate) subqueries: &'a SubqueryTrack, /// Whether query results may keep unordered arrays: CTE and FROM subquery bodies. pub(crate) free_projections: bool, /// Whether this converts a case's input fixture, whose literals stand for source data. @@ -195,6 +199,52 @@ impl OrderTrack { } } +/// What a statement's conversion tracks as it goes: unordered values passed up, subqueries emitted. +#[derive(Debug, Default)] +pub(crate) struct StatementTracks { + pub(crate) order: OrderTrack, + pub(crate) subqueries: SubqueryTrack, +} + +/// A subquery value emitted in a statement's conversion. +#[derive(Clone, Debug)] +pub(crate) struct SubqueryValue { + pub(crate) sql: String, + /// Whether it may call a function whose result can change between calls. + pub(crate) volatile: bool, + pub(crate) location: Option, +} + +/// The subquery values a statement's conversion emitted, in order, and the parameters it bound. +#[derive(Debug, Default)] +pub(crate) struct SubqueryTrack { + values: std::cell::RefCell>, + bound: std::cell::Cell, +} + +impl SubqueryTrack { + /// A subquery value emitted. + pub(crate) fn record(&self, value: SubqueryValue) { + self.values.borrow_mut().push(value); + } + + /// The count so far. + pub(crate) fn mark(&self) -> usize { + self.values.borrow().len() + } + + /// The values emitted since `mark`. + pub(crate) fn since(&self, mark: usize) -> Vec { + self.values.borrow()[mark..].to_vec() + } + + /// A lambda parameter name not yet used in the statement. + pub(crate) fn parameter(&self) -> String { + self.bound.set(self.bound.get() + 1); + format!("{SUBQUERY_PARAMETER_PREFIX}{}", self.bound.get()) + } +} + impl Context<'_> { /// The same context for a query whose results may, with `free`, keep unordered arrays. pub(crate) fn projecting(&self, free: bool) -> Self { diff --git a/crates/sqlquack-rules/src/conversion/constants.rs b/crates/sqlquack-rules/src/conversion/constants.rs index 4db9a4fc7..6269405a0 100644 --- a/crates/sqlquack-rules/src/conversion/constants.rs +++ b/crates/sqlquack-rules/src/conversion/constants.rs @@ -44,6 +44,28 @@ pub const TIMESTAMP_NTZ_NAME: &str = "TIMESTAMP_NTZ(9)"; pub const LAMBDA_KIND: &str = "lambda"; /// What a lambda parameter's DuckDB name starts with. pub const LAMBDA_PARAMETER_PREFIX: &str = "lambda_"; +/// What the DuckDB name of a lambda parameter holding a subquery's value starts with. +pub const SUBQUERY_PARAMETER_PREFIX: &str = "subquery_value_"; +/// Built-in functions whose result can change between calls, which a subquery must not share. +pub const NONDETERMINISTIC_FUNCTIONS: &[&str] = &[ + "RANDOM", + "RANDSTR", + "UUID_STRING", + "NORMAL", + "UNIFORM", + "ZIPF", + "SEQ1", + "SEQ2", + "SEQ4", + "SEQ8", +]; +/// Why a subquery reading a lambda's parameter is refused where the conversion wraps it in one. +pub const SUBQUERY_READS_LAMBDA: &str = "a subquery that reads a lambda's parameter, which \ + DuckDB cannot bind inside the lambda, so it cannot be computed once outside it either"; +/// Why a subquery calling a volatile function is refused where the conversion wraps it in a lambda. +pub const VOLATILE_SUBQUERY_IN_LAMBDA: &str = "a subquery calling a function whose result can \ + change between calls, inside a lambda DuckDB cannot bind it in; computing it once outside \ + could change how often it runs compared with Snowflake"; /// The window aggregate that converts with DISTINCT, over whole partitions. pub const DISTINCT_WINDOW_AGGREGATE: &str = "count"; /// The named aggregates that convert over a window. diff --git a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs index 601f0129f..66207cfb8 100644 --- a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs +++ b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs @@ -10,7 +10,7 @@ use crate::conversion::_helpers::queries::query::convert_query; use crate::conversion::_helpers::support::collecting::{Collector, collected}; use crate::conversion::_helpers::support::depth::within_depth; use crate::conversion::_helpers::support::locations::Locator; -use crate::conversion::_helpers::support::scope::{Context, OrderTrack}; +use crate::conversion::_helpers::support::scope::{Context, StatementTracks}; use crate::conversion::_helpers::support::tree::{session_tree, strip_positions}; use crate::conversion::_helpers::support::typed_leaves::ConvertedQuery; use crate::conversion::main::convert_statement::convert_statement; @@ -76,7 +76,7 @@ fn collected_conversion( let locator = Locator::new(&tree, positioned, lines); let log = ProvenanceLog::default(); let typed = TypedLeaves::default(); - let order = OrderTrack::default(); + let tracks = StatementTracks::default(); let context = Context { log: &log, schema, @@ -84,7 +84,8 @@ fn collected_conversion( ctes: Vec::new(), locator: &locator, typed: &typed, - order: &order, + order: &tracks.order, + subqueries: &tracks.subqueries, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs index bef66e1e1..82c313b33 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs @@ -11,7 +11,7 @@ use crate::conversion::_helpers::queries::function_body::{ }; use crate::conversion::_helpers::support::depth::positioned; use crate::conversion::_helpers::support::locations::Locator; -use crate::conversion::_helpers::support::scope::{Context, OrderTrack}; +use crate::conversion::_helpers::support::scope::{Context, StatementTracks}; use crate::conversion::_helpers::support::tree::session_tree; use crate::conversion::models::{ConversionOptions, ConvertedFunction}; use sqlquack_semantics::types::models::TypedLeaves; @@ -32,7 +32,7 @@ pub fn convert_function_body( .map_err(|reason| refused(&reason))?; let locator = Locator::new(&tree, &positioned, lines); let typed = TypedLeaves::default(); - let order = OrderTrack::default(); + let tracks = StatementTracks::default(); let context = Context { log: &log, schema, @@ -40,7 +40,8 @@ pub fn convert_function_body( ctes: Vec::new(), locator: &locator, typed: &typed, - order: &order, + order: &tracks.order, + subqueries: &tracks.subqueries, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index 09a1cf518..d6d8d2737 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -18,7 +18,7 @@ use crate::conversion::_helpers::queries::function_body::{ use crate::conversion::_helpers::queries::query::convert_query; use crate::conversion::_helpers::support::depth::positioned; use crate::conversion::_helpers::support::locations::Locator; -use crate::conversion::_helpers::support::scope::{Context, OrderTrack}; +use crate::conversion::_helpers::support::scope::{Context, StatementTracks}; use crate::conversion::_helpers::support::tree::{node_construct, session_tree}; use crate::conversion::constants::OUTPUT_ALIAS; use crate::conversion::models::{ConversionOptions, Converted}; @@ -37,7 +37,7 @@ pub fn convert_statement( .map_err(|reason| outside_conditions(node_construct("statement"), reason))?; let locator = Locator::new(&tree, &positioned, lines); let typed = TypedLeaves::default(); - let order = OrderTrack::default(); + let tracks = StatementTracks::default(); let context = Context { log: &log, schema, @@ -45,7 +45,8 @@ pub fn convert_statement( ctes: Vec::new(), locator: &locator, typed: &typed, - order: &order, + order: &tracks.order, + subqueries: &tracks.subqueries, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack/src/analysis/tests/test_composition.rs b/crates/sqlquack/src/analysis/tests/test_composition.rs index 1939e6eb6..f0803570f 100644 --- a/crates/sqlquack/src/analysis/tests/test_composition.rs +++ b/crates/sqlquack/src/analysis/tests/test_composition.rs @@ -3,6 +3,9 @@ use std::time::Duration; use crate::analysis::tests::helpers::{timed_conversion, unpacking_query}; use crate::analysis::tests::test_types::{CompositionTestCase, LargeLiteralTestCase}; +/// The least base time growth is measured from, so tiny timings do not swing the ratio. +const TIMING_FLOOR: Duration = Duration::from_millis(50); + #[test] fn given_composed_variant_text_when_converting_then_sql_and_time_grow_linearly() { let test_cases = [ @@ -10,25 +13,20 @@ fn given_composed_variant_text_when_converting_then_sql_and_time_grow_linearly() description: "one column, text written through nested PARSE_JSON and TO_VARCHAR", columns: 1, depths: [1, 4, 8], - expected_budget_seconds: 2, + expected_max_time_ratio: 16, }, CompositionTestCase { description: "sixteen columns of flattened elements, as staging models unpack them", columns: 16, depths: [1, 2, 3], - expected_budget_seconds: 8, + expected_max_time_ratio: 6, }, ]; for test_case in test_cases { - let [low, middle, high] = test_case.depths.map(|depth| { + let [(low, low_time), (middle, _), (high, high_time)] = test_case.depths.map(|depth| { let (sql, elapsed) = timed_conversion(&unpacking_query(test_case.columns, depth)); let sql = sql.unwrap_or_else(|| panic!("{}: converts", test_case.description)); - assert!( - elapsed < Duration::from_secs(test_case.expected_budget_seconds), - "{}: depth {depth} took {elapsed:?}", - test_case.description - ); - sql.len() + (sql.len(), elapsed) }); let [first, second, third] = test_case.depths; assert_eq!( @@ -37,6 +35,11 @@ fn given_composed_variant_text_when_converting_then_sql_and_time_grow_linearly() "{}: each level must add the same SQL: {low}, {middle}, {high}", test_case.description ); + assert!( + high_time <= low_time.max(TIMING_FLOOR) * test_case.expected_max_time_ratio, + "{}: depth {third} took {high_time:?} against {low_time:?} at depth {first}", + test_case.description + ); } } diff --git a/crates/sqlquack/src/analysis/tests/test_divergence_modes.rs b/crates/sqlquack/src/analysis/tests/test_divergence_modes.rs index 5d49ae896..0d809f979 100644 --- a/crates/sqlquack/src/analysis/tests/test_divergence_modes.rs +++ b/crates/sqlquack/src/analysis/tests/test_divergence_modes.rs @@ -85,7 +85,7 @@ fn given_a_known_divergence_when_converting_then_default_mode_warns_and_strict_m expected_outcome: DivergenceModeOutcome { converted: false, divergences: vec![], - categories: vec![RefusalCategory::TypeCondition], + categories: vec![RefusalCategory::DivergenceRefused], }, }, DivergenceModeTestCase { @@ -115,7 +115,7 @@ fn given_a_known_divergence_when_converting_then_default_mode_warns_and_strict_m expected_outcome: DivergenceModeOutcome { converted: false, divergences: vec![], - categories: vec![RefusalCategory::TypeCondition], + categories: vec![RefusalCategory::DivergenceRefused], }, }, DivergenceModeTestCase { @@ -210,7 +210,7 @@ fn given_a_known_divergence_when_converting_then_default_mode_warns_and_strict_m expected_outcome: DivergenceModeOutcome { converted: false, divergences: vec![], - categories: vec![RefusalCategory::TypeCondition], + categories: vec![RefusalCategory::DivergenceRefused], }, }, DivergenceModeTestCase { diff --git a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs index 0cc20b605..28fdb2a88 100644 --- a/crates/sqlquack/src/analysis/tests/test_session_timezone.rs +++ b/crates/sqlquack/src/analysis/tests/test_session_timezone.rs @@ -24,7 +24,7 @@ fn given_session_dependent_statements_when_converting_then_they_rely_on_a_utc_se expected_outcome: DivergenceModeOutcome { converted: false, divergences: vec![], - categories: vec![RefusalCategory::TypeCondition], + categories: vec![RefusalCategory::DivergenceRefused], }, }, SessionTimeZoneTestCase { diff --git a/crates/sqlquack/src/analysis/tests/test_types.rs b/crates/sqlquack/src/analysis/tests/test_types.rs index e64bb1af6..a4bd3cc7b 100644 --- a/crates/sqlquack/src/analysis/tests/test_types.rs +++ b/crates/sqlquack/src/analysis/tests/test_types.rs @@ -81,7 +81,8 @@ pub(crate) struct CompositionTestCase { pub(crate) description: &'static str, pub(crate) columns: usize, pub(crate) depths: [usize; 3], - pub(crate) expected_budget_seconds: u64, + /// How many times the time at the first depth the last depth may take: linear, with room for noise. + pub(crate) expected_max_time_ratio: u32, } pub(crate) struct BudgetTestCase { diff --git a/crates/sqlquack/tests/cli/fixtures/volatile_lookback.json b/crates/sqlquack/tests/cli/fixtures/volatile_lookback.json new file mode 100644 index 000000000..cc122d68b --- /dev/null +++ b/crates/sqlquack/tests/cli/fixtures/volatile_lookback.json @@ -0,0 +1,2 @@ +{"relations": {"EVENTS": [["EVENT_ID", "NUMBER(38,0)"], ["OCCURRED_AT", "TIMESTAMP_NTZ(9)"]]}, + "functions": {"LOOKBACK": {"language": "sql", "arguments": [], "returns": "NUMBER(1,0)"}}} diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 4530ee709..304766dd3 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -339,6 +339,21 @@ fn given_snowflake_sql_when_converting_then_duckdb_sql_or_named_refusals_are_pri mention: "not WEEK_START; pass --policy", expected_outcome: convert_outcome(1, false), }, + ConvertTestCase { + description: "a subquery reading a lambda's parameter refuses: it cannot be bound outside", + sql: "SELECT TRANSFORM(SPLIT('a,b', ','), x VARCHAR -> (SELECT UPPER(x))) AS t", + flags: &[], + mention: "a subquery that reads a lambda's parameter", + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a subquery calling a volatile function refuses where a lambda wraps it", + sql: "SELECT DATEADD(day, -3, (SELECT MAX(occurred_at) FROM events \ + WHERE event_id > LOOKBACK())) AS d", + flags: &["--schema", "tests/cli/fixtures/volatile_lookback.json"], + mention: "a subquery calling a function whose result can change between calls", + expected_outcome: convert_outcome(1, false), + }, ConvertTestCase { description: "comment-only input is a usage error", sql: "-- no statement here\n", @@ -1013,6 +1028,37 @@ fn given_recorded_shapes_when_converting_then_they_convert_or_refuse_as_recorded } } +#[test] +fn given_a_subquery_operand_when_converting_and_running_then_it_is_bound_outside_lambdas() { + let test_cases = [ + ValueTestCase { + description: "an incremental lookback from the latest row keeps the last three days", + sql: "WITH events AS (SELECT 1 AS event_id, '2026-01-10 00:00:00'::TIMESTAMP_NTZ AS \ + occurred_at UNION ALL SELECT 2, '2026-01-02 00:00:00'::TIMESTAMP_NTZ UNION ALL \ + SELECT 3, '2026-01-08 12:00:00'::TIMESTAMP_NTZ) SELECT COUNT(*)::VARCHAR AS n FROM events \ + WHERE occurred_at >= DATEADD(day, -3, (SELECT MAX(occurred_at) FROM events))", + expected_value: "2", + }, + ValueTestCase { + description: "a DATEADD of a DATEADD of a subquery binds it once, outside both", + sql: "WITH events AS (SELECT 1 AS event_id, '2026-01-10 00:00:00'::TIMESTAMP_NTZ AS \ + occurred_at UNION ALL SELECT 2, '2026-01-02 00:00:00'::TIMESTAMP_NTZ UNION ALL \ + SELECT 3, '2026-01-08 12:00:00'::TIMESTAMP_NTZ) SELECT COUNT(*)::VARCHAR AS n FROM events \ + WHERE occurred_at >= \ + DATEADD(hour, 37, DATEADD(day, -3, (SELECT MAX(occurred_at) FROM events)))", + expected_value: "1", + }, + ]; + for test_case in test_cases { + assert_eq!( + converted_first_value(test_case.sql), + test_case.expected_value, + "{}", + test_case.description + ); + } +} + #[test] fn given_connect_by_root_of_a_qualified_column_when_running_then_it_reads_the_root_value() { let test_cases = [ValueTestCase { @@ -1653,6 +1699,13 @@ fn given_a_refused_divergence_when_converting_then_its_help_names_it_and_only_fi expected_help: "to rely on `hash`: set default and strict = \"warn\" under \ [divergences.hash] in ", }, + DivergenceHelpTestCase { + description: "a divergence a rule's type check needs is still SQ0008, with policy help", + sql: "SELECT CAST(1 AS NUMBER(38,0)) - CAST(1 AS NUMBER(38,2)) AS d", + flags: &["--strict"], + expected_help: "to rely on `numeric_range`: drop --strict, or set strict = \"warn\" \ + under [divergences.numeric_range] in ", + }, DivergenceHelpTestCase { description: "a value divergence under --loud-divergences-only", sql: "SELECT CURRENT_TIMESTAMP AS t", diff --git a/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.arrow b/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.arrow new file mode 100644 index 000000000..4454628f0 Binary files /dev/null and b/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.arrow differ diff --git a/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.json b/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.json new file mode 100644 index 000000000..dd88b49a2 --- /dev/null +++ b/evidence/results/999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8.json @@ -0,0 +1,70 @@ +{ + "arrow_sha256": "d06ad7c4780cdc388dfa7e15a24614c9a69f1f32c33cf9f3e56e97253af875d8", + "case_hash": "999eb66d86bbbaef7781a226f1101e40afd448fc65c9e2a73a9151519c1caae8", + "columns": [ + { + "internal_size": null, + "name": "K", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + }, + { + "internal_size": null, + "name": "CUTOFF", + "nullable": true, + "precision": 0, + "scale": 9, + "type_code": 8, + "type_name": "TIMESTAMP_NTZ" + } + ], + "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": "ab82f9cbb1866faf7be63ec6451a9615205a6f9e92615462c6d21abe1b5ae2dd", + "recorded_at": "2026-11-06T02:42:35.367405+00:00", + "recorder": "sqlquack-record 0.1.0", + "row_count": 3, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.40.102" +} diff --git a/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.arrow b/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.arrow new file mode 100644 index 000000000..261db053e Binary files /dev/null and b/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.arrow differ diff --git a/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.json b/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.json new file mode 100644 index 000000000..099362e1d --- /dev/null +++ b/evidence/results/a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e.json @@ -0,0 +1,61 @@ +{ + "arrow_sha256": "d36152c006b73d913c9d5b2001091352710a5ae079fd936ea1c44b905284c62e", + "case_hash": "a7c066ab03d03d2171a302bbb3550c6241526bad253baef68c1e46eb4b07ed8e", + "columns": [ + { + "internal_size": null, + "name": "K", + "nullable": false, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "effective_parameters": { + "BINARY_INPUT_FORMAT": "HEX", + "BINARY_OUTPUT_FORMAT": "HEX", + "DATE_INPUT_FORMAT": "AUTO", + "DATE_OUTPUT_FORMAT": "YYYY-MM-DD", + "DEFAULT_NULL_ORDERING": "LAST", + "GEOGRAPHY_OUTPUT_FORMAT": "GeoJSON", + "GEOMETRY_OUTPUT_FORMAT": "GeoJSON", + "JSON_INDENT": "2", + "QUOTED_IDENTIFIERS_IGNORE_CASE": "false", + "STRICT_JSON_OUTPUT": "false", + "TIMESTAMP_DAY_IS_ALWAYS_24H": "false", + "TIMESTAMP_INPUT_FORMAT": "AUTO", + "TIMESTAMP_LTZ_OUTPUT_FORMAT": "", + "TIMESTAMP_NTZ_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3", + "TIMESTAMP_OUTPUT_FORMAT": "YYYY-MM-DD HH24:MI:SS.FF3 TZHTZM", + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMESTAMP_TZ_OUTPUT_FORMAT": "", + "TIMEZONE": "UTC", + "TIME_INPUT_FORMAT": "AUTO", + "TIME_OUTPUT_FORMAT": "HH24:MI:SS", + "TWO_DIGIT_CENTURY_START": "1970", + "USE_CACHED_RESULT": "false", + "WEEK_OF_YEAR_POLICY": "0", + "WEEK_START": "0" + }, + "error": null, + "format": 1, + "outcome": "rows", + "parameters_sha256": "d094d15417903748055eca8fde8cf2884751253939f05ad5ad8d9bf14299ac61", + "query_id_sha256": "952cd2b5f821cc66e043b461cb87968343ea8ec8583b0546c51066043a524f43", + "recorded_at": "2026-11-06T02:42:35.367405+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" +}