diff --git a/crates/dbx-core/src/data_compare.rs b/crates/dbx-core/src/data_compare.rs index 545259cf8d..f38cd612ab 100644 --- a/crates/dbx-core/src/data_compare.rs +++ b/crates/dbx-core/src/data_compare.rs @@ -10,8 +10,8 @@ use crate::models::connection::DatabaseType; use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions}; use crate::schema::get_columns_core; use crate::sql_dialect::{ - build_count_table_sql, pagination_strategy, qualified_table_name, quote_table_identifier, PaginationContext, - TablePaginationStrategy, + build_count_table_sql, firebird_rows_clause, pagination_strategy, qualified_table_name, quote_table_identifier, + PaginationContext, TablePaginationStrategy, }; use crate::transfer::{generate_comment_ddl, generate_create_table_ddl}; @@ -906,6 +906,10 @@ fn build_data_compare_select_sql( if offset > 0 { format!("SKIP {offset} FIRST {row_limit}") } else { format!("FIRST {row_limit}") }; format!("SELECT {row_limit_clause} {select_columns} FROM {table}{order_by}") } + TablePaginationStrategy::FirebirdRows => { + let rows = firebird_rows_clause(row_limit, offset); + format!("SELECT {select_columns} FROM {table}{order_by} {rows}") + } TablePaginationStrategy::AgentMaxRows => format!("SELECT {select_columns} FROM {table}{order_by};"), TablePaginationStrategy::Unbounded => format!("SELECT {select_columns} FROM {table}{order_by}"), TablePaginationStrategy::QuestDbLimit => { @@ -1469,6 +1473,22 @@ mod tests { ); } + #[test] + fn builds_backend_table_select_sql_for_firebird_rows_syntax() { + assert_eq!( + build_data_compare_select_sql( + DatabaseType::Firebird, + "ignored", + "USERS", + &["ID".to_string(), "NAME".to_string()], + &["ID".to_string()], + 25, + 50, + ), + "SELECT \"ID\", \"NAME\" FROM \"USERS\" ORDER BY \"ID\" ASC ROWS 51 TO 75" + ); + } + #[test] fn builds_backend_table_select_sql_for_oceanbase_oracle_rownum_pages() { assert_eq!( diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index f01b53435c..9ae4b83eb5 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -11,7 +11,9 @@ mod data_grid_tdengine_sql; use data_grid_tdengine_sql::build_tdengine_data_grid_save_statements; use crate::models::connection::DatabaseType; -use crate::sql_dialect::{quote_table_identifier, table_pagination_strategy, TablePaginationStrategy}; +use crate::sql_dialect::{ + firebird_rows_clause, quote_table_identifier, table_pagination_strategy, TablePaginationStrategy, +}; use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal}; const DBX_ROWID_COLUMN: &str = "__DBX_ROWID"; @@ -496,6 +498,10 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc TablePaginationStrategy::SqlServerTop => format!("SELECT TOP ({limit}) {select_list}{from_clause}"), TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} {select_list}{from_clause}"), TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} {select_list}{from_clause}"), + TablePaginationStrategy::FirebirdRows => { + let rows = firebird_rows_clause(limit, 0); + format!("SELECT {select_list}{from_clause} {rows}") + } TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => { format!("SELECT {select_list}{from_clause} FETCH FIRST {limit} ROWS ONLY") } @@ -2283,6 +2289,20 @@ mod tests { }), "SELECT * FROM (SELECT \"KIND\" AS dbx_value, COUNT(*) AS dbx_count FROM \"APP\".\"EVENTS\" GROUP BY \"KIND\" ORDER BY dbx_count DESC, dbx_value) WHERE ROWNUM <= 10" ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::Firebird), + schema: None, + table_name: "USERS".to_string(), + column_name: "STATUS".to_string(), + column_info: Some(column("STATUS", "varchar(32)", true, None)), + where_input: Some("WHERE DELETED_AT IS NULL".to_string()), + search_value: None, + limit: Some(25), + include_counts: false, + }), + "SELECT \"STATUS\" AS dbx_value FROM \"USERS\" WHERE (DELETED_AT IS NULL) GROUP BY \"STATUS\" ORDER BY dbx_value ROWS 25" + ); } #[test] diff --git a/crates/dbx-core/src/database_search_sql.rs b/crates/dbx-core/src/database_search_sql.rs index 2bfb615b47..036736d8d1 100644 --- a/crates/dbx-core/src/database_search_sql.rs +++ b/crates/dbx-core/src/database_search_sql.rs @@ -3,7 +3,8 @@ use serde_json::Value; use crate::models::connection::DatabaseType; use crate::sql_dialect::{ - pagination_strategy, qualified_table_name, quote_table_identifier, PaginationContext, TablePaginationStrategy, + firebird_rows_clause, pagination_strategy, qualified_table_name, quote_table_identifier, PaginationContext, + TablePaginationStrategy, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -113,6 +114,10 @@ pub fn build_database_search_sql(options: DatabaseSearchSqlOptions) -> Option format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})"), TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} * FROM {table} WHERE ({where_clause})"), TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} * FROM {table} WHERE ({where_clause})"), + TablePaginationStrategy::FirebirdRows => { + let rows = firebird_rows_clause(limit, 0); + format!("SELECT * FROM {table} WHERE ({where_clause}) {rows}") + } TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => { format!("SELECT * FROM {table} WHERE ({where_clause}) FETCH FIRST {limit} ROWS ONLY") } diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index d6655bb90b..9634082173 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::models::connection::DatabaseType; use crate::sql::find_statement_at_cursor; -use crate::sql_dialect::{pagination_strategy, quote_table_identifier, PaginationContext, TablePaginationStrategy}; +use crate::sql_dialect::{ + firebird_rows_clause, pagination_strategy, quote_table_identifier, PaginationContext, TablePaginationStrategy, +}; use sqlparser::ast::{Expr, GroupByExpr, SelectItem, SetExpr, Statement}; use sqlparser::dialect::GenericDialect; use sqlparser::parser::Parser; @@ -187,6 +189,7 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB .unwrap_or_else(|| err("unsupported")), TablePaginationStrategy::QuestDbLimit => ok(add_questdb_limit(&statement, safe_limit, safe_offset)), TablePaginationStrategy::InformixFirst => ok(add_informix_first_limit(&statement, safe_limit, safe_offset)), + TablePaginationStrategy::FirebirdRows => ok(add_firebird_rows_limit(&statement, safe_limit, safe_offset)), TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => { ok(add_fetch_first_limit(&statement, safe_limit, safe_offset)) } @@ -790,6 +793,26 @@ fn has_top_level_informix_row_limit(sql: &str) -> bool { tokens[select_index + 1..from_index].iter().any(|token| token.text == "FIRST" || token.text == "SKIP") } +fn has_top_level_firebird_row_limit(sql: &str) -> bool { + if has_top_level_fetch_first(sql) { + return true; + } + let tokens = top_level_sql_tokens(sql); + if tokens.iter().any(|token| token.text == "ROWS") { + return true; + } + let Some(select_index) = tokens.iter().position(|token| token.text == "SELECT") else { + return false; + }; + let from_index = tokens + .iter() + .enumerate() + .find(|(index, token)| *index > select_index && token.text == "FROM") + .map(|(index, _)| index) + .unwrap_or(tokens.len()); + tokens[select_index + 1..from_index].iter().any(|token| token.text == "FIRST" || token.text == "SKIP") +} + fn has_top_level_fetch_first(sql: &str) -> bool { let tokens = top_level_sql_tokens(sql); tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "FIRST") @@ -822,6 +845,14 @@ fn add_fetch_first_limit(statement: &str, limit: usize, offset: usize) -> String append_sql_suffix(statement, &format!("{offset_sql} FETCH FIRST {limit} ROWS ONLY;")) } +fn add_firebird_rows_limit(statement: &str, limit: usize, offset: usize) -> String { + if has_top_level_firebird_row_limit(statement) { + return format!("{statement};"); + } + let rows = firebird_rows_clause(limit, offset); + append_sql_suffix(statement, &format!("{rows};")) +} + fn add_rownum_limit(statement: &str, limit: usize, offset: usize) -> String { if has_top_level_rownum(statement) { return format!("{statement};"); @@ -1722,6 +1753,30 @@ WHERE u.id = picked.id; assert_eq!(result.sql.unwrap(), "SELECT FIRST 20 id FROM users;"); } + #[test] + fn uses_rows_pagination_for_firebird() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT id FROM users WHERE active = 1 ORDER BY id".to_string(), + database_type: Some(DatabaseType::Firebird), + limit: 50, + offset: 100, + }); + + assert_eq!(result.sql.unwrap(), "SELECT id FROM users WHERE active = 1 ORDER BY id ROWS 101 TO 150;"); + } + + #[test] + fn firebird_pagination_keeps_existing_rows_clause() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT id FROM users ROWS 20".to_string(), + database_type: Some(DatabaseType::Firebird), + limit: 50, + offset: 0, + }); + + assert_eq!(result.sql.unwrap(), "SELECT id FROM users ROWS 20;"); + } + #[test] fn uses_mysql_style_alias_for_pagination() { let result = build_paginated_query_sql(PaginatedQuerySqlOptions { diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index e14fa10f21..f8763359c4 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -7,8 +7,8 @@ mod types; mod tests; pub use capabilities::{ - is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first, PaginationContext, - TablePaginationStrategy, + firebird_rows_clause, is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first, + PaginationContext, TablePaginationStrategy, }; pub use identifiers::{normalize_where_input, qualified_table_name, quote_table_identifier}; pub(crate) use identifiers::{parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_transfer_identifier}; diff --git a/crates/dbx-core/src/sql_dialect/capabilities.rs b/crates/dbx-core/src/sql_dialect/capabilities.rs index caf08cac89..8775c01a99 100644 --- a/crates/dbx-core/src/sql_dialect/capabilities.rs +++ b/crates/dbx-core/src/sql_dialect/capabilities.rs @@ -8,6 +8,7 @@ pub enum TablePaginationStrategy { SqlServerTop, IrisTop, InformixFirst, + FirebirdRows, Rownum, QuestDbLimit, Unbounded, @@ -84,6 +85,7 @@ pub fn pagination_strategy(database_type: Option, context: Paginat Some(DatabaseType::SqlServer) => TablePaginationStrategy::SqlServerTop, Some(DatabaseType::Iris) => TablePaginationStrategy::IrisTop, Some(DatabaseType::Informix) => TablePaginationStrategy::InformixFirst, + Some(DatabaseType::Firebird) => TablePaginationStrategy::FirebirdRows, Some(DatabaseType::OceanbaseOracle) => TablePaginationStrategy::Rownum, Some(DatabaseType::Questdb) => TablePaginationStrategy::QuestDbLimit, _ => TablePaginationStrategy::LimitOffset, @@ -102,3 +104,13 @@ pub(super) fn is_simple_informix_identifier(name: &str) -> bool { (first.is_ascii_alphabetic() || first == '_') && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') } + +pub fn firebird_rows_clause(limit: usize, offset: usize) -> String { + if offset > 0 { + let start = offset + 1; + let end = offset + limit; + format!("ROWS {start} TO {end}") + } else { + format!("ROWS {limit}") + } +} diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 0986c3f4a8..a4f81c4741 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -1,6 +1,6 @@ use crate::models::connection::DatabaseType; -use super::capabilities::{table_pagination_strategy, uses_fetch_first, TablePaginationStrategy}; +use super::capabilities::{firebird_rows_clause, table_pagination_strategy, uses_fetch_first, TablePaginationStrategy}; use super::identifiers::{normalize_where_input, qualified_table_name, quote_table_identifier}; use super::types::{ TableDataSelectSqlOptions, TableSelectSqlOptions, DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, @@ -63,6 +63,10 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String let row_limit = informix_row_limit_clause(limit, options.offset.unwrap_or(0)); format!("SELECT {row_limit} {select_columns} FROM {table_alias}{where_clause}{order}") } + TablePaginationStrategy::FirebirdRows => { + let rows = firebird_rows_clause(limit, options.offset.unwrap_or(0)); + format!("SELECT {select_columns} FROM {table_alias}{where_clause}{order} {rows}") + } TablePaginationStrategy::Db2FetchFirst if options.offset.is_some_and(|offset| offset > 0) => { build_db2_table_select_page_sql( &table_alias, @@ -156,6 +160,10 @@ pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String { TablePaginationStrategy::InformixFirst => { format!("SELECT FIRST {limit} {select_columns} FROM {table}{order_by}") } + TablePaginationStrategy::FirebirdRows => { + let rows = firebird_rows_clause(limit, 0); + format!("SELECT {select_columns} FROM {table}{order_by} {rows}") + } TablePaginationStrategy::Rownum => { build_rownum_table_select_sql(&table, "", &order_by, &select_columns, &select_columns, limit, 0) } diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index f8c640378a..bd9eb99f1e 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -62,6 +62,7 @@ fn maps_table_pagination_strategy_by_database_type() { assert_eq!(table_pagination_strategy(Some(DatabaseType::SqlServer)), TablePaginationStrategy::SqlServerTop); assert_eq!(table_pagination_strategy(Some(DatabaseType::Iris)), TablePaginationStrategy::IrisTop); assert_eq!(table_pagination_strategy(Some(DatabaseType::Informix)), TablePaginationStrategy::InformixFirst); + assert_eq!(table_pagination_strategy(Some(DatabaseType::Firebird)), TablePaginationStrategy::FirebirdRows); assert_eq!(table_pagination_strategy(Some(DatabaseType::OceanbaseOracle)), TablePaginationStrategy::Rownum); assert_eq!(table_pagination_strategy(Some(DatabaseType::Questdb)), TablePaginationStrategy::QuestDbLimit); assert_eq!(table_pagination_strategy(Some(DatabaseType::Oracle)), TablePaginationStrategy::Rownum); @@ -124,6 +125,17 @@ fn builds_select_sql_with_limit_syntax_for_database_type() { }), "SELECT \"id\", \"name\" FROM \"DB2INST1\".\"USERS\" ORDER BY \"id\" ASC FETCH FIRST 100 ROWS ONLY" ); + assert_eq!( + build_table_select_sql(TableSelectSqlOptions { + database_type: Some(DatabaseType::Firebird), + schema: None, + table_name: "USERS", + columns: &columns, + order_columns: &keys, + limit: 100, + }), + "SELECT \"id\", \"name\" FROM \"USERS\" ORDER BY \"id\" ASC ROWS 100" + ); assert_eq!( build_table_select_sql(TableSelectSqlOptions { database_type: Some(DatabaseType::Oracle), @@ -331,6 +343,23 @@ fn builds_table_data_where_and_schema_queries() { }), "SELECT * FROM \"DB2INST1\".\"ORDERS\" WHERE (amount > 10) FETCH FIRST 50 ROWS ONLY" ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Firebird), + schema: None, + table_name: "ORDERS".to_string(), + table_type: None, + primary_keys: vec!["ID".to_string()], + columns: vec!["ID".to_string(), "AMOUNT".to_string()], + fallback_order_columns: Vec::new(), + order_by: Some("\"ID\" ASC".to_string()), + limit: Some(50), + offset: Some(100), + where_input: Some("WHERE amount > 10".to_string()), + include_row_id: false, + }), + "SELECT * FROM \"ORDERS\" WHERE (amount > 10) ORDER BY \"ID\" ASC ROWS 101 TO 150" + ); assert_eq!( build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(DatabaseType::Oracle),