diff --git a/CHANGELOG.md b/CHANGELOG.md index dd35f0e4..38b007f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [Unreleased] + +### Fixed + +- Oracle: `LIMIT` is now rendered as `[OFFSET n ROWS] FETCH FIRST m ROWS ONLY` + by the generator itself, driven by `GeneratorConfig::limit_fetch_style` + (Oracle now defaults to `LimitFetchStyle::FetchFirst`). This covers ASTs built + with the builder or generated without `transpile`, as well as subqueries, CTEs, + `INSERT ... SELECT`, and set operations that previously kept `LIMIT`. + `LIMIT ALL` / `LIMIT NULL` are dropped instead of becoming `FETCH FIRST ALL`, + and T-SQL `TOP n [PERCENT] [WITH TIES]` maps to the equivalent `FETCH FIRST`. + T-SQL's `limit_fetch_style` is now `Top`, matching its existing output. + An explicit `FetchFirst` config takes precedence over dialect-specific + LIMIT/OFFSET rendering (e.g. Presto/Trino `OFFSET n LIMIT m`), except on + T-SQL/Fabric, which keep their TOP / `OFFSET ... FETCH NEXT` output. + ([#480](https://github.com/tobilg/polyglot/issues/480)) +- Set operations: a branch-local row limit that renders as a trailing clause + (e.g. T-SQL `SELECT TOP 5 ...` in a `UNION` branch transpiled to `LIMIT` or + `FETCH FIRST`) is now parenthesized, so it no longer limits the whole set + operation. SQLite, which rejects parenthesized operands, gets + `SELECT * FROM (...)` instead. Operands wrapped in comments (e.g. a comment + before `UNION ALL`) are grouped the same way, with the comment kept outside + the parentheses. + ## [0.13.0] - 2026-09-24 ### Added diff --git a/crates/polyglot-sql/src/dialects/normalization/statements.rs b/crates/polyglot-sql/src/dialects/normalization/statements.rs index 2da07bb8..4f5bdd75 100644 --- a/crates/polyglot-sql/src/dialects/normalization/statements.rs +++ b/crates/polyglot-sql/src/dialects/normalization/statements.rs @@ -119,30 +119,8 @@ pub(super) fn normalize_root(expression: Expression, context: &NormalizationCont expr }; - // Oracle: LIMIT -> FETCH FIRST, OFFSET -> OFFSET ROWS - let expr = if matches!(target, DialectType::Oracle) { - if let Expression::Select(mut select) = expr { - if let Some(limit) = select.limit.take() { - // Convert LIMIT to FETCH FIRST n ROWS ONLY - select.fetch = Some(crate::expressions::Fetch { - direction: "FIRST".to_string(), - count: Some(limit.this), - percent: false, - rows: true, - with_ties: false, - }); - } - // Add ROWS to OFFSET if present - if let Some(ref mut offset) = select.offset { - offset.rows = Some(true); - } - Expression::Select(select) - } else { - expr - } - } else { - expr - }; + // Oracle LIMIT/OFFSET -> OFFSET n ROWS FETCH FIRST m ROWS ONLY is rendered by the + // generator (`LimitFetchStyle::FetchFirst`), so it also applies to nested queries. // Handle CreateTable WITH properties transformation before recursive transforms let expr = if let Expression::CreateTable(mut ct) = expr { diff --git a/crates/polyglot-sql/src/dialects/oracle.rs b/crates/polyglot-sql/src/dialects/oracle.rs index e3340c1a..1990d8e3 100644 --- a/crates/polyglot-sql/src/dialects/oracle.rs +++ b/crates/polyglot-sql/src/dialects/oracle.rs @@ -56,6 +56,8 @@ impl DialectImpl for OracleDialect { tz_to_with_time_zone: true, // Oracle UNPIVOT aliases retain literal aliases as literals. unpivot_aliases_are_identifiers: false, + // Oracle has no LIMIT: use OFFSET n ROWS FETCH FIRST m ROWS ONLY + limit_fetch_style: crate::generator::LimitFetchStyle::FetchFirst, ..Default::default() } } diff --git a/crates/polyglot-sql/src/dialects/tsql.rs b/crates/polyglot-sql/src/dialects/tsql.rs index ec8146c9..c7c91e58 100644 --- a/crates/polyglot-sql/src/dialects/tsql.rs +++ b/crates/polyglot-sql/src/dialects/tsql.rs @@ -62,8 +62,8 @@ impl DialectImpl for TSQLDialect { identifier_quote_style: IdentifierQuoteStyle::BRACKET, dialect: Some(DialectType::TSQL), // T-SQL specific settings from Python sqlglot - // SQL Server uses TOP/FETCH instead of LIMIT - limit_fetch_style: crate::generator::LimitFetchStyle::FetchFirst, + // SQL Server uses TOP (or OFFSET/FETCH, handled by the generator) instead of LIMIT + limit_fetch_style: crate::generator::LimitFetchStyle::Top, // NULLS FIRST/LAST not supported in SQL Server null_ordering_supported: false, // SQL Server does not support SQL:2003 aggregate FILTER clauses. diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index cc5cf7db..44fb9edf 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -5174,6 +5174,7 @@ impl Generator { Some(DialectType::TSQL) | Some(DialectType::Teradata) | Some(DialectType::Fabric) ); let keep_top_verbatim = !is_top_dialect + && !self.uses_fetch_first_limit() && select.limit.is_none() && select .top @@ -5731,7 +5732,7 @@ impl Generator { Some(DialectType::Presto) | Some(DialectType::Trino) ); - if is_presto_like && select.offset.is_some() { + if is_presto_like && select.offset.is_some() && !self.uses_fetch_first_limit() { // Presto/Trino syntax: OFFSET n LIMIT m (offset comes first) if let Some(offset) = &select.offset { if self.config.pretty { @@ -5791,8 +5792,12 @@ impl Generator { ) }); + // FETCH FIRST dialects (Oracle) render LIMIT/TOP as a row-limiting clause + // after OFFSET. An explicit FETCH on the same SELECT keeps the LIMIT as-is. + let limit_as_fetch = self.uses_fetch_first_limit() && select.fetch.is_none(); + // Standard LIMIT clause (skip for SQL Server - we use TOP or OFFSET/FETCH instead) - if let Some(limit) = &select.limit { + if let Some(limit) = select.limit.as_ref().filter(|_| !limit_as_fetch) { // SQL Server uses TOP (no OFFSET) or OFFSET/FETCH (with OFFSET) instead of LIMIT if !matches!( self.config.dialect, @@ -5826,7 +5831,8 @@ impl Generator { } // Convert TOP to LIMIT for non-TOP dialects - if select.top.is_some() && !is_top_dialect && select.limit.is_none() { + if select.top.is_some() && !is_top_dialect && select.limit.is_none() && !limit_as_fetch + { if let Some(top) = &select.top { if !top.percent && !top.with_ties { if self.config.pretty { @@ -5895,13 +5901,40 @@ impl Generator { self.write_keyword("OFFSET"); self.write_space(); self.write_limit_expr(&offset.this)?; - // Output ROWS keyword if it was in the original SQL - if offset.rows == Some(true) { + // Output ROWS keyword if it was in the original SQL; the + // ANSI row-limiting clause requires it. + if offset.rows == Some(true) || self.uses_fetch_first_limit() { self.write_space(); self.write_keyword("ROWS"); } } } + + if limit_as_fetch { + // LIMIT ALL / LIMIT NULL mean "no limit": emit nothing. + let fetch = match (&select.limit, &select.top) { + (Some(limit), _) if !Self::is_noop_limit_expr(&limit.this) => { + Some((&limit.this, limit.percent, false, limit.comments.as_slice())) + } + (None, Some(top)) if !is_top_dialect => { + Some((&top.this, top.percent, top.with_ties, &[][..])) + } + _ => None, + }; + if let Some((count, percent, with_ties, comments)) = fetch { + if self.config.pretty { + self.write_newline(); + self.write_indent(); + } else { + self.write_space(); + } + self.write_fetch_first(count, percent, with_ties)?; + for comment in comments { + self.write(" "); + self.write_formatted_comment(comment); + } + } + } } // ClickHouse LIMIT BY clause (after LIMIT/OFFSET) @@ -7681,7 +7714,7 @@ impl Generator { } // Generate the base (leftmost) expression - self.generate_expression(leftmost)?; + self.generate_set_operand(leftmost)?; // Generate each union step from innermost to outermost for union in chain.iter().rev() { @@ -7743,7 +7776,7 @@ impl Generator { } else { self.write_space(); } - self.generate_expression(&union.right)?; + self.generate_set_operand(&union.right)?; // ORDER BY, LIMIT, OFFSET for the set operation if let Some(order_by) = &union.order_by { if self.config.pretty { @@ -7760,26 +7793,7 @@ impl Generator { self.generate_ordered(ordered)?; } } - if let Some(limit) = &union.limit { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("LIMIT"); - self.write_space(); - self.generate_expression(limit)?; - } - if let Some(offset) = &union.offset { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("OFFSET"); - self.write_space(); - self.generate_expression(offset)?; - } + self.generate_set_op_limit_offset(union.limit.as_deref(), union.offset.as_deref())?; // DISTRIBUTE BY (Hive/Spark) if let Some(distribute_by) = &union.distribute_by { self.write_space(); @@ -7864,7 +7878,7 @@ impl Generator { self.write_space(); } - self.generate_expression(leftmost)?; + self.generate_set_operand(leftmost)?; for intersect in chain.iter().rev() { self.generate_intersect_step(intersect)?; @@ -7928,7 +7942,7 @@ impl Generator { } else { self.write_space(); } - self.generate_expression(&intersect.right)?; + self.generate_set_operand(&intersect.right)?; // ORDER BY, LIMIT, OFFSET for the set operation if let Some(order_by) = &intersect.order_by { if self.config.pretty { @@ -7945,26 +7959,7 @@ impl Generator { self.generate_ordered(ordered)?; } } - if let Some(limit) = &intersect.limit { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("LIMIT"); - self.write_space(); - self.generate_expression(limit)?; - } - if let Some(offset) = &intersect.offset { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("OFFSET"); - self.write_space(); - self.generate_expression(offset)?; - } + self.generate_set_op_limit_offset(intersect.limit.as_deref(), intersect.offset.as_deref())?; // DISTRIBUTE BY (Hive/Spark) if let Some(distribute_by) = &intersect.distribute_by { self.write_space(); @@ -8048,7 +8043,7 @@ impl Generator { self.write_space(); } - self.generate_expression(leftmost)?; + self.generate_set_operand(leftmost)?; for except in chain.iter().rev() { self.generate_except_step(except)?; @@ -8146,7 +8141,7 @@ impl Generator { } else { self.write_space(); } - self.generate_expression(&except.right)?; + self.generate_set_operand(&except.right)?; // ORDER BY, LIMIT, OFFSET for the set operation if let Some(order_by) = &except.order_by { if self.config.pretty { @@ -8163,26 +8158,7 @@ impl Generator { self.generate_ordered(ordered)?; } } - if let Some(limit) = &except.limit { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("LIMIT"); - self.write_space(); - self.generate_expression(limit)?; - } - if let Some(offset) = &except.offset { - if self.config.pretty { - self.write_newline(); - } else { - self.write_space(); - } - self.write_keyword("OFFSET"); - self.write_space(); - self.generate_expression(offset)?; - } + self.generate_set_op_limit_offset(except.limit.as_deref(), except.offset.as_deref())?; // DISTRIBUTE BY (Hive/Spark) if let Some(distribute_by) = &except.distribute_by { self.write_space(); @@ -26673,23 +26649,7 @@ impl Generator { } } - if let Some(limit) = &subquery.limit { - self.write_space(); - self.write_keyword("LIMIT"); - self.write_space(); - self.generate_expression(&limit.this)?; - if limit.percent { - self.write_space(); - self.write_keyword("PERCENT"); - } - } - - if let Some(offset) = &subquery.offset { - self.write_space(); - self.write_keyword("OFFSET"); - self.write_space(); - self.generate_expression(&offset.this)?; - } + self.generate_subquery_limit_offset(subquery)?; } if !skip_outer_parens { @@ -26715,23 +26675,7 @@ impl Generator { } } - if let Some(limit) = &subquery.limit { - self.write_space(); - self.write_keyword("LIMIT"); - self.write_space(); - self.generate_expression(&limit.this)?; - if limit.percent { - self.write_space(); - self.write_keyword("PERCENT"); - } - } - - if let Some(offset) = &subquery.offset { - self.write_space(); - self.write_keyword("OFFSET"); - self.write_space(); - self.generate_expression(&offset.this)?; - } + self.generate_subquery_limit_offset(subquery)?; // Generate DISTRIBUTE BY (Hive/Spark) if let Some(distribute_by) = &subquery.distribute_by { @@ -29930,6 +29874,89 @@ impl Generator { self.generate_expression(expr) } + /// Whether a plain `LIMIT n` must be rendered as the ANSI row-limiting clause + /// `[OFFSET m ROWS] FETCH FIRST n ROWS ONLY` (Oracle, DB2, ...). + /// + /// The configured style takes precedence over dialect-specific LIMIT/OFFSET + /// handling, except for T-SQL/Fabric: their TOP / `OFFSET ... FETCH NEXT` + /// rendering is already a row-limiting clause, and FETCH requires ORDER BY there. + fn uses_fetch_first_limit(&self) -> bool { + self.config.limit_fetch_style == LimitFetchStyle::FetchFirst + && !matches!( + self.config.dialect, + Some(DialectType::TSQL) | Some(DialectType::Fabric) + ) + } + + /// Generate a UNION / INTERSECT / EXCEPT operand. A bare SELECT whose row + /// limit renders as a trailing clause (e.g. T-SQL `TOP n` as `LIMIT n` or + /// `FETCH FIRST n ROWS ONLY`) is parenthesized, so the limit stays on that + /// branch instead of binding to the whole set operation. ClickHouse already + /// binds branch LIMITs locally. + fn generate_set_operand(&mut self, expr: &Expression) -> Result<()> { + // Group the annotated operand itself, keeping its comments outside the parentheses. + if let Expression::Annotated(annotated) = expr { + self.generate_set_operand(&annotated.this)?; + for comment in &annotated.trailing_comments { + self.write(" "); + self.write_formatted_comment(comment); + } + return Ok(()); + } + let Expression::Select(select) = expr else { + return self.generate_expression(expr); + }; + if self.config.dialect == Some(DialectType::ClickHouse) { + return self.generate_expression(expr); + } + let tsql_like = matches!( + self.config.dialect, + Some(DialectType::TSQL) | Some(DialectType::Fabric) + ); + let is_top_dialect = tsql_like || self.config.dialect == Some(DialectType::Teradata); + let trailing_top = select.top.as_ref().is_some_and(|top| { + !is_top_dialect && (self.uses_fetch_first_limit() || !(top.percent || top.with_ties)) + }); + let trailing_limit = select.offset.is_some() + || (select.limit.is_some() && !tsql_like) + || (select.limit.is_none() && trailing_top); + if !trailing_limit { + return self.generate_expression(expr); + } + if self.config.dialect == Some(DialectType::SQLite) { + // SQLite does not accept parenthesized compound-select operands. + self.write_keyword("SELECT * FROM"); + self.write_space(); + } + self.write("("); + self.generate_expression(expr)?; + self.write(")"); + Ok(()) + } + + /// Write `FETCH FIRST [PERCENT] ROWS {ONLY | WITH TIES}`. + fn write_fetch_first( + &mut self, + count: &Expression, + percent: bool, + with_ties: bool, + ) -> Result<()> { + self.write_keyword("FETCH FIRST"); + self.write_space(); + self.write_limit_expr(count)?; + if percent { + self.write_space(); + self.write_keyword("PERCENT"); + } + self.write_space(); + self.write_keyword(if with_ties { + "ROWS WITH TIES" + } else { + "ROWS ONLY" + }); + Ok(()) + } + fn is_noop_limit_expr(expr: &Expression) -> bool { match expr { Expression::Null(_) => true, @@ -35935,13 +35962,101 @@ impl Generator { Ok(()) } + /// Trailing `LIMIT n OFFSET m` of a UNION / INTERSECT / EXCEPT, or + /// `OFFSET m ROWS FETCH FIRST n ROWS ONLY` for FETCH FIRST dialects. + fn generate_set_op_limit_offset( + &mut self, + limit: Option<&Expression>, + offset: Option<&Expression>, + ) -> Result<()> { + let fetch_first = self.uses_fetch_first_limit(); + if let Some(limit) = limit.filter(|_| !fetch_first) { + if self.config.pretty { + self.write_newline(); + } else { + self.write_space(); + } + self.write_keyword("LIMIT"); + self.write_space(); + self.generate_expression(limit)?; + } + if let Some(offset) = offset { + if self.config.pretty { + self.write_newline(); + } else { + self.write_space(); + } + self.write_keyword("OFFSET"); + self.write_space(); + self.generate_expression(offset)?; + if fetch_first { + self.write_space(); + self.write_keyword("ROWS"); + } + } + if let Some(limit) = limit.filter(|l| fetch_first && !Self::is_noop_limit_expr(l)) { + if self.config.pretty { + self.write_newline(); + } else { + self.write_space(); + } + self.write_fetch_first(limit, false, false)?; + } + Ok(()) + } + + /// `LIMIT` / `OFFSET` modifiers attached to a subquery, or + /// `OFFSET m ROWS FETCH FIRST n ROWS ONLY` for FETCH FIRST dialects. + fn generate_subquery_limit_offset(&mut self, subquery: &Subquery) -> Result<()> { + let fetch_first = self.uses_fetch_first_limit(); + if let Some(limit) = subquery.limit.as_ref().filter(|_| !fetch_first) { + self.write_space(); + self.write_keyword("LIMIT"); + self.write_space(); + self.generate_expression(&limit.this)?; + if limit.percent { + self.write_space(); + self.write_keyword("PERCENT"); + } + } + + if let Some(offset) = &subquery.offset { + self.write_space(); + self.write_keyword("OFFSET"); + self.write_space(); + self.generate_expression(&offset.this)?; + if fetch_first { + self.write_space(); + self.write_keyword("ROWS"); + } + } + + if let Some(limit) = subquery + .limit + .as_ref() + .filter(|l| fetch_first && !Self::is_noop_limit_expr(&l.this)) + { + self.write_space(); + self.write_fetch_first(&limit.this, limit.percent, false)?; + } + Ok(()) + } + fn generate_limit(&mut self, e: &Limit) -> Result<()> { - self.write_keyword("LIMIT"); - self.write_space(); - self.write_limit_expr(&e.this)?; - if e.percent { + if self.uses_fetch_first_limit() { + // LIMIT ALL / LIMIT NULL mean "no limit": there is no FETCH equivalent. + if Self::is_noop_limit_expr(&e.this) { + return Ok(()); + } + self.write_fetch_first(&e.this, e.percent, false)?; + } else { + self.write_keyword("LIMIT"); self.write_space(); - self.write_keyword("PERCENT"); + self.write_limit_expr(&e.this)?; + if e.percent { + self.write_space(); + self.write_keyword("PERCENT"); + } } // Emit any comments that were captured from before the LIMIT keyword for comment in &e.comments { @@ -44844,6 +44959,144 @@ mod tests { crate::builder::col(name).into_inner() } + fn fetch_first_sql(expr: &Expression, dialect: Option, pretty: bool) -> String { + let config = GeneratorConfig { + limit_fetch_style: LimitFetchStyle::FetchFirst, + dialect, + pretty, + ..Default::default() + }; + Generator::with_config(config).generate(expr).unwrap() + } + + fn parse_one(sql: &str) -> Expression { + Parser::parse_sql(sql).unwrap().remove(0) + } + + #[test] + fn test_fetch_first_style_renders_builder_limits() { + let limit = crate::builder::from("t") + .select_cols(["a"]) + .limit(5) + .build(); + assert_eq!( + fetch_first_sql(&limit, None, false), + "SELECT a FROM t FETCH FIRST 5 ROWS ONLY" + ); + let limit_offset = crate::builder::from("t") + .select_cols(["a"]) + .limit(5) + .offset(10) + .build(); + assert_eq!( + fetch_first_sql(&limit_offset, Some(DialectType::Oracle), false), + "SELECT a FROM t OFFSET 10 ROWS FETCH FIRST 5 ROWS ONLY" + ); + assert_eq!( + fetch_first_sql(&limit_offset, Some(DialectType::Oracle), true), + "SELECT\n a\nFROM t\nOFFSET 10 ROWS\nFETCH FIRST 5 ROWS ONLY" + ); + } + + #[test] + fn test_fetch_first_style_drops_noop_limits() { + for sql in ["SELECT a FROM t LIMIT ALL", "SELECT a FROM t LIMIT NULL"] { + assert_eq!( + fetch_first_sql(&parse_one(sql), None, false), + "SELECT a FROM t" + ); + } + for this in [ + Expression::Null(Null), + Expression::Var(Box::new(Var { this: "ALL".into() })), + ] { + let limit = Expression::Limit(Box::new(Limit { + this, + percent: false, + comments: Vec::new(), + })); + assert_eq!(fetch_first_sql(&limit, None, false), ""); + } + } + + /// Comment-annotated set operands are grouped like bare ones, with the comment + /// rendered outside the parentheses. + #[test] + fn test_annotated_set_operand_limit_stays_branch_local() { + let annotate = |sql: &str| { + Expression::Annotated(Box::new(Annotated { + this: parse_one(sql), + trailing_comments: vec![" branch note ".into()], + })) + }; + let limited = || annotate("SELECT a FROM t LIMIT 5"); + let plain = || parse_one("SELECT a FROM u"); + for op in ["UNION ALL", "INTERSECT", "EXCEPT"] { + for limited_left in [true, false] { + let mut expr = parse_one(&format!("SELECT a FROM t {op} SELECT a FROM u")); + let (left, right) = match &mut expr { + Expression::Union(u) => (&mut u.left, &mut u.right), + Expression::Intersect(i) => (&mut i.left, &mut i.right), + Expression::Except(e) => (&mut e.left, &mut e.right), + other => panic!("unexpected {other:?}"), + }; + let expected = if limited_left { + (*left, *right) = (limited(), plain()); + format!("(SELECT a FROM t LIMIT 5) /* branch note */ {op} SELECT a FROM u") + } else { + (*left, *right) = (plain(), limited()); + format!("SELECT a FROM u {op} (SELECT a FROM t LIMIT 5) /* branch note */") + }; + let config = GeneratorConfig { + dialect: Some(DialectType::DuckDB), + ..Default::default() + }; + assert_eq!( + Generator::with_config(config).generate(&expr).unwrap(), + expected + ); + } + } + } + + #[test] + fn test_fetch_first_style_precedence_over_dialect_limit_handling() { + let sql = "SELECT a FROM t ORDER BY a LIMIT 5 OFFSET 2"; + // T-SQL/Fabric keep their own TOP / OFFSET ... FETCH NEXT rendering. + for dialect in [DialectType::TSQL, DialectType::Fabric] { + assert_eq!( + fetch_first_sql(&parse_one(sql), Some(dialect), false), + "SELECT a FROM t ORDER BY a OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY", + "{dialect:?}" + ); + assert_eq!( + fetch_first_sql(&parse_one("SELECT a FROM t LIMIT 5"), Some(dialect), false), + "SELECT TOP 5 a FROM t", + "{dialect:?}" + ); + } + // Everywhere else the configured style wins, with or without OFFSET. + for dialect in [ + DialectType::Presto, + DialectType::Trino, + DialectType::PostgreSQL, + ] { + assert_eq!( + fetch_first_sql(&parse_one(sql), Some(dialect), false), + "SELECT a FROM t ORDER BY a OFFSET 2 ROWS FETCH FIRST 5 ROWS ONLY", + "{dialect:?}" + ); + } + // An explicit FETCH is kept verbatim. + let fetch = "SELECT a FROM t ORDER BY a OFFSET 2 ROWS FETCH FIRST 5 ROWS ONLY"; + for dialect in [DialectType::TSQL, DialectType::Trino, DialectType::Oracle] { + assert_eq!( + fetch_first_sql(&parse_one(fetch), Some(dialect), false), + fetch + ); + } + } + #[test] fn test_date_time_format_converters_preserve_unicode() { let converters: &[(&str, fn(&str) -> String, &str, &str)] = &[ diff --git a/crates/polyglot-sql/tests/dialect_matrix.rs b/crates/polyglot-sql/tests/dialect_matrix.rs index 84c0b223..5371cbbb 100644 --- a/crates/polyglot-sql/tests/dialect_matrix.rs +++ b/crates/polyglot-sql/tests/dialect_matrix.rs @@ -5753,3 +5753,192 @@ mod hana_regressions { } } } + +mod oracle_row_limit_regressions { + use super::*; + use DialectType::*; + + #[test] + fn postgres_limits_render_as_fetch_first_in_nested_queries() { + let cases = [ + ( + "SELECT a FROM t LIMIT 5", + "SELECT a FROM t FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT a FROM t ORDER BY a LIMIT 5 OFFSET 10", + "SELECT a FROM t ORDER BY a OFFSET 10 ROWS FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT a FROM t OFFSET 10", + "SELECT a FROM t OFFSET 10 ROWS", + ), + ("SELECT a FROM t LIMIT ALL", "SELECT a FROM t"), + ("SELECT a FROM t LIMIT NULL", "SELECT a FROM t"), + ( + "SELECT * FROM (SELECT a FROM t LIMIT 5) AS s", + "SELECT * FROM (SELECT a FROM t FETCH FIRST 5 ROWS ONLY) s", + ), + ( + "SELECT a FROM t WHERE a IN (SELECT b FROM u LIMIT 3)", + "SELECT a FROM t WHERE a IN (SELECT b FROM u FETCH FIRST 3 ROWS ONLY)", + ), + ( + "WITH c AS (SELECT a FROM t LIMIT 2) SELECT * FROM c", + "WITH c AS (SELECT a FROM t FETCH FIRST 2 ROWS ONLY) SELECT * FROM c", + ), + ( + "INSERT INTO x SELECT a FROM t LIMIT 5", + "INSERT INTO x SELECT a FROM t FETCH FIRST 5 ROWS ONLY", + ), + ]; + for (sql, expected) in cases { + assert_eq!(transpile(sql, PostgreSQL, Oracle), expected, "{sql}"); + } + } + + #[test] + fn set_operation_limits_apply_to_the_whole_set_operation() { + let cases = [ + ( + "SELECT a FROM t UNION ALL SELECT b FROM u LIMIT 5", + "SELECT a FROM t UNION ALL SELECT b FROM u FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT a FROM t UNION ALL SELECT b FROM u ORDER BY 1 LIMIT 5 OFFSET 2", + "SELECT a FROM t UNION ALL SELECT b FROM u ORDER BY 1 OFFSET 2 ROWS FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT a FROM t INTERSECT SELECT b FROM u LIMIT 5", + "SELECT a FROM t INTERSECT SELECT b FROM u FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT a FROM t EXCEPT SELECT b FROM u LIMIT 5", + "SELECT a FROM t MINUS SELECT b FROM u FETCH FIRST 5 ROWS ONLY", + ), + // Parenthesized branch limits stay on their branch. + ( + "(SELECT a FROM t LIMIT 5) UNION ALL SELECT a FROM u ORDER BY 1 LIMIT 3", + "(SELECT a FROM t FETCH FIRST 5 ROWS ONLY) UNION ALL SELECT a FROM u ORDER BY 1 FETCH FIRST 3 ROWS ONLY", + ), + ]; + for (sql, expected) in cases { + assert_eq!(transpile(sql, PostgreSQL, Oracle), expected, "{sql}"); + } + } + + #[test] + fn tsql_top_maps_to_fetch_first() { + let cases = [ + ( + "SELECT TOP 5 a FROM t", + "SELECT a FROM t FETCH FIRST 5 ROWS ONLY", + ), + ( + "SELECT TOP 10 PERCENT a FROM t", + "SELECT a FROM t FETCH FIRST 10 PERCENT ROWS ONLY", + ), + ( + "SELECT TOP 5 WITH TIES a FROM t ORDER BY a", + "SELECT a FROM t ORDER BY a NULLS FIRST FETCH FIRST 5 ROWS WITH TIES", + ), + ]; + for (sql, expected) in cases { + assert_eq!(transpile(sql, TSQL, Oracle), expected, "{sql}"); + } + } + + /// `SELECT TOP n` limits only its own branch; the converted trailing limit must not + /// bind to the whole set operation. + #[test] + fn tsql_top_in_set_operation_branch_stays_branch_local() { + let cases = [ + ( + "SELECT a FROM t UNION ALL SELECT TOP 5 a FROM u", + "SELECT a FROM t UNION ALL (SELECT a FROM u FETCH FIRST 5 ROWS ONLY)", + "SELECT a FROM t UNION ALL (SELECT a FROM u LIMIT 5)", + ), + ( + "SELECT TOP 5 a FROM t UNION ALL SELECT a FROM u", + "(SELECT a FROM t FETCH FIRST 5 ROWS ONLY) UNION ALL SELECT a FROM u", + "(SELECT a FROM t LIMIT 5) UNION ALL SELECT a FROM u", + ), + ( + "SELECT TOP 5 a FROM t INTERSECT SELECT TOP 2 a FROM u", + "(SELECT a FROM t FETCH FIRST 5 ROWS ONLY) INTERSECT (SELECT a FROM u FETCH FIRST 2 ROWS ONLY)", + "(SELECT a FROM t LIMIT 5) INTERSECT (SELECT a FROM u LIMIT 2)", + ), + ( + "SELECT TOP 5 a FROM t EXCEPT SELECT a FROM u ORDER BY a", + "(SELECT a FROM t FETCH FIRST 5 ROWS ONLY) MINUS SELECT a FROM u ORDER BY a NULLS FIRST", + "(SELECT a FROM t LIMIT 5) EXCEPT SELECT a FROM u ORDER BY a NULLS FIRST", + ), + ]; + for (sql, oracle, duckdb) in cases { + assert_eq!(transpile(sql, TSQL, Oracle), oracle, "{sql}"); + assert_eq!(transpile(sql, TSQL, DuckDB), duckdb, "{sql}"); + assert_eq!(transpile(sql, TSQL, TSQL), sql, "{sql}"); + } + // SQLite rejects parenthesized compound operands; use a derived table instead. + assert_eq!( + transpile( + "SELECT a FROM t UNION ALL SELECT TOP 5 a FROM u", + TSQL, + SQLite + ), + "SELECT a FROM t UNION ALL SELECT * FROM (SELECT a FROM u LIMIT 5)" + ); + } + + /// A comment between an operand and the set operator wraps the operand in + /// `Annotated`; its branch limit must still be grouped, with the comment kept + /// outside the parentheses. + #[test] + fn commented_tsql_top_set_operand_stays_branch_local() { + for op in ["UNION ALL", "INTERSECT", "EXCEPT"] { + for comment in ["/* branch note */", "-- branch note"] { + let sql = format!("SELECT TOP 5 a FROM t\n{comment}\n{op} SELECT a FROM u"); + assert_eq!( + transpile(&sql, TSQL, DuckDB), + format!("(SELECT a FROM t LIMIT 5) /* branch note */ {op} SELECT a FROM u"), + "{sql}" + ); + assert_eq!( + transpile(&sql, TSQL, SQLite), + format!("SELECT * FROM (SELECT a FROM t LIMIT 5) /* branch note */ {op} SELECT a FROM u"), + "{sql}" + ); + assert_eq!( + transpile(&sql, TSQL, Oracle), + format!( + "(SELECT a FROM t FETCH FIRST 5 ROWS ONLY) /* branch note */ {} SELECT a FROM u", + if op == "EXCEPT" { "MINUS" } else { op } + ), + "{sql}" + ); + } + } + } + + #[test] + fn oracle_fetch_identity_is_preserved() { + for sql in [ + "SELECT a FROM t FETCH FIRST 5 ROWS ONLY", + "SELECT a FROM t OFFSET 1 ROWS FETCH NEXT 5 ROWS ONLY", + "SELECT a FROM t ORDER BY a FETCH FIRST 5 ROWS WITH TIES", + ] { + assert_eq!(transpile(sql, Oracle, Oracle), sql); + } + } + + #[test] + fn limit_dialects_still_emit_limit() { + for target in [PostgreSQL, DuckDB, MySQL] { + assert_eq!( + transpile("SELECT a FROM t LIMIT 5 OFFSET 2", Generic, target), + "SELECT a FROM t LIMIT 5 OFFSET 2", + "{target:?}" + ); + } + } +}