diff --git a/Makefile b/Makefile index 70a60bdd..d62f6f39 100644 --- a/Makefile +++ b/Makefile @@ -287,6 +287,7 @@ test-rust-verify-core: @cargo test --lib -p polyglot-sql @cargo test -p polyglot-sql --test deep_nesting_regression @cargo test -p polyglot-sql --test dialect_matrix + @cargo test -p polyglot-sql --test duckdb_integer_division @echo "" @echo "=== Generic identity tests ===" @cargo test --test sqlglot_identity test_sqlglot_identity_all -p polyglot-sql -- --nocapture diff --git a/crates/polyglot-sql/src/dialects/duckdb.rs b/crates/polyglot-sql/src/dialects/duckdb.rs index 41524eab..9509d8d9 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -103,6 +103,8 @@ impl DialectImpl for DuckDBDialect { config.nested_comments = true; // DuckDB allows underscores as digit separators in numeric literals config.numbers_can_be_underscore_separated = true; + // `//` is integer division (same tokenizer-level recognition as Vertica) + config.double_slash_int_div = true; config } diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index a3e689f1..738d8904 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -648,6 +648,34 @@ fn is_default_presto_date_format(fmt: &str) -> bool { fmt == "%Y-%m-%d" || fmt == "%F" } +/// Whether `e` is syntactically a float literal (e.g. `7.0`, `3.14`), as a +/// cheap, annotation-free signal that an integer-truncating operation (like +/// `DIV`) would behave differently than source dialects (DuckDB's `//`) +/// that fall back to ordinary float division on non-integer operands. +#[cfg(feature = "transpile")] +pub(crate) fn is_float_literal_operand(e: &Expression) -> bool { + match e { + Expression::Neg(n) => is_float_literal_operand(&n.this), + Expression::Paren(p) => is_float_literal_operand(&p.this), + Expression::Literal(lit) => matches!( + lit.as_ref(), + crate::expressions::Literal::Number(n) if n.contains(['.', 'e', 'E']) + ), + _ => false, + } +} + +/// Whether `e` is the integer literal `0` — used to detect integer division +/// by a literal zero, which some targets (BigQuery's `DIV`) raise a hard +/// error on, unlike DuckDB's `//`, which returns `NULL`. +#[cfg(feature = "transpile")] +pub(crate) fn is_literal_zero(e: &Expression) -> bool { + matches!( + e, + Expression::Literal(lit) if matches!(lit.as_ref(), crate::expressions::Literal::Number(n) if n.parse::() == Ok(0.0)) + ) +} + /// Applies a dialect transform bottom-up through selected syntax children. /// /// The public entrypoint uses an explicit task stack for the recursion-heavy shapes @@ -3525,6 +3553,28 @@ impl Dialect { Ok(Expression::DataType(DT::Text)) } Expression::DataType(DT::Char { .. }) => Ok(Expression::DataType(DT::Text)), + // DuckDB's `//` only truncates when both operands are + // integers; with a float operand it is ordinary float + // division (`7.0 // 2` is 3.5), so that case lowers to + // `/` exactly. It also returns NULL on a zero divisor, + // where every other target's integer division raises, so + // report that rather than emit SQL with a different result. + Expression::IntDiv(f) if target != DialectType::DuckDB => { + if is_float_literal_operand(&f.this) + || is_float_literal_operand(&f.expression) + { + return Ok(Expression::Div(Box::new( + crate::expressions::BinaryOp::new(f.this, f.expression), + ))); + } + if is_literal_zero(&f.expression) { + return Err(crate::error::Error::unsupported( + "DuckDB's // by a literal zero (returns NULL; integer division elsewhere raises an error)", + target.to_string(), + )); + } + Ok(Expression::IntDiv(f)) + } _ => Ok(e), })? } else { diff --git a/crates/polyglot-sql/src/dialects/sqlite.rs b/crates/polyglot-sql/src/dialects/sqlite.rs index 3a068f99..172468a5 100644 --- a/crates/polyglot-sql/src/dialects/sqlite.rs +++ b/crates/polyglot-sql/src/dialects/sqlite.rs @@ -216,6 +216,37 @@ impl DialectImpl for SQLiteDialect { Ok(Expression::Div(op)) } + // IntDiv: SQLite has no DIV function, so emulate truncating + // integer division as CAST(CAST(x AS REAL) / y AS INTEGER). + Expression::IntDiv(f) => { + let cast_x = Expression::Cast(Box::new(Cast { + this: f.this, + to: DataType::Float { + precision: None, + scale: None, + real_spelling: true, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + })); + let div_expr = Expression::Div(Box::new(BinaryOp::new(cast_x, f.expression))); + Ok(Expression::Cast(Box::new(Cast { + this: div_expr, + to: DataType::Int { + length: None, + integer_spelling: true, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))) + } + // Pass through everything else _ => Ok(expr), } diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index cc5cf7db..c65b67ad 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -3448,6 +3448,10 @@ impl Generator { Expression::Div(op) => self.generate_precedence_binary_op(op, "/", InfixOperator::Div), Expression::IntDiv(f) => { use crate::dialects::DialectType; + let is_native_slash_op = matches!( + self.config.dialect, + Some(DialectType::DuckDB) | Some(DialectType::Vertica) + ); if matches!(self.config.dialect, Some(DialectType::ClickHouse)) { self.write("intDiv("); self.generate_expression(&f.this)?; @@ -3455,10 +3459,7 @@ impl Generator { self.generate_expression(&f.expression)?; self.write(")"); Ok(()) - } else if matches!( - self.config.dialect, - Some(DialectType::DuckDB) | Some(DialectType::Vertica) - ) { + } else if is_native_slash_op { // DuckDB and Vertica use // operator for integer division self.generate_expression(&f.this)?; self.write(" // "); diff --git a/crates/polyglot-sql/tests/duckdb_integer_division.rs b/crates/polyglot-sql/tests/duckdb_integer_division.rs new file mode 100644 index 00000000..9582e3b5 --- /dev/null +++ b/crates/polyglot-sql/tests/duckdb_integer_division.rs @@ -0,0 +1,189 @@ +//! DuckDB `//` integer division: tokenizes to a single operator (so `7 / / 2` +//! and separated/commented slashes are rejected, matching DuckDB), parses to +//! `IntDiv` with correct precedence, and round-trips or lowers per target -- +//! reporting unsupported rather than silently changing results when a +//! float operand or a literal-zero divisor means the target's truncating +//! `DIV` can't reproduce DuckDB's actual behavior. +use polyglot_sql::{parse_one, transpile, DialectType}; + +#[test] +fn duckdb_int_div_round_trips() { + let out = transpile( + "SELECT 7 // 2 AS v", + DialectType::DuckDB, + DialectType::DuckDB, + ) + .unwrap(); + assert_eq!(out, vec!["SELECT 7 // 2 AS v"]); +} + +#[test] +fn duckdb_int_div_transpiles_like_other_integer_division() { + // Same lowering the existing MySQL `DIV` operator gets for these targets. + for target in [DialectType::PostgreSQL, DialectType::BigQuery] { + let out = transpile("SELECT 7 // 2 AS v", DialectType::DuckDB, target).unwrap(); + assert_eq!(out, vec!["SELECT DIV(7, 2) AS v"], "target {target:?}"); + } +} + +#[test] +fn duckdb_int_div_emulated_for_sqlite() { + // SQLite has no DIV function, so // is emulated as truncating division. + let out = transpile( + "SELECT 7 // 2 AS v", + DialectType::DuckDB, + DialectType::SQLite, + ) + .unwrap(); + assert_eq!( + out, + vec!["SELECT CAST(CAST(7 AS REAL) / 2 AS INTEGER) AS v"] + ); +} + +#[test] +fn duckdb_int_div_binds_tighter_than_addition() { + let out = transpile( + "SELECT 1 + 7 // 2 AS v", + DialectType::DuckDB, + DialectType::DuckDB, + ) + .unwrap(); + assert_eq!(out, vec!["SELECT 1 + 7 // 2 AS v"]); +} + +#[test] +fn duckdb_int_div_precedence_is_unambiguous_in_the_ast() { + // `1 + 7 // 2` round-trips the same either way `+`/`//` group (7), so it + // doesn't actually prove precedence -- assert the AST shape directly, + // and use `2 + 7 // 2` (5 if // binds tighter, 4 if + does) to show the + // two groupings aren't interchangeable. + use polyglot_sql::Expression; + let expr = parse_one("SELECT 2 + 7 // 2 AS v", DialectType::DuckDB).unwrap(); + let Expression::Select(select) = &expr else { + panic!("expected a SELECT"); + }; + let Expression::Alias(alias) = &select.expressions[0] else { + panic!("expected an aliased projection"); + }; + let Expression::Add(add) = &alias.this else { + panic!( + "expected the top-level operator to be +, got {:?}", + alias.this + ); + }; + assert!( + matches!(add.right, Expression::IntDiv(_)), + "expected // to bind tighter than + (Add(2, IntDiv(7, 2))), got {add:?}" + ); +} + +#[test] +fn duckdb_int_div_rejects_separated_slashes() { + // DuckDB requires a contiguous `//`; `a / / b` and `a / /* c */ / b` are + // invalid DuckDB SQL and must not be accepted as integer division. + assert!(parse_one("SELECT 7 / / 2", DialectType::DuckDB).is_err()); + assert!(parse_one("SELECT 7 / /* c */ / 2", DialectType::DuckDB).is_err()); +} + +#[test] +fn duckdb_int_div_on_float_operand_lowers_to_float_division() { + // DuckDB's // is ordinary float division when either operand is + // non-integer (`7.0 // 2` is `3.5`, not `3`), so it lowers to `/` -- + // never to a truncating DIV/intDiv/CAST(... AS INTEGER) -- on every + // target, including Vertica's own `//`. Negated, parenthesized, and + // exponent-form literals count too. + for target in [ + DialectType::PostgreSQL, + DialectType::BigQuery, + DialectType::SQLite, + DialectType::Vertica, + DialectType::ClickHouse, + DialectType::MySQL, + DialectType::DataFusion, + ] { + for sql in [ + "SELECT 7.0 // 2 AS v", + "SELECT -7.0 // 2 AS v", + "SELECT (7.0) // 2 AS v", + "SELECT 7e0 // 2 AS v", + "SELECT 7 // 2.0 AS v", + ] { + let out = transpile(sql, DialectType::DuckDB, target) + .unwrap_or_else(|e| panic!("{sql} -> {target:?}: {e}")) + .remove(0); + assert!(out.contains(" / "), "{sql} -> {target:?}: got {out}"); + for truncating in ["DIV", "intDiv", "AS INTEGER", "//"] { + assert!( + !out.contains(truncating), + "{sql} -> {target:?}: still truncates: {out}" + ); + } + } + } + // The int/int case is unaffected. + assert_eq!( + transpile( + "SELECT 7 // 2 AS v", + DialectType::DuckDB, + DialectType::Vertica + ) + .unwrap(), + vec!["SELECT 7 // 2 AS v"] + ); +} + +#[test] +fn duckdb_int_div_by_literal_zero_is_unsupported() { + // DuckDB's 7 // 0 returns NULL; PostgreSQL's DIV(7, 0), BigQuery's + // DIV(7, 0), and ClickHouse's intDiv(7, 0) all raise an error. + for target in [ + DialectType::PostgreSQL, + DialectType::BigQuery, + DialectType::ClickHouse, + ] { + let err = transpile("SELECT 7 // 0 AS v", DialectType::DuckDB, target).unwrap_err(); + assert!( + err.to_string().contains("zero"), + "target {target:?}: got {err}" + ); + } +} + +#[test] +fn other_dialects_integer_division_is_unaffected() { + // The float/zero guards are about DuckDB's // semantics specifically. + // MySQL's and Vertica's integer division genuinely truncates float + // operands, so their existing lowering must keep working. + assert_eq!( + transpile("SELECT 7.5 DIV 2", DialectType::MySQL, DialectType::MySQL).unwrap(), + vec!["SELECT DIV(7.5, 2)"] + ); + assert_eq!( + transpile( + "SELECT 7.5 // 2", + DialectType::Vertica, + DialectType::PostgreSQL + ) + .unwrap(), + vec!["SELECT DIV(7.5, 2)"] + ); +} + +#[test] +fn plain_division_is_unchanged_in_duckdb_and_elsewhere() { + let out = transpile( + "SELECT 7 / 2 AS v", + DialectType::DuckDB, + DialectType::DuckDB, + ) + .unwrap(); + assert_eq!(out, vec!["SELECT 7 / 2 AS v"]); + let out = transpile( + "SELECT 7 / 2 AS v", + DialectType::PostgreSQL, + DialectType::PostgreSQL, + ) + .unwrap(); + assert_eq!(out, vec!["SELECT 7 / 2 AS v"]); +}