Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions cases/temporal/subquery_operands.toml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions coverage/translation/results-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlquack-core/src/refusals/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
9 changes: 7 additions & 2 deletions crates/sqlquack-core/src/refusals/main/outside_conditions.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
4 changes: 2 additions & 2 deletions crates/sqlquack-core/src/refusals/main/refused_divergence.rs
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
148 changes: 148 additions & 0 deletions crates/sqlquack-duckdb/src/checking/_helpers/lambda_bodies.rs
Original file line number Diff line number Diff line change
@@ -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<usize>,
pub(crate) parameters: Vec<String>,
}

/// 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(), &quoted))
}

/// Whether `range` of `sql` is a whole word outside every range in `quoted`.
fn whole_word(sql: &str, range: Range<usize>, quoted: &[Range<usize>]) -> 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<Range<usize>> {
let mut ranges: Vec<Range<usize>> = 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<Body> {
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<u8>,
/// 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<String>)>,
bodies: Vec<Body>,
}

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<String> = 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,
});
}
}
}
1 change: 1 addition & 0 deletions crates/sqlquack-duckdb/src/checking/_helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
36 changes: 36 additions & 0 deletions crates/sqlquack-duckdb/src/checking/main/bound_outside_lambdas.rs
Original file line number Diff line number Diff line change
@@ -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<Option<String>, String> {
let bodies = lambda_bodies(sql);
let starts: Vec<usize> = 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<String> = 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)
))),
}
}
1 change: 1 addition & 0 deletions crates/sqlquack-duckdb/src/checking/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//! Entry points.

pub mod bound_outside_lambdas;
pub mod check_printed;
2 changes: 2 additions & 0 deletions crates/sqlquack-duckdb/src/checking/tests.rs
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading
Loading