From cb0c710be261abbfa94cffcd0a23990bcf832949 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:50:10 -0700 Subject: [PATCH 1/5] Parse DuckDB `//` integer division as IntDiv --- crates/polyglot-sql/src/parser.rs | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index c3352531..196ab904 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -31749,8 +31749,25 @@ impl Parser { let right = self.parse_power()?; Expression::Mul(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Slash) { - let right = self.parse_power()?; - Expression::Div(Box::new(BinaryOp::new(left, right))) + // DuckDB `//` integer division: a second slash following `/`. + // (`a / / b` is invalid SQL, so this can't shadow real division.) + if matches!( + self.config.dialect, + Some(crate::dialects::DialectType::DuckDB) + ) && self.check(TokenType::Slash) + { + self.advance()?; + let right = self.parse_power()?; + Expression::IntDiv(Box::new(crate::expressions::BinaryFunc { + this: left, + expression: right, + original_name: None, + inferred_type: None, + })) + } else { + let right = self.parse_power()?; + Expression::Div(Box::new(BinaryOp::new(left, right))) + } } else if self.match_token(TokenType::Percent) { let right = self.parse_power()?; Expression::Mod(Box::new(BinaryOp::new(left, right))) @@ -32048,8 +32065,25 @@ impl Parser { let right = self.parse_power()?; Expression::Mul(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Slash) { - let right = self.parse_power()?; - Expression::Div(Box::new(BinaryOp::new(left, right))) + // DuckDB `//` integer division: a second slash following `/`. + // (`a / / b` is invalid SQL, so this can't shadow real division.) + if matches!( + self.config.dialect, + Some(crate::dialects::DialectType::DuckDB) + ) && self.check(TokenType::Slash) + { + self.advance()?; + let right = self.parse_power()?; + Expression::IntDiv(Box::new(crate::expressions::BinaryFunc { + this: left, + expression: right, + original_name: None, + inferred_type: None, + })) + } else { + let right = self.parse_power()?; + Expression::Div(Box::new(BinaryOp::new(left, right))) + } } else if self.match_token(TokenType::Percent) { let right = self.parse_power()?; Expression::Mod(Box::new(BinaryOp::new(left, right))) From 56fb9c0d81988da81158319e61949643c264fae6 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:00:23 -0700 Subject: [PATCH 2/5] Test DuckDB `//` integer division parsing and lowering --- .../tests/duckdb_integer_division.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/polyglot-sql/tests/duckdb_integer_division.rs 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..f44eface --- /dev/null +++ b/crates/polyglot-sql/tests/duckdb_integer_division.rs @@ -0,0 +1,51 @@ +//! DuckDB `//` integer division parses to `IntDiv` and round-trips. +use polyglot_sql::{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_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 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"]); +} From 8da3709bd0d2f131a463a594ed581643f7401a3a Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:47:41 -0700 Subject: [PATCH 3/5] Address review feedback on DuckDB // integer division (PR 482) - Enable the existing double_slash_int_div tokenizer flag for DuckDB (already used by Vertica) instead of a bespoke parser lookahead; the existing DIV-keyword parsing path then handles it for free. Fixes separated/commented slashes (e.g. "7 / / 2") being wrongly accepted - Report unsupported instead of silently wrong output when a DuckDB // operand is a float literal, since DuckDB falls back to ordinary float division there (7.0 // 2 is 3.5, not 3) and truncating DIV forms can't reproduce that - Report unsupported for BigQuery when dividing by a literal zero, since BigQuery's DIV raises an error where DuckDB's // returns NULL - Emulate integer division for SQLite (CAST(CAST(x AS REAL) / y AS INTEGER)), which has no DIV function and previously got invalid SQL - Wire duckdb_integer_division into make test-rust-verify; expand coverage from 4 to 9 cases --- Makefile | 1 + crates/polyglot-sql/src/dialects/bigquery.rs | 29 +++++- crates/polyglot-sql/src/dialects/duckdb.rs | 2 + crates/polyglot-sql/src/dialects/mod.rs | 23 +++++ crates/polyglot-sql/src/dialects/postgres.rs | 22 ++++- crates/polyglot-sql/src/dialects/sqlite.rs | 42 +++++++++ crates/polyglot-sql/src/generator.rs | 21 ++++- crates/polyglot-sql/src/parser.rs | 42 +-------- .../tests/duckdb_integer_division.rs | 89 ++++++++++++++++++- 9 files changed, 219 insertions(+), 52 deletions(-) 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/bigquery.rs b/crates/polyglot-sql/src/dialects/bigquery.rs index afac1cc9..aaa2b307 100644 --- a/crates/polyglot-sql/src/dialects/bigquery.rs +++ b/crates/polyglot-sql/src/dialects/bigquery.rs @@ -263,10 +263,31 @@ impl DialectImpl for BigQueryDialect { )))), // IntDiv -> DIV - Expression::IntDiv(f) => Ok(Expression::Function(Box::new(Function::new( - "DIV".to_string(), - vec![f.this, f.expression], - )))), + Expression::IntDiv(f) => { + if crate::dialects::is_float_literal_operand(&f.this) + || crate::dialects::is_float_literal_operand(&f.expression) + { + // BigQuery's DIV() truncates to an integer; DuckDB's // + // falls back to ordinary float division when either + // operand is non-integer (e.g. `7.0 // 2` is `3.5`). + return Err(crate::error::Error::unsupported( + "DuckDB's // on a float operand (falls back to float division, not integer DIV)", + "bigquery", + )); + } + if crate::dialects::is_literal_zero(&f.expression) { + // BigQuery's DIV(x, 0) raises a division-by-zero error; + // DuckDB's // returns NULL for the same input. + return Err(crate::error::Error::unsupported( + "DuckDB's // by a literal zero (returns NULL; BigQuery's DIV raises an error)", + "bigquery", + )); + } + Ok(Expression::Function(Box::new(Function::new( + "DIV".to_string(), + vec![f.this, f.expression], + )))) + } // Int64 -> INT64 Expression::Int64(f) => Ok(Expression::Function(Box::new(Function::new( 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..6e66173a 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -648,6 +648,29 @@ 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 { + matches!( + e, + Expression::Literal(lit) if matches!(lit.as_ref(), crate::expressions::Literal::Number(n) if n.contains('.')) + ) +} + +/// 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 diff --git a/crates/polyglot-sql/src/dialects/postgres.rs b/crates/polyglot-sql/src/dialects/postgres.rs index a679ee58..c5a398e8 100644 --- a/crates/polyglot-sql/src/dialects/postgres.rs +++ b/crates/polyglot-sql/src/dialects/postgres.rs @@ -733,10 +733,24 @@ impl DialectImpl for PostgresDialect { // MISC FUNCTIONS // ============================================ // IntDiv -> DIV in PostgreSQL - Expression::IntDiv(f) => Ok(Expression::Function(Box::new(Function::new( - "DIV".to_string(), - vec![f.this, f.expression], - )))), + Expression::IntDiv(f) => { + if crate::dialects::is_float_literal_operand(&f.this) + || crate::dialects::is_float_literal_operand(&f.expression) + { + // PostgreSQL's DIV() truncates to an integer; DuckDB's // + // falls back to ordinary float division when either + // operand is non-integer, so the result would differ + // (e.g. `7.0 // 2` is `3.5`, not `3`). + return Err(crate::error::Error::unsupported( + "DuckDB's // on a float operand (falls back to float division, not integer DIV)", + "postgresql", + )); + } + Ok(Expression::Function(Box::new(Function::new( + "DIV".to_string(), + vec![f.this, f.expression], + )))) + } // Unicode -> ASCII in PostgreSQL Expression::Unicode(f) => Ok(Expression::Function(Box::new(Function::new( diff --git a/crates/polyglot-sql/src/dialects/sqlite.rs b/crates/polyglot-sql/src/dialects/sqlite.rs index 3a068f99..9c17563f 100644 --- a/crates/polyglot-sql/src/dialects/sqlite.rs +++ b/crates/polyglot-sql/src/dialects/sqlite.rs @@ -216,6 +216,48 @@ 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) => { + if crate::dialects::is_float_literal_operand(&f.this) + || crate::dialects::is_float_literal_operand(&f.expression) + { + // The CAST(... AS INTEGER) emulation truncates; DuckDB's + // // falls back to ordinary float division when either + // operand is non-integer (e.g. `7.0 // 2` is `3.5`). + return Err(crate::error::Error::unsupported( + "DuckDB's // on a float operand (falls back to float division, not truncating division)", + "sqlite", + )); + } + 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..0e9f33f6 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -3448,6 +3448,22 @@ 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 !is_native_slash_op + && (crate::dialects::is_float_literal_operand(&f.this) + || crate::dialects::is_float_literal_operand(&f.expression)) + { + // DuckDB's // falls back to ordinary float division when + // either operand is non-integer (e.g. `7.0 // 2` is + // `3.5`), which truncating DIV forms here can't preserve. + return Err(crate::error::Error::unsupported( + "DuckDB's // on a float operand (falls back to float division, not integer DIV)", + self.config.dialect.unwrap_or_default().to_string(), + )); + } if matches!(self.config.dialect, Some(DialectType::ClickHouse)) { self.write("intDiv("); self.generate_expression(&f.this)?; @@ -3455,10 +3471,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/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 196ab904..c3352531 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -31749,25 +31749,8 @@ impl Parser { let right = self.parse_power()?; Expression::Mul(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Slash) { - // DuckDB `//` integer division: a second slash following `/`. - // (`a / / b` is invalid SQL, so this can't shadow real division.) - if matches!( - self.config.dialect, - Some(crate::dialects::DialectType::DuckDB) - ) && self.check(TokenType::Slash) - { - self.advance()?; - let right = self.parse_power()?; - Expression::IntDiv(Box::new(crate::expressions::BinaryFunc { - this: left, - expression: right, - original_name: None, - inferred_type: None, - })) - } else { - let right = self.parse_power()?; - Expression::Div(Box::new(BinaryOp::new(left, right))) - } + let right = self.parse_power()?; + Expression::Div(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Percent) { let right = self.parse_power()?; Expression::Mod(Box::new(BinaryOp::new(left, right))) @@ -32065,25 +32048,8 @@ impl Parser { let right = self.parse_power()?; Expression::Mul(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Slash) { - // DuckDB `//` integer division: a second slash following `/`. - // (`a / / b` is invalid SQL, so this can't shadow real division.) - if matches!( - self.config.dialect, - Some(crate::dialects::DialectType::DuckDB) - ) && self.check(TokenType::Slash) - { - self.advance()?; - let right = self.parse_power()?; - Expression::IntDiv(Box::new(crate::expressions::BinaryFunc { - this: left, - expression: right, - original_name: None, - inferred_type: None, - })) - } else { - let right = self.parse_power()?; - Expression::Div(Box::new(BinaryOp::new(left, right))) - } + let right = self.parse_power()?; + Expression::Div(Box::new(BinaryOp::new(left, right))) } else if self.match_token(TokenType::Percent) { let right = self.parse_power()?; Expression::Mod(Box::new(BinaryOp::new(left, right))) diff --git a/crates/polyglot-sql/tests/duckdb_integer_division.rs b/crates/polyglot-sql/tests/duckdb_integer_division.rs index f44eface..6f9b5070 100644 --- a/crates/polyglot-sql/tests/duckdb_integer_division.rs +++ b/crates/polyglot-sql/tests/duckdb_integer_division.rs @@ -1,5 +1,10 @@ -//! DuckDB `//` integer division parses to `IntDiv` and round-trips. -use polyglot_sql::{transpile, DialectType}; +//! 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() { @@ -21,6 +26,21 @@ fn duckdb_int_div_transpiles_like_other_integer_division() { } } +#[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( @@ -32,6 +52,71 @@ fn duckdb_int_div_binds_tighter_than_addition() { 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_is_unsupported() { + // DuckDB's // falls back to ordinary float division when either operand + // is non-integer (`7.0 // 2` is `3.5`, not `3`), which a truncating + // target DIV can't reproduce -- so this must be reported, not silently + // transpiled to a different result. + for target in [ + DialectType::PostgreSQL, + DialectType::BigQuery, + DialectType::SQLite, + ] { + let err = transpile("SELECT 7.0 // 2 AS v", DialectType::DuckDB, target).unwrap_err(); + assert!( + err.to_string().contains("float"), + "target {target:?}: got {err}" + ); + } +} + +#[test] +fn duckdb_int_div_by_zero_is_unsupported_for_bigquery() { + // DuckDB's 7 // 0 returns NULL; BigQuery's DIV(7, 0) raises an error. + let err = transpile( + "SELECT 7 // 0 AS v", + DialectType::DuckDB, + DialectType::BigQuery, + ) + .unwrap_err(); + assert!(err.to_string().contains("zero"), "got: {err}"); +} + #[test] fn plain_division_is_unchanged_in_duckdb_and_elsewhere() { let out = transpile( From b1add7e74b72de8a8634bd4363ed7e633ce61fc5 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:11:07 -0700 Subject: [PATCH 4/5] Fix integer-division review follow-ups - Move the float-operand guard into the DuckDB-source transform so it only applies to DuckDB's //; it had been placed in target-side code that can't see the source and broke MySQL's own DIV (7.5 DIV 2) and Vertica's //, which genuinely truncate - Reject a literal-zero divisor for every non-DuckDB target, not just BigQuery: PostgreSQL's DIV and ClickHouse's intDiv raise too, where DuckDB returns NULL - Recognize negated, parenthesized, and exponent-form float literals - Treat Vertica and ClickHouse as truncating targets for the float guard --- crates/polyglot-sql/src/dialects/bigquery.rs | 29 ++------- crates/polyglot-sql/src/dialects/mod.rs | 36 ++++++++-- crates/polyglot-sql/src/dialects/postgres.rs | 22 ++----- crates/polyglot-sql/src/dialects/sqlite.rs | 11 ---- crates/polyglot-sql/src/generator.rs | 12 ---- .../tests/duckdb_integer_division.rs | 65 +++++++++++++++---- 6 files changed, 91 insertions(+), 84 deletions(-) diff --git a/crates/polyglot-sql/src/dialects/bigquery.rs b/crates/polyglot-sql/src/dialects/bigquery.rs index aaa2b307..afac1cc9 100644 --- a/crates/polyglot-sql/src/dialects/bigquery.rs +++ b/crates/polyglot-sql/src/dialects/bigquery.rs @@ -263,31 +263,10 @@ impl DialectImpl for BigQueryDialect { )))), // IntDiv -> DIV - Expression::IntDiv(f) => { - if crate::dialects::is_float_literal_operand(&f.this) - || crate::dialects::is_float_literal_operand(&f.expression) - { - // BigQuery's DIV() truncates to an integer; DuckDB's // - // falls back to ordinary float division when either - // operand is non-integer (e.g. `7.0 // 2` is `3.5`). - return Err(crate::error::Error::unsupported( - "DuckDB's // on a float operand (falls back to float division, not integer DIV)", - "bigquery", - )); - } - if crate::dialects::is_literal_zero(&f.expression) { - // BigQuery's DIV(x, 0) raises a division-by-zero error; - // DuckDB's // returns NULL for the same input. - return Err(crate::error::Error::unsupported( - "DuckDB's // by a literal zero (returns NULL; BigQuery's DIV raises an error)", - "bigquery", - )); - } - Ok(Expression::Function(Box::new(Function::new( - "DIV".to_string(), - vec![f.this, f.expression], - )))) - } + Expression::IntDiv(f) => Ok(Expression::Function(Box::new(Function::new( + "DIV".to_string(), + vec![f.this, f.expression], + )))), // Int64 -> INT64 Expression::Int64(f) => Ok(Expression::Function(Box::new(Function::new( diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 6e66173a..5b64ff41 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -654,10 +654,15 @@ fn is_default_presto_date_format(fmt: &str) -> bool { /// that fall back to ordinary float division on non-integer operands. #[cfg(feature = "transpile")] pub(crate) fn is_float_literal_operand(e: &Expression) -> bool { - matches!( - e, - Expression::Literal(lit) if matches!(lit.as_ref(), crate::expressions::Literal::Number(n) if n.contains('.')) - ) + 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 @@ -3548,6 +3553,29 @@ 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's ordinary float + // division (`7.0 // 2` is 3.5) -- and it returns NULL on + // a zero divisor. Every other target's integer division + // truncates unconditionally and errors on zero, so report + // those inputs rather than emit SQL with different results. + Expression::IntDiv(f) if target != DialectType::DuckDB => { + if is_float_literal_operand(&f.this) + || is_float_literal_operand(&f.expression) + { + return Err(crate::error::Error::unsupported( + "DuckDB's // on a float operand (falls back to float division, not integer DIV)", + target.to_string(), + )); + } + 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/postgres.rs b/crates/polyglot-sql/src/dialects/postgres.rs index c5a398e8..a679ee58 100644 --- a/crates/polyglot-sql/src/dialects/postgres.rs +++ b/crates/polyglot-sql/src/dialects/postgres.rs @@ -733,24 +733,10 @@ impl DialectImpl for PostgresDialect { // MISC FUNCTIONS // ============================================ // IntDiv -> DIV in PostgreSQL - Expression::IntDiv(f) => { - if crate::dialects::is_float_literal_operand(&f.this) - || crate::dialects::is_float_literal_operand(&f.expression) - { - // PostgreSQL's DIV() truncates to an integer; DuckDB's // - // falls back to ordinary float division when either - // operand is non-integer, so the result would differ - // (e.g. `7.0 // 2` is `3.5`, not `3`). - return Err(crate::error::Error::unsupported( - "DuckDB's // on a float operand (falls back to float division, not integer DIV)", - "postgresql", - )); - } - Ok(Expression::Function(Box::new(Function::new( - "DIV".to_string(), - vec![f.this, f.expression], - )))) - } + Expression::IntDiv(f) => Ok(Expression::Function(Box::new(Function::new( + "DIV".to_string(), + vec![f.this, f.expression], + )))), // Unicode -> ASCII in PostgreSQL Expression::Unicode(f) => Ok(Expression::Function(Box::new(Function::new( diff --git a/crates/polyglot-sql/src/dialects/sqlite.rs b/crates/polyglot-sql/src/dialects/sqlite.rs index 9c17563f..172468a5 100644 --- a/crates/polyglot-sql/src/dialects/sqlite.rs +++ b/crates/polyglot-sql/src/dialects/sqlite.rs @@ -219,17 +219,6 @@ impl DialectImpl for SQLiteDialect { // IntDiv: SQLite has no DIV function, so emulate truncating // integer division as CAST(CAST(x AS REAL) / y AS INTEGER). Expression::IntDiv(f) => { - if crate::dialects::is_float_literal_operand(&f.this) - || crate::dialects::is_float_literal_operand(&f.expression) - { - // The CAST(... AS INTEGER) emulation truncates; DuckDB's - // // falls back to ordinary float division when either - // operand is non-integer (e.g. `7.0 // 2` is `3.5`). - return Err(crate::error::Error::unsupported( - "DuckDB's // on a float operand (falls back to float division, not truncating division)", - "sqlite", - )); - } let cast_x = Expression::Cast(Box::new(Cast { this: f.this, to: DataType::Float { diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index 0e9f33f6..c65b67ad 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -3452,18 +3452,6 @@ impl Generator { self.config.dialect, Some(DialectType::DuckDB) | Some(DialectType::Vertica) ); - if !is_native_slash_op - && (crate::dialects::is_float_literal_operand(&f.this) - || crate::dialects::is_float_literal_operand(&f.expression)) - { - // DuckDB's // falls back to ordinary float division when - // either operand is non-integer (e.g. `7.0 // 2` is - // `3.5`), which truncating DIV forms here can't preserve. - return Err(crate::error::Error::unsupported( - "DuckDB's // on a float operand (falls back to float division, not integer DIV)", - self.config.dialect.unwrap_or_default().to_string(), - )); - } if matches!(self.config.dialect, Some(DialectType::ClickHouse)) { self.write("intDiv("); self.generate_expression(&f.this)?; diff --git a/crates/polyglot-sql/tests/duckdb_integer_division.rs b/crates/polyglot-sql/tests/duckdb_integer_division.rs index 6f9b5070..28f43038 100644 --- a/crates/polyglot-sql/tests/duckdb_integer_division.rs +++ b/crates/polyglot-sql/tests/duckdb_integer_division.rs @@ -89,32 +89,69 @@ fn duckdb_int_div_rejects_separated_slashes() { #[test] fn duckdb_int_div_on_float_operand_is_unsupported() { // DuckDB's // falls back to ordinary float division when either operand - // is non-integer (`7.0 // 2` is `3.5`, not `3`), which a truncating - // target DIV can't reproduce -- so this must be reported, not silently - // transpiled to a different result. + // is non-integer (`7.0 // 2` is `3.5`, not `3`). Every other target's + // integer division truncates unconditionally -- including Vertica's own + // `//` and ClickHouse's intDiv -- so this must be reported, not silently + // transpiled to a different result. Negated, parenthesized, and + // exponent-form literals count too. for target in [ DialectType::PostgreSQL, DialectType::BigQuery, DialectType::SQLite, + DialectType::Vertica, + DialectType::ClickHouse, ] { - let err = transpile("SELECT 7.0 // 2 AS v", DialectType::DuckDB, target).unwrap_err(); + 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 err = transpile(sql, DialectType::DuckDB, target).unwrap_err(); + assert!( + err.to_string().contains("float"), + "{sql} -> {target:?}: got {err}" + ); + } + } +} + +#[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("float"), + err.to_string().contains("zero"), "target {target:?}: got {err}" ); } } #[test] -fn duckdb_int_div_by_zero_is_unsupported_for_bigquery() { - // DuckDB's 7 // 0 returns NULL; BigQuery's DIV(7, 0) raises an error. - let err = transpile( - "SELECT 7 // 0 AS v", - DialectType::DuckDB, - DialectType::BigQuery, - ) - .unwrap_err(); - assert!(err.to_string().contains("zero"), "got: {err}"); +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] From 89a85b0e65ff69fb8dc9e4caea15b7c9550daca7 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:20:55 -0700 Subject: [PATCH 5/5] Lower DuckDB // with a float operand to plain division DuckDB's // is ordinary float division when either operand is a float literal, so emit / (which then follows each target's own division rules) instead of reporting it unsupported. hotquery's hotdata target already lowered IntDiv to / and was producing the right answer here; the guard would have turned that into an error. --- crates/polyglot-sql/src/dialects/mod.rs | 17 ++++----- .../tests/duckdb_integer_division.rs | 38 +++++++++++++------ 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 5b64ff41..738d8904 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -3554,19 +3554,18 @@ impl Dialect { } Expression::DataType(DT::Char { .. }) => Ok(Expression::DataType(DT::Text)), // DuckDB's `//` only truncates when both operands are - // integers -- with a float operand it's ordinary float - // division (`7.0 // 2` is 3.5) -- and it returns NULL on - // a zero divisor. Every other target's integer division - // truncates unconditionally and errors on zero, so report - // those inputs rather than emit SQL with different results. + // 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 Err(crate::error::Error::unsupported( - "DuckDB's // on a float operand (falls back to float division, not integer DIV)", - target.to_string(), - )); + 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( diff --git a/crates/polyglot-sql/tests/duckdb_integer_division.rs b/crates/polyglot-sql/tests/duckdb_integer_division.rs index 28f43038..9582e3b5 100644 --- a/crates/polyglot-sql/tests/duckdb_integer_division.rs +++ b/crates/polyglot-sql/tests/duckdb_integer_division.rs @@ -87,12 +87,11 @@ fn duckdb_int_div_rejects_separated_slashes() { } #[test] -fn duckdb_int_div_on_float_operand_is_unsupported() { - // DuckDB's // falls back to ordinary float division when either operand - // is non-integer (`7.0 // 2` is `3.5`, not `3`). Every other target's - // integer division truncates unconditionally -- including Vertica's own - // `//` and ClickHouse's intDiv -- so this must be reported, not silently - // transpiled to a different result. Negated, parenthesized, and +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, @@ -100,6 +99,8 @@ fn duckdb_int_div_on_float_operand_is_unsupported() { DialectType::SQLite, DialectType::Vertica, DialectType::ClickHouse, + DialectType::MySQL, + DialectType::DataFusion, ] { for sql in [ "SELECT 7.0 // 2 AS v", @@ -108,13 +109,28 @@ fn duckdb_int_div_on_float_operand_is_unsupported() { "SELECT 7e0 // 2 AS v", "SELECT 7 // 2.0 AS v", ] { - let err = transpile(sql, DialectType::DuckDB, target).unwrap_err(); - assert!( - err.to_string().contains("float"), - "{sql} -> {target:?}: got {err}" - ); + 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]