feat(core): split foundational capabilities into dedicated crates

This commit is contained in:
t8y2 committed 2026-09-18 13:00:56 +08:00
1 parent d50f5175d6
commit bc00431834
402 files changed
+4386 -3367

No files matched your search

@@ -0,0 +1,153 @@
use crate::models::connection::DatabaseType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TablePaginationStrategy {
LimitOffset,
FetchFirst,
Db2FetchFirst,
SqlServerTop,
IrisTop,
InformixFirst,
FirebirdRows,
Rownum,
QuestDbLimit,
Unbounded,
AgentMaxRows,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaginationContext {
TablePreview,
BoundedRead,
UserQuery,
}
pub fn is_schema_aware(database_type: DatabaseType) -> bool {
matches!(
database_type,
DatabaseType::Postgres
| DatabaseType::SqlServer
| DatabaseType::Oracle
| DatabaseType::Redshift
| DatabaseType::Dameng
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Uxdb
| DatabaseType::Vastbase
| DatabaseType::Yashandb
| DatabaseType::Oscar
| DatabaseType::Databricks
| DatabaseType::SapHana
| DatabaseType::Teradata
| DatabaseType::Vertica
| DatabaseType::Exasol
| DatabaseType::OpenGauss
| DatabaseType::OceanbaseOracle
| DatabaseType::Gbase
| DatabaseType::Databend
| DatabaseType::Jdbc
| DatabaseType::H2
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::PrestoSql
| DatabaseType::Hive
| DatabaseType::Kyuubi
| DatabaseType::Impala
| DatabaseType::Argo
| DatabaseType::Spark
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Tdengine
| DatabaseType::Xugu
| DatabaseType::Sqlite
| DatabaseType::DuckDb
| DatabaseType::Iris
// Spanner supports named schemas; the PostgreSQL dialect defaults to `public`.
// GoogleSQL's default schema is the empty string, which the blank-schema filters
// in `qualified_table_name` / `table_data_qualified_table_name` drop along with
// the dot separator (`` `s`.`t` `` with an empty `s` is a Spanner syntax error).
| DatabaseType::Spanner
)
}
pub fn uses_fetch_first(database_type: DatabaseType) -> bool {
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::Db2)
}
pub fn uses_oracle_row_id(database_type: Option<DatabaseType>) -> bool {
matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
}
/// Xugu exposes an unqualified ROWID pseudo-column for base, partitioned and
/// temporary tables. It is intentionally separate from Oracle's ROWIDTOCHAR
/// representation because qualified ROWID and ROWIDTOCHAR are not supported
/// by Xugu.
pub fn uses_xugu_row_id(database_type: Option<DatabaseType>) -> bool {
database_type == Some(DatabaseType::Xugu)
}
pub fn uses_synthetic_row_id(database_type: Option<DatabaseType>) -> bool {
uses_oracle_row_id(database_type) || uses_xugu_row_id(database_type)
}
/// Oracle 系方言不支持 `INSERT ... VALUES (...), (...)` 多行语法,
/// 复制为 INSERT 与导出 INSERT 都需按行生成单条语句。
pub fn uses_single_row_insert_statements(database_type: DatabaseType) -> bool {
matches!(database_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle)
}
pub fn pagination_strategy(database_type: Option<DatabaseType>, context: PaginationContext) -> TablePaginationStrategy {
match database_type {
Some(DatabaseType::Jdbc) => TablePaginationStrategy::AgentMaxRows,
Some(DatabaseType::Oracle) if matches!(context, PaginationContext::TablePreview) => {
TablePaginationStrategy::Rownum
}
// Oracle's row-limiting clause (`FETCH FIRST`/`OFFSET ... FETCH`) was
// introduced in 12c. ROWNUM remains compatible with the supported 11g
// baseline while still providing a bounded read for newer servers.
Some(DatabaseType::Oracle) if matches!(context, PaginationContext::BoundedRead) => {
TablePaginationStrategy::Rownum
}
Some(DatabaseType::Oracle) => TablePaginationStrategy::Unbounded,
Some(DatabaseType::Oscar)
if matches!(context, PaginationContext::TablePreview | PaginationContext::BoundedRead) =>
{
TablePaginationStrategy::Rownum
}
Some(DatabaseType::Oscar) => TablePaginationStrategy::Unbounded,
Some(DatabaseType::Dameng) => TablePaginationStrategy::Rownum,
Some(DatabaseType::Db2) => TablePaginationStrategy::Db2FetchFirst,
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,
}
}
pub fn table_pagination_strategy(database_type: Option<DatabaseType>) -> TablePaginationStrategy {
pagination_strategy(database_type, PaginationContext::TablePreview)
}
pub(super) fn is_simple_informix_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(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}")
}
}
@@ -0,0 +1,971 @@
//! Target-database DDL profile: how to *write* SQL for a concrete [`DatabaseType`].
//!
//! Profiles are data + small enums. Call sites must not branch on individual databases;
//! they only consult profile fields (quote style, auto-increment form, type map, …).
use crate::models::connection::DatabaseType;
/// Identifier quoting style for generated DDL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuoteStyle {
/// MySQL-family: `name`
Backtick,
/// PostgreSQL / Access / most ANSI: "name"
DoubleQuote,
/// SQL Server: [name]
Brackets,
/// Oracle-style unquoted uppercase
UnquotedUpper,
}
/// How auto-increment / identity is expressed on the target database.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoIncSyntax {
/// No special auto-increment DDL.
None,
/// Append a fixed suffix to the column definition (e.g. ` AUTO_INCREMENT`, ` IDENTITY(1,1)`).
Suffix(&'static str),
/// Replace the mapped type with this type name for auto PK columns (e.g. Access `COUNTER`).
ReplaceTypeWith(&'static str),
/// PostgreSQL-style sequence + DEFAULT nextval (handled by generator after CREATE).
PostgresSequence,
}
/// Static type rewrite rule: source base type (uppercase, no params) → target template.
/// Target may use `{}` for a single length/precision placeholder (first param only).
#[derive(Debug, Clone, Copy)]
pub struct TypeMapEntry {
pub source_base: &'static str,
pub target_template: &'static str,
}
/// How CREATE INDEX places the index method / type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexTypePlacement {
/// No index-type clause.
None,
/// PostgreSQL: `CREATE INDEX … ON t USING btree (…)`
UsingSuffix,
/// SQL Server: `CREATE CLUSTERED INDEX …`
TypePrefix,
/// MySQL: `CREATE INDEX … USING BTREE ON t (…)`
UsingBeforeOn,
}
/// CREATE TRIGGER body shape (templates use `{name}` `{timing}` `{event}` `{table}` `{body}`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerTemplate {
/// `CREATE TRIGGER {name} {timing} {event} ON {table} FOR EACH ROW BEGIN {body} END;`
MysqlStyle,
/// `CREATE TRIGGER {name} {timing} {event} ON {table} FOR EACH ROW EXECUTE FUNCTION {body};`
PostgresStyle,
/// `CREATE TRIGGER {name} ON {table} {timing} {event} AS BEGIN {body} END;`
SqlServerStyle,
/// Conservative MySQL-like default for unknown engines.
GenericRowBody,
}
/// RENAME COLUMN strategy inside ALTER TABLE.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenameColumnSyntax {
/// `CHANGE COLUMN old new_def` (MySQL)
MysqlChangeColumn,
/// `RENAME COLUMN old TO new`
RenameColumn,
/// `ALTER COLUMN old RENAME TO new` (H2)
AlterColumnRenameTo,
/// `EXEC sp_rename 't.old', 'new', 'COLUMN'`
SqlServerSpRename,
}
/// Column DDL that cannot use the generic `ADD COLUMN` / `ALTER COLUMN` forms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnDdlSyntax {
Generic,
/// SQL Server uses `ADD` without `COLUMN`, a full definition for
/// `ALTER COLUMN`, and separate default-constraint objects.
SqlServer,
}
/// Standalone table and column comment representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentDdlSyntax {
Generic,
/// SQL Server stores comments in the `MS_Description` extended property.
SqlServerExtendedProperty,
}
/// DDL generation profile for one [`DatabaseType`].
#[derive(Debug, Clone, Copy)]
pub struct DdlDialectProfile {
pub database_type: DatabaseType,
pub quote: QuoteStyle,
pub auto_inc: AutoIncSyntax,
/// When false, display widths like `INT(11)` are stripped if no type_map hit.
pub supports_display_width: bool,
/// Whether this target's VARCHAR/CHARACTER VARYING/NVARCHAR syntax rejects a
/// missing length (e.g. `VARCHAR` with no length is a MySQL syntax error).
/// Distinct from `supports_display_width`: that flag is about whether the
/// target *prints* MySQL-style `INT(11)` widths, not whether VARCHAR needs one.
pub requires_explicit_varchar_length: bool,
/// Cap for length params when applying `{}` templates (e.g. Access TEXT max 255).
pub max_varchar_len: Option<u32>,
/// MySQL-style inline `COMMENT '...'` on columns.
pub inline_column_comment: bool,
/// SQLite-style: emit FOREIGN KEY clauses inside CREATE TABLE.
pub foreign_keys_inline_in_create: bool,
/// `DROP INDEX name ON table` (MySQL) vs `DROP INDEX IF EXISTS name`.
pub drop_index_uses_on_table: bool,
/// `DROP FOREIGN KEY` (MySQL) vs `DROP CONSTRAINT`.
pub drop_fk_as_foreign_key: bool,
/// `DROP TABLE t CASCADE` is accepted. Oracle's `CASCADE CONSTRAINTS` is a
/// different clause and does not count.
pub drop_table_supports_cascade: bool,
/// `DROP TABLE IF EXISTS t` is valid grammar. False for Oracle (pre-23c),
/// Access, Firebird, Db2, Informix, SAP HANA and Teradata.
pub drop_table_supports_if_exists: bool,
/// Index method placement.
pub index_type_placement: IndexTypePlacement,
/// `INCLUDE (cols)` on indexes.
pub index_supports_include: bool,
/// Partial index `WHERE …`.
pub index_supports_filter: bool,
/// Index `COMMENT '…'` (MySQL).
pub index_supports_comment: bool,
/// Table comment: `ALTER TABLE t COMMENT = '…'` vs `COMMENT ON TABLE`.
pub table_comment_via_alter: bool,
/// Standalone column comment SQL is unsupported (MySQL needs MODIFY COLUMN).
pub column_comment_via_modify_only: bool,
pub comment_ddl: CommentDdlSyntax,
pub trigger_template: TriggerTemplate,
pub rename_column: RenameColumnSyntax,
pub column_ddl: ColumnDdlSyntax,
/// MySQL `MODIFY COLUMN` vs ANSI `ALTER COLUMN … TYPE/SET`.
pub alter_uses_modify_column: bool,
/// Batch multiple alter clauses in one `ALTER TABLE` statement.
pub alter_batches_clauses: bool,
/// Prefer emitting source SHOW CREATE / native DDL when dialects match (MySQL-family).
pub prefers_native_source_ddl: bool,
/// GRANT/REVOKE identifier style: backtick + user quotes vs ANSI double-quote.
pub grant_uses_mysql_user_syntax: bool,
/// Emit a comment that FK changes may need table rebuild (SQLite-family).
pub warn_fk_needs_table_rebuild: bool,
pub create_table_if_not_exists: bool,
pub create_index_if_not_exists: bool,
pub create_function_or_replace: bool,
pub supports_function_ddl: bool,
pub supports_sequence_ddl: bool,
pub supports_rule_ddl: bool,
pub supports_owner_ddl: bool,
/// `{create_kw} {name} {definition};` — `create_kw` is CREATE [OR REPLACE] FUNCTION.
pub function_create_template: Option<&'static str>,
/// `DROP FUNCTION IF EXISTS {name}{cascade};`
pub function_drop_template: Option<&'static str>,
/// `CREATE SEQUENCE {name} AS {data_type} START WITH {start_value} … {cycle};`
pub sequence_create_template: Option<&'static str>,
/// `ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} … {cycle};`
pub sequence_alter_template: Option<&'static str>,
/// `DROP SEQUENCE {name}{cascade};`
pub sequence_drop_template: Option<&'static str>,
/// `DROP RULE IF EXISTS {rule_name} ON {table_name};`
pub rule_drop_template: Option<&'static str>,
/// `ALTER {object_type} {name} OWNER TO {owner};`
pub owner_alter_template: Option<&'static str>,
/// `DROP TABLE {table}{cascade};` — the caller quotes `{table}` itself. Engines
/// that drop a differently-named object override the whole shape.
pub drop_table_template: &'static str,
/// Optional session lock-timeout preamble for generated scripts.
pub lock_timeout_sql: Option<&'static str>,
/// Data-driven base-type rewrites for this target (empty → rely on matrix / normalize only).
pub type_map: &'static [TypeMapEntry],
}
impl DdlDialectProfile {
pub fn lookup_type(&self, source_base_upper: &str) -> Option<&'static str> {
self.type_map.iter().find(|e| e.source_base.eq_ignore_ascii_case(source_base_upper)).map(|e| e.target_template)
}
pub fn quote_ident(&self, name: &str) -> String {
match self.quote {
QuoteStyle::Backtick => format!("`{}`", name.replace('`', "``")),
QuoteStyle::DoubleQuote => format!("\"{}\"", name.replace('"', "\"\"")),
QuoteStyle::Brackets => format!("[{}]", name.replace(']', "]]")),
// Oracle: unquoted identifiers fold to uppercase. Mixed-case names and
// special characters must be double-quoted with original spelling preserved.
QuoteStyle::UnquotedUpper => {
let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
let has_special = name.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '$' && c != '#');
let bad_start =
name.is_empty() || !name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
// All-lower "emp" stays unquoted → EMP (Oracle fold). Mixed "empMixed" → "empMixed".
let needs_quotes = (has_lower && has_upper) || has_special || bad_start;
if needs_quotes {
format!("\"{}\"", name.replace('"', "\"\""))
} else {
name.to_uppercase()
}
}
}
}
pub fn alter_modify_keyword(&self) -> &'static str {
if self.database_type == DatabaseType::Dameng {
"MODIFY"
} else {
"MODIFY COLUMN"
}
}
/// Replace `{key}` placeholders. Unknown keys are left unchanged.
pub fn render_template(template: &str, vars: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in vars {
out = out.replace(&format!("{{{key}}}"), value);
}
out
}
}
const FN_CREATE: &str = "{create_kw} {name} {definition};";
const FN_DROP: &str = "DROP FUNCTION IF EXISTS {name}{cascade};";
const SEQ_CREATE: &str =
"CREATE SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
const SEQ_ALTER: &str =
"ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
const SEQ_DROP: &str = "DROP SEQUENCE {name}{cascade};";
const SQLSERVER_FN_DROP: &str = "DROP FUNCTION {name};";
const SQLSERVER_SEQ_ALTER: &str =
"ALTER SEQUENCE {name} RESTART WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
const RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};";
const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};";
/// Shared ANSI `DROP TABLE` shape. `{if_exists}` is deliberately absent: whether a
/// dialect accepts `IF EXISTS` is a capability flag, not part of the rendered shape.
const DROP_TABLE: &str = "DROP TABLE {table}{cascade};";
// ---------------------------------------------------------------------------
// Type maps (data only — no per-database logic at call sites)
// ---------------------------------------------------------------------------
const ACCESS_TYPE_MAP: &[TypeMapEntry] = &[
TypeMapEntry { source_base: "BOOL", target_template: "YESNO" },
TypeMapEntry { source_base: "BOOLEAN", target_template: "YESNO" },
TypeMapEntry { source_base: "BIT", target_template: "YESNO" },
TypeMapEntry { source_base: "YESNO", target_template: "YESNO" },
TypeMapEntry { source_base: "TINYINT", target_template: "BYTE" },
TypeMapEntry { source_base: "BYTE", target_template: "BYTE" },
TypeMapEntry { source_base: "SMALLINT", target_template: "SMALLINT" },
TypeMapEntry { source_base: "SHORT", target_template: "SMALLINT" },
TypeMapEntry { source_base: "MEDIUMINT", target_template: "INTEGER" },
TypeMapEntry { source_base: "INT", target_template: "INTEGER" },
TypeMapEntry { source_base: "INTEGER", target_template: "INTEGER" },
TypeMapEntry { source_base: "INT4", target_template: "INTEGER" },
TypeMapEntry { source_base: "LONG", target_template: "INTEGER" },
TypeMapEntry { source_base: "BIGINT", target_template: "DECIMAL(20,0)" },
TypeMapEntry { source_base: "INT8", target_template: "DECIMAL(20,0)" },
TypeMapEntry { source_base: "FLOAT", target_template: "SINGLE" },
TypeMapEntry { source_base: "REAL", target_template: "SINGLE" },
TypeMapEntry { source_base: "SINGLE", target_template: "SINGLE" },
TypeMapEntry { source_base: "DOUBLE", target_template: "DOUBLE" },
TypeMapEntry { source_base: "DOUBLE PRECISION", target_template: "DOUBLE" },
TypeMapEntry { source_base: "DECIMAL", target_template: "DECIMAL({})" },
TypeMapEntry { source_base: "NUMERIC", target_template: "DECIMAL({})" },
TypeMapEntry { source_base: "NUMBER", target_template: "DECIMAL({})" },
TypeMapEntry { source_base: "CURRENCY", target_template: "CURRENCY" },
TypeMapEntry { source_base: "VARCHAR", target_template: "TEXT({})" },
TypeMapEntry { source_base: "CHARACTER VARYING", target_template: "TEXT({})" },
TypeMapEntry { source_base: "CHAR", target_template: "TEXT({})" },
TypeMapEntry { source_base: "CHARACTER", target_template: "TEXT({})" },
TypeMapEntry { source_base: "NVARCHAR", target_template: "TEXT({})" },
TypeMapEntry { source_base: "NVARCHAR2", target_template: "TEXT({})" },
TypeMapEntry { source_base: "VARCHAR2", target_template: "TEXT({})" },
TypeMapEntry { source_base: "TEXT", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "TINYTEXT", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "MEDIUMTEXT", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "LONGTEXT", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "CLOB", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "MEMO", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "DATE", target_template: "DATETIME" },
TypeMapEntry { source_base: "DATETIME", target_template: "DATETIME" },
TypeMapEntry { source_base: "TIMESTAMP", target_template: "DATETIME" },
TypeMapEntry { source_base: "TIME", target_template: "DATETIME" },
TypeMapEntry { source_base: "YEAR", target_template: "SMALLINT" },
TypeMapEntry { source_base: "BLOB", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "TINYBLOB", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "MEDIUMBLOB", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "LONGBLOB", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "BINARY", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "VARBINARY", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "IMAGE", target_template: "OLEOBJECT" },
TypeMapEntry { source_base: "JSON", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "JSONB", target_template: "LONGTEXT" },
TypeMapEntry { source_base: "UUID", target_template: "GUID" },
TypeMapEntry { source_base: "UNIQUEIDENTIFIER", target_template: "GUID" },
TypeMapEntry { source_base: "GUID", target_template: "GUID" },
];
const SQLITE_TYPE_MAP: &[TypeMapEntry] = &[
TypeMapEntry { source_base: "TINYINT", target_template: "INTEGER" },
TypeMapEntry { source_base: "SMALLINT", target_template: "INTEGER" },
TypeMapEntry { source_base: "MEDIUMINT", target_template: "INTEGER" },
TypeMapEntry { source_base: "INT", target_template: "INTEGER" },
TypeMapEntry { source_base: "INTEGER", target_template: "INTEGER" },
TypeMapEntry { source_base: "BIGINT", target_template: "INTEGER" },
TypeMapEntry { source_base: "FLOAT", target_template: "REAL" },
TypeMapEntry { source_base: "DOUBLE", target_template: "REAL" },
TypeMapEntry { source_base: "DOUBLE PRECISION", target_template: "REAL" },
TypeMapEntry { source_base: "DECIMAL", target_template: "NUMERIC" },
TypeMapEntry { source_base: "NUMERIC", target_template: "NUMERIC" },
TypeMapEntry { source_base: "VARCHAR", target_template: "TEXT" },
TypeMapEntry { source_base: "CHAR", target_template: "TEXT" },
TypeMapEntry { source_base: "TEXT", target_template: "TEXT" },
TypeMapEntry { source_base: "TINYTEXT", target_template: "TEXT" },
TypeMapEntry { source_base: "MEDIUMTEXT", target_template: "TEXT" },
TypeMapEntry { source_base: "LONGTEXT", target_template: "TEXT" },
TypeMapEntry { source_base: "DATETIME", target_template: "TEXT" },
TypeMapEntry { source_base: "TIMESTAMP", target_template: "TEXT" },
TypeMapEntry { source_base: "DATE", target_template: "TEXT" },
TypeMapEntry { source_base: "BLOB", target_template: "BLOB" },
TypeMapEntry { source_base: "JSON", target_template: "TEXT" },
];
// ---------------------------------------------------------------------------
// Profile families (shared shapes; registration is the only DatabaseType match)
// ---------------------------------------------------------------------------
fn mysql_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::Backtick,
auto_inc: AutoIncSyntax::Suffix(" AUTO_INCREMENT"),
supports_display_width: true,
requires_explicit_varchar_length: true,
max_varchar_len: Some(65_535),
inline_column_comment: true,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: true,
drop_fk_as_foreign_key: true,
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::UsingBeforeOn,
index_supports_include: false,
index_supports_filter: false,
index_supports_comment: true,
table_comment_via_alter: true,
column_comment_via_modify_only: true,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::MysqlStyle,
rename_column: RenameColumnSyntax::MysqlChangeColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: true,
grant_uses_mysql_user_syntax: true,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: true,
alter_batches_clauses: true,
create_table_if_not_exists: true,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: false,
supports_sequence_ddl: false,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: None,
function_drop_template: None,
sequence_create_template: None,
sequence_alter_template: None,
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET SESSION lock_wait_timeout = 3;"),
type_map: &[],
}
}
fn postgres_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::PostgresSequence,
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: None,
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: true,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::UsingSuffix,
index_supports_include: true,
index_supports_filter: true,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::PostgresStyle,
rename_column: RenameColumnSyntax::RenameColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: true,
create_function_or_replace: true,
supports_function_ddl: true,
supports_sequence_ddl: true,
supports_rule_ddl: true,
supports_owner_ddl: true,
function_create_template: Some(FN_CREATE),
function_drop_template: Some(FN_DROP),
sequence_create_template: Some(SEQ_CREATE),
sequence_alter_template: Some(SEQ_ALTER),
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: Some(RULE_DROP),
owner_alter_template: Some(OWNER_ALTER),
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET lock_timeout = '3s';"),
type_map: &[],
}
}
fn oracle_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::UnquotedUpper,
auto_inc: AutoIncSyntax::Suffix(" GENERATED BY DEFAULT AS IDENTITY"),
supports_display_width: false,
// Oracle's VARCHAR2 also rejects a missing length in real DDL, but this
// is left false (unverified, no Oracle instance in scope for this
// change) rather than asserting untested cross-dialect behavior.
requires_explicit_varchar_length: false,
max_varchar_len: Some(4000),
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// Oracle spells it `CASCADE CONSTRAINTS`, and `IF EXISTS` only landed in 23c.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: false,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: true,
supports_sequence_ddl: true,
supports_rule_ddl: false,
supports_owner_ddl: false,
// Same ANSI shapes as Postgres for the subset currently generated by schema_diff.
function_create_template: Some(FN_CREATE),
function_drop_template: Some(FN_DROP),
sequence_create_template: Some(SEQ_CREATE),
sequence_alter_template: Some(SEQ_ALTER),
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("ALTER SESSION SET DDL_LOCK_TIMEOUT = 3;"),
type_map: &[],
}
}
fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::Brackets,
auto_inc: AutoIncSyntax::Suffix(" IDENTITY(1,1)"),
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: None,
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
// `DROP TABLE IF EXISTS` is valid on SQL Server 2016+.
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::TypePrefix,
index_supports_include: true,
index_supports_filter: true,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::SqlServerExtendedProperty,
trigger_template: TriggerTemplate::SqlServerStyle,
rename_column: RenameColumnSyntax::SqlServerSpRename,
column_ddl: ColumnDdlSyntax::SqlServer,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: true,
supports_sequence_ddl: true,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: Some(FN_CREATE),
function_drop_template: Some(SQLSERVER_FN_DROP),
sequence_create_template: Some(SEQ_CREATE),
sequence_alter_template: Some(SQLSERVER_SEQ_ALTER),
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET LOCK_TIMEOUT 3000;"),
type_map: &[],
}
}
fn sqlite_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::Suffix(" AUTOINCREMENT"),
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: None,
inline_column_comment: false,
foreign_keys_inline_in_create: true,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: true,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: true,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: true,
create_index_if_not_exists: true,
create_function_or_replace: false,
supports_function_ddl: false,
supports_sequence_ddl: false,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: None,
function_drop_template: None,
sequence_create_template: None,
sequence_alter_template: None,
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: SQLITE_TYPE_MAP,
}
}
fn access_profile() -> DdlDialectProfile {
DdlDialectProfile {
database_type: DatabaseType::Access,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::ReplaceTypeWith("COUNTER"),
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: Some(255),
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// Jet SQL has neither CASCADE nor IF EXISTS on DROP TABLE.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: false,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: false,
supports_sequence_ddl: false,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: None,
function_drop_template: None,
sequence_create_template: None,
sequence_alter_template: None,
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: ACCESS_TYPE_MAP,
}
}
fn h2_profile() -> DdlDialectProfile {
DdlDialectProfile {
database_type: DatabaseType::H2,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::None,
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: None,
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// H2 accepts CASCADE, but it stays off to keep the cascade set aligned with
// the frontend preview list in `dbAdminSql.ts`.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::AlterColumnRenameTo,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: false,
supports_sequence_ddl: false,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: None,
function_drop_template: None,
sequence_create_template: None,
sequence_alter_template: None,
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: &[],
}
}
fn conservative_ansi(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::None,
supports_display_width: false,
// This fallback also covers DB2 and Informix, which reject a bare
// VARCHAR with no length — but it's shared with dialects that don't
// (Snowflake, BigQuery, DuckDB, Trino, ...), so it can't be flipped
// true here without a dedicated DB2/Informix profile and DB
// verification. Tracked as a known gap, not fixed by this flag.
requires_explicit_varchar_length: false,
max_varchar_len: None,
inline_column_comment: false,
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
// Most engines in this bucket accept `IF EXISTS`; `profile_for` opts out the
// ones that do not.
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
index_supports_comment: false,
table_comment_via_alter: false,
column_comment_via_modify_only: false,
comment_ddl: CommentDdlSyntax::Generic,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
column_ddl: ColumnDdlSyntax::Generic,
prefers_native_source_ddl: false,
grant_uses_mysql_user_syntax: false,
warn_fk_needs_table_rebuild: false,
alter_uses_modify_column: false,
alter_batches_clauses: false,
create_table_if_not_exists: false,
create_index_if_not_exists: false,
create_function_or_replace: false,
supports_function_ddl: false,
supports_sequence_ddl: false,
supports_rule_ddl: false,
supports_owner_ddl: false,
function_create_template: None,
function_drop_template: None,
sequence_create_template: None,
sequence_alter_template: None,
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: &[],
}
}
fn clickhouse_profile() -> DdlDialectProfile {
let mut p = conservative_ansi(DatabaseType::ClickHouse);
// ClickHouse supports CREATE TABLE IF NOT EXISTS like MySQL/SQLite.
p.create_table_if_not_exists = true;
p.lock_timeout_sql = None;
p
}
fn influxdb_profile() -> DdlDialectProfile {
let mut p = conservative_ansi(DatabaseType::InfluxDb);
// InfluxDB drops a measurement, not a table, and has no IF EXISTS form.
p.drop_table_template = "DROP MEASUREMENT {table};";
p.drop_table_supports_if_exists = false;
p
}
/// Resolve DDL profile for a concrete target database type.
///
/// This is the **only** place that maps [`DatabaseType`] → profile data.
/// Generators must not re-match on individual databases afterward.
pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
use DatabaseType::*;
match db_type {
// MySQL family
Mysql | Doris | StarRocks | Goldendb | Sundb | Databend | Gbase | ManticoreSearch => mysql_family(db_type),
Dameng => {
let mut profile = oracle_family(db_type);
profile.alter_uses_modify_column = true;
profile
}
// PostgreSQL family
Postgres | Redshift | Gaussdb | Kingbase | Highgo | Vastbase | OpenGauss | Kwdb | Uxdb => {
postgres_family(db_type)
}
// PostgreSQL-shaped DDL, but not the PostgreSQL `DROP TABLE` grammar:
// Firebird has neither CASCADE nor IF EXISTS; Vertica and Exasol are kept off
// CASCADE so the set stays aligned with the frontend preview list.
Firebird | Vertica | Exasol => {
let mut profile = postgres_family(db_type);
profile.drop_table_supports_cascade = false;
profile.drop_table_supports_if_exists = db_type != Firebird;
profile
}
// Oracle family
Oracle | OceanbaseOracle | Yashandb | Xugu | Iris => oracle_family(db_type),
// SQL Server (not Access)
SqlServer => sqlserver_family(db_type),
// Access: own profile (must not inherit SqlServer IDENTITY)
Access => access_profile(),
// SQLite family
Sqlite | Rqlite | Turso | CloudflareD1 => sqlite_family(db_type),
H2 => h2_profile(),
ClickHouse => clickhouse_profile(),
// Engines whose DROP TABLE grammar has no `IF EXISTS`.
Db2 | Informix | SapHana | Teradata => {
let mut profile = conservative_ansi(db_type);
profile.drop_table_supports_if_exists = false;
profile
}
DuckDb | Questdb | Snowflake | Trino | PrestoSql | Hive | Kyuubi | Impala | Argo | Spark | Bigquery
| Spanner | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb | Databricks | Jdbc => {
conservative_ansi(db_type)
}
InfluxDb => influxdb_profile(),
Plugin => conservative_ansi(db_type),
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_profile_is_not_sqlserver_identity() {
let access = profile_for(DatabaseType::Access);
let mssql = profile_for(DatabaseType::SqlServer);
assert!(matches!(access.auto_inc, AutoIncSyntax::ReplaceTypeWith("COUNTER")));
assert!(matches!(mssql.auto_inc, AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")));
assert!(access.lookup_type("VARCHAR").is_some());
assert!(mssql.type_map.is_empty());
// Access must not inherit SQL Server lock-timeout behavior. Neither
// database supports CREATE INDEX IF NOT EXISTS syntax.
assert_eq!(access.lock_timeout_sql, None);
assert!(mssql.lock_timeout_sql.is_some());
assert!(!access.create_index_if_not_exists);
assert!(!mssql.create_index_if_not_exists);
}
#[test]
fn oracle_profile_uses_valid_identity_and_ddl_lock_timeout() {
let oracle = profile_for(DatabaseType::Oracle);
assert!(matches!(
oracle.auto_inc,
AutoIncSyntax::Suffix(s) if s.contains("GENERATED BY DEFAULT AS IDENTITY")
));
assert_eq!(oracle.lock_timeout_sql, Some("ALTER SESSION SET DDL_LOCK_TIMEOUT = 3;"));
}
#[test]
fn quote_styles() {
assert_eq!(profile_for(DatabaseType::Mysql).quote_ident("a`b"), "`a``b`");
assert_eq!(profile_for(DatabaseType::Access).quote_ident(r#"a"b"#), "\"a\"\"b\"");
assert_eq!(profile_for(DatabaseType::SqlServer).quote_ident("a]b"), "[a]]b]");
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("emp"), "EMP");
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("EMP"), "EMP");
// Mixed-case must preserve spelling via quotes (not EMPMIXED).
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("empMixed"), "\"empMixed\"");
assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("Emp"), "\"Emp\"");
}
#[test]
fn postgres_object_templates_are_populated() {
let p = profile_for(DatabaseType::Postgres);
assert!(p.function_create_template.is_some());
assert!(p.function_drop_template.is_some());
assert!(p.sequence_create_template.is_some());
assert!(p.sequence_alter_template.is_some());
assert!(p.sequence_drop_template.is_some());
assert!(p.rule_drop_template.is_some());
assert!(p.owner_alter_template.is_some());
}
#[test]
fn mysql_object_templates_are_absent() {
let p = profile_for(DatabaseType::Mysql);
assert!(p.function_create_template.is_none());
assert!(p.sequence_create_template.is_none());
assert!(p.rule_drop_template.is_none());
assert!(p.owner_alter_template.is_none());
}
#[test]
fn render_template_replaces_placeholders() {
let rendered = DdlDialectProfile::render_template(
"DROP FUNCTION IF EXISTS {name}{cascade};",
&[("name", "\"public\".\"f\""), ("cascade", " CASCADE")],
);
assert_eq!(rendered, "DROP FUNCTION IF EXISTS \"public\".\"f\" CASCADE;");
}
#[test]
fn drop_table_if_exists_support_matches_dialect_grammar() {
for db in [
DatabaseType::Mysql,
DatabaseType::Postgres,
DatabaseType::SqlServer,
DatabaseType::Sqlite,
DatabaseType::DuckDb,
DatabaseType::ClickHouse,
DatabaseType::H2,
DatabaseType::Snowflake,
] {
assert!(profile_for(db).drop_table_supports_if_exists, "{db:?} does support DROP TABLE IF EXISTS");
}
// Oracle only gained `IF EXISTS` in 23c; the rest have no such grammar at all.
for db in [
DatabaseType::Oracle,
DatabaseType::Dameng,
DatabaseType::OceanbaseOracle,
DatabaseType::Access,
DatabaseType::Firebird,
DatabaseType::Db2,
DatabaseType::Informix,
DatabaseType::SapHana,
DatabaseType::Teradata,
DatabaseType::InfluxDb,
] {
assert!(!profile_for(db).drop_table_supports_if_exists, "{db:?} does not support DROP TABLE IF EXISTS");
}
}
#[test]
fn drop_table_cascade_support_is_limited_to_the_postgres_grammar() {
for db in [
DatabaseType::Postgres,
DatabaseType::Redshift,
DatabaseType::Gaussdb,
DatabaseType::Kwdb,
DatabaseType::Kingbase,
DatabaseType::Highgo,
DatabaseType::Uxdb,
DatabaseType::Vastbase,
DatabaseType::OpenGauss,
] {
assert!(profile_for(db).drop_table_supports_cascade, "{db:?} should keep DROP TABLE … CASCADE");
}
// Oracle's `CASCADE CONSTRAINTS` is a different clause; Firebird, Vertica and Exasol
// share the PostgreSQL profile but stay out of the cascade set so the backend matches
// the frontend preview list in `dbAdminSql.ts`.
for db in [
DatabaseType::Oracle,
DatabaseType::Firebird,
DatabaseType::Vertica,
DatabaseType::Exasol,
DatabaseType::Mysql,
DatabaseType::Sqlite,
DatabaseType::DuckDb,
DatabaseType::SqlServer,
DatabaseType::H2,
] {
assert!(!profile_for(db).drop_table_supports_cascade, "{db:?} must not emit DROP TABLE … CASCADE");
}
}
#[test]
fn drop_table_template_carries_the_whole_statement_shape() {
assert_eq!(profile_for(DatabaseType::Postgres).drop_table_template, "DROP TABLE {table}{cascade};");
assert_eq!(profile_for(DatabaseType::InfluxDb).drop_table_template, "DROP MEASUREMENT {table};");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
use crate::sql_dialect::descriptor::{DialectCapabilityDescriptor, DialectInfo, DialectKind, TypeMappingMatrix};
use insta::assert_json_snapshot;
#[test]
fn snapshot_mysql_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
assert_json_snapshot!("mysql_descriptor", desc);
}
#[test]
fn snapshot_postgres_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Postgres);
assert_json_snapshot!("postgres_descriptor", desc);
}
#[test]
fn snapshot_sqlite_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Sqlite);
assert_json_snapshot!("sqlite_descriptor", desc);
}
#[test]
fn snapshot_sqlserver_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::SqlServer);
assert_json_snapshot!("sqlserver_descriptor", desc);
}
#[test]
fn snapshot_oracle_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Oracle);
assert_json_snapshot!("oracle_descriptor", desc);
}
#[test]
fn snapshot_duckdb_descriptor() {
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::DuckDb);
assert_json_snapshot!("duckdb_descriptor", desc);
}
#[test]
fn snapshot_mysql_info() {
let info = DialectInfo::for_kind(DialectKind::Mysql);
assert_json_snapshot!("mysql_info", info);
}
#[test]
fn snapshot_postgres_info() {
let info = DialectInfo::for_kind(DialectKind::Postgres);
assert_json_snapshot!("postgres_info", info);
}
#[test]
fn snapshot_mysql_to_postgres_type_mapping() {
let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres);
assert_json_snapshot!("mysql_to_postgres_mapping", &matrix.rules);
}
#[test]
fn snapshot_postgres_to_mysql_type_mapping() {
let matrix = TypeMappingMatrix::for_dialects(DialectKind::Postgres, DialectKind::Mysql);
assert_json_snapshot!("postgres_to_mysql_mapping", &matrix.rules);
}
#[test]
fn snapshot_all_dialects_info() {
let all = DialectInfo::all();
assert_json_snapshot!("all_dialects_info", all);
}
@@ -0,0 +1,434 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use super::descriptor::{DialectCapabilityDescriptor, DialectKind};
use super::dialect_yaml::DialectYaml;
/// Singleton dialect registry that holds all loaded dialect descriptors.
/// Thread-safe via RwLock. Access via `DialectRegistry::global()`.
pub struct DialectRegistry {
descriptors: RwLock<HashMap<String, LoadedDialect>>,
plugin_dirs: RwLock<Vec<PathBuf>>,
}
#[derive(Debug, Clone)]
pub struct LoadedDialect {
pub dialect_name: String,
pub kind: DialectKind,
pub descriptor: DialectCapabilityDescriptor,
pub yaml: DialectYaml,
pub source_path: Option<PathBuf>,
pub loaded_at: String,
}
#[derive(Debug, Clone)]
pub struct LoadError {
pub path: PathBuf,
pub error: String,
}
impl DialectRegistry {
pub fn new() -> Self {
Self { descriptors: RwLock::new(HashMap::new()), plugin_dirs: RwLock::new(Vec::new()) }
}
pub fn global() -> &'static Self {
use std::sync::OnceLock;
static INSTANCE: OnceLock<DialectRegistry> = OnceLock::new();
INSTANCE.get_or_init(DialectRegistry::new)
}
pub fn add_plugin_dir(&self, dir: PathBuf) {
if let Ok(mut dirs) = self.plugin_dirs.write() {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
}
pub fn get(&self, name: &str) -> Option<LoadedDialect> {
self.descriptors.read().ok().and_then(|d| d.get(&name.to_ascii_lowercase()).cloned())
}
pub fn get_by_kind(&self, kind: DialectKind) -> Option<LoadedDialect> {
self.descriptors.read().ok().and_then(|d| d.values().find(|ld| ld.kind == kind).cloned())
}
/// Return all loaded dialects that map to the given kind.
/// Unlike `get_by_kind` (which returns only the first match), this is
/// order-independent and is used when a type's capabilities must be
/// resolved across an entire dialect family (e.g. PostgreSQL, HighGo,
/// KingBase all share `DialectKind::Postgres`).
pub fn get_all_by_kind(&self, kind: DialectKind) -> Vec<LoadedDialect> {
self.descriptors
.read()
.ok()
.map(|d| d.values().filter(|ld| ld.kind == kind).cloned().collect())
.unwrap_or_default()
}
pub fn get_descriptor(&self, name: &str) -> Option<DialectCapabilityDescriptor> {
self.get(name).map(|ld| ld.descriptor)
}
pub fn get_descriptor_for_db(
&self,
db_type: crate::models::connection::DatabaseType,
) -> Option<DialectCapabilityDescriptor> {
if let Some(dialect_name) = crate::database_manifest::dialect_name(&db_type) {
if let Some(loaded) = self.get(dialect_name) {
return Some(loaded.descriptor);
}
}
let kind = DialectKind::from_database_type(db_type);
let label = kind.label();
let db_name = db_type.as_str();
self.get(db_name).or_else(|| self.get(label)).map(|ld| ld.descriptor)
}
pub fn get_yaml(&self, name: &str) -> Option<DialectYaml> {
self.get(name).map(|ld| ld.yaml)
}
pub fn has(&self, name: &str) -> bool {
self.descriptors.read().ok().is_some_and(|d| d.contains_key(&name.to_ascii_lowercase()))
}
pub fn all_names(&self) -> Vec<String> {
self.descriptors.read().ok().map(|d| d.keys().cloned().collect()).unwrap_or_default()
}
pub fn all_descriptors(&self) -> Vec<DialectCapabilityDescriptor> {
self.descriptors.read().ok().map(|d| d.values().map(|ld| ld.descriptor).collect()).unwrap_or_default()
}
pub fn register(
&self,
name: &str,
kind: DialectKind,
descriptor: DialectCapabilityDescriptor,
yaml: DialectYaml,
source_path: Option<PathBuf>,
) {
if let Ok(mut d) = self.descriptors.write() {
d.insert(
name.to_ascii_lowercase(),
LoadedDialect {
dialect_name: name.to_string(),
kind,
descriptor,
yaml,
source_path,
loaded_at: chrono::Utc::now().to_rfc3339(),
},
);
}
}
pub fn register_yaml(&self, path: &Path, yaml: DialectYaml, descriptor: DialectCapabilityDescriptor) {
let kind = descriptor.dialect;
let name = yaml.dialect.name.clone();
self.register(&name, kind, descriptor, yaml, Some(path.to_path_buf()));
}
pub fn register_descriptor(&self, name: &str, descriptor: DialectCapabilityDescriptor, yaml: DialectYaml) {
let kind = descriptor.dialect;
self.register(name, kind, descriptor, yaml, None);
}
pub fn unregister(&self, name: &str) -> bool {
self.descriptors.write().ok().is_some_and(|mut d| d.remove(&name.to_ascii_lowercase()).is_some())
}
pub fn is_empty(&self) -> bool {
self.descriptors.read().ok().is_none_or(|d| d.is_empty())
}
pub fn len(&self) -> usize {
self.descriptors.read().ok().map_or(0, |d| d.len())
}
}
impl Default for DialectRegistry {
fn default() -> Self {
Self::new()
}
}
/// Register all core dialect YAML definitions embedded at compile time.
/// Called once at startup by `lazy_init()`.
pub fn register_core_dialects() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let registry = DialectRegistry::global();
include!(concat!(env!("OUT_DIR"), "/core_dialects.rs"));
let n = registry.len();
log::info!("Registered {n} core dialects");
});
}
// ============================================================================
// DialectPluginLoader — scans plugin directories and loads YAML files
// ============================================================================
pub struct DialectPluginLoader;
#[derive(Debug)]
pub struct LoadResult {
pub loaded: Vec<DialectKind>,
pub errors: Vec<LoadError>,
pub skipped: Vec<PathBuf>,
}
impl DialectPluginLoader {
pub fn scan_and_load(registry: &DialectRegistry, plugin_dirs: &[PathBuf]) -> LoadResult {
let mut result = LoadResult { loaded: Vec::new(), errors: Vec::new(), skipped: Vec::new() };
for dir in plugin_dirs {
if !dir.exists() {
continue;
}
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
result.errors.push(LoadError { path: dir.clone(), error: format!("Cannot read directory: {e}") });
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if !matches!(extension, "yaml" | "yml") {
continue;
}
match Self::load_file(&path) {
Ok((kind, yaml, descriptor)) => {
registry.register(file_name, kind, descriptor, yaml, Some(path.clone()));
result.loaded.push(kind);
}
Err(e) => {
result.errors.push(LoadError { path: path.clone(), error: e });
}
}
}
}
result
}
pub fn load_file(path: &Path) -> Result<(DialectKind, DialectYaml, DialectCapabilityDescriptor), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Cannot read file: {e}"))?;
Self::load_from_string(&content, Some(path))
}
pub fn load_from_string(
yaml_str: &str,
source_path: Option<&Path>,
) -> Result<(DialectKind, DialectYaml, DialectCapabilityDescriptor), String> {
let parsed: DialectYaml = serde_yaml::from_str(yaml_str).map_err(|e| format!("YAML parse error: {e}"))?;
let kind = parsed.dialect_kind().ok_or_else(|| {
format!(
"Unknown dialect name '{}' in {}",
parsed.dialect.name,
source_path.map_or_else(|| "<string>".to_string(), |p| p.display().to_string())
)
})?;
let validation_errors = parsed.validate();
if !validation_errors.is_empty() {
let msgs: Vec<String> = validation_errors.iter().map(|e| e.to_string()).collect();
return Err(format!("Validation errors: {}", msgs.join("; ")));
}
let descriptor = parsed.to_descriptor(kind);
Ok((kind, parsed, descriptor))
}
pub fn load_directory(registry: &DialectRegistry, dir: &Path) -> LoadResult {
Self::scan_and_load(registry, &[dir.to_path_buf()])
}
}
// ============================================================================
// Fallback: use hardcoded descriptors when YAML is unavailable
// ============================================================================
pub fn resolve_descriptor(kind: DialectKind, registry: &DialectRegistry) -> DialectCapabilityDescriptor {
if let Some(desc) = registry.get_descriptor(kind.label()) {
return desc;
}
DialectCapabilityDescriptor::for_dialect(kind)
}
pub fn resolve_descriptor_for_db(
db_type: crate::models::connection::DatabaseType,
registry: &DialectRegistry,
) -> DialectCapabilityDescriptor {
if let Some(desc) = registry.get_descriptor_for_db(db_type) {
return desc;
}
let kind = DialectKind::from_database_type(db_type);
DialectCapabilityDescriptor::for_dialect(kind)
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn temp_dialect_dir() -> PathBuf {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dbx-dialect-test-{}-{stamp}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_temp_yaml(dir: &Path, name: &str, dialect_name: &str) -> PathBuf {
let file_path = dir.join(name);
let yaml_content = format!(
r#"
dialect:
name: "{dialect_name}"
versions:
- version: "1.0"
identifier_rules:
quote_char: "\""
max_length: 64
"#
);
let mut file = fs::File::create(&file_path).unwrap();
file.write_all(yaml_content.as_bytes()).unwrap();
file_path
}
fn cleanup_temp_dir(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn load_valid_yaml_file() {
let dir = temp_dialect_dir();
let path = write_temp_yaml(&dir, "dialect_postgresql.yaml", "PostgreSQL");
let result = DialectPluginLoader::load_file(&path);
cleanup_temp_dir(&dir);
assert!(result.is_ok());
let (kind, _yaml, desc) = result.unwrap();
assert_eq!(kind, DialectKind::Postgres);
assert_eq!(desc.dialect, DialectKind::Postgres);
}
#[test]
fn load_valid_yaml_file_mysql() {
let dir = temp_dialect_dir();
let path = write_temp_yaml(&dir, "dialect_mysql.yaml", "MySQL");
let result = DialectPluginLoader::load_file(&path);
cleanup_temp_dir(&dir);
assert!(result.is_ok());
let (kind, _, _) = result.unwrap();
assert_eq!(kind, DialectKind::Mysql);
}
#[test]
fn load_invalid_dialect_name() {
let dir = temp_dialect_dir();
let path = write_temp_yaml(&dir, "dialect_unknown.yaml", "UnknownDB");
let result = DialectPluginLoader::load_file(&path);
cleanup_temp_dir(&dir);
assert!(result.is_err());
}
#[test]
fn load_invalid_yaml_syntax() {
let dir = temp_dialect_dir();
let path = dir.join("dialect_bad.yaml");
let mut file = fs::File::create(&path).unwrap();
file.write_all(b"dialect: [invalid: yaml: syntax:").unwrap();
let result = DialectPluginLoader::load_file(&path);
cleanup_temp_dir(&dir);
assert!(result.is_err());
}
#[test]
fn registry_register_and_get() {
let registry = DialectRegistry::new();
assert!(registry.is_empty());
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
let yaml = DialectYaml {
dialect: super::super::dialect_yaml::DialectMeta {
name: "MySQL".to_string(),
display_name: Some("MySQL".to_string()),
versions: vec![],
},
identifier_rules: super::super::dialect_yaml::IdentifierRules {
quote_char: "`".to_string(),
case_sensitive: false,
max_length: 64,
},
..Default::default()
};
registry.register_descriptor("MySQL", desc, yaml);
assert_eq!(registry.len(), 1);
assert!(registry.has("MySQL"));
assert!(!registry.has("PostgreSQL"));
let loaded = registry.get("MySQL").unwrap();
assert_eq!(loaded.dialect_name, "MySQL");
registry.unregister("MySQL");
assert!(registry.is_empty());
}
#[test]
fn registry_fallback_resolve() {
let registry = DialectRegistry::new();
let desc = resolve_descriptor(DialectKind::Mysql, &registry);
assert!(desc.has_capability(super::super::descriptor::CAP_ADD_COLUMN));
}
#[test]
fn registry_all_kinds() {
let registry = DialectRegistry::new();
let desc = DialectCapabilityDescriptor::for_dialect(DialectKind::Mysql);
let yaml = DialectYaml {
dialect: super::super::dialect_yaml::DialectMeta {
name: "MySQL".to_string(),
display_name: None,
versions: vec![],
},
identifier_rules: super::super::dialect_yaml::IdentifierRules {
quote_char: "`".to_string(),
case_sensitive: false,
max_length: 64,
},
..Default::default()
};
registry.register_descriptor("MySQL", desc, yaml);
let names = registry.all_names();
assert_eq!(names, vec!["mysql"]);
}
}
@@ -0,0 +1,134 @@
use crate::sql_dialect::descriptor::DialectKind;
use crate::sql_dialect::dialect_loader::DialectRegistry;
pub fn list_dialect_type_names(dialect_name: &str) -> Vec<String> {
super::lazy_init();
list_dialect_type_names_in(dialect_name, DialectRegistry::global())
}
fn list_dialect_type_names_in(dialect_name: &str, registry: &DialectRegistry) -> Vec<String> {
if let Some(loaded) = registry.get(dialect_name) {
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
}
// Public callers may use stable dialect labels such as `postgres`,
// while the embedded catalog keeps the product name `PostgreSQL`.
if let Some(kind) = DialectKind::from_label(dialect_name) {
if let Some(loaded) = registry.get(core_catalog_name(kind)) {
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
}
// Last-resort fallback: any dialect in the same family
if let Some(loaded) = registry.get_by_kind(kind) {
return loaded.yaml.types.iter().map(|t| t.name.clone()).collect();
}
}
Vec::new()
}
fn core_catalog_name(kind: DialectKind) -> &'static str {
match kind {
DialectKind::Mysql => "MySQL",
DialectKind::Postgres => "PostgreSQL",
DialectKind::Sqlite => "SQLite",
DialectKind::DuckDb => "DuckDB",
DialectKind::SqlServer => "SQL Server",
DialectKind::Oracle => "Oracle",
DialectKind::H2 => "H2",
DialectKind::ClickHouse => "ClickHouse",
DialectKind::ManticoreSearch => "ManticoreSearch",
DialectKind::Informix => "Informix",
DialectKind::Questdb => "QuestDB",
DialectKind::Unsupported => "Unsupported",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sql_dialect::descriptor::DialectCapabilityDescriptor;
use crate::sql_dialect::dialect_yaml::{DialectMeta, DialectType, DialectVersion, DialectYaml, IdentifierRules};
fn make_registry_with_types(dialect_name: &str, type_names: &[&str]) -> DialectRegistry {
let registry = DialectRegistry::new();
let types: Vec<DialectType> = type_names
.iter()
.map(|&name| DialectType {
name: name.to_string(),
category: "STRING".to_string(),
max_precision: None,
precision_range: None,
has_length: false,
has_precision: false,
aliases: vec![],
semantic_fidelity_base: 1.0,
})
.collect();
let yaml = DialectYaml {
dialect: DialectMeta {
name: dialect_name.to_string(),
display_name: Some(dialect_name.to_string()),
versions: vec![DialectVersion { version: "1.0".to_string(), status: "COMPATIBLE".to_string() }],
},
types,
identifier_rules: IdentifierRules { quote_char: "\"".to_string(), case_sensitive: false, max_length: 64 },
..Default::default()
};
let kind = yaml.dialect_kind().unwrap();
let descriptor = DialectCapabilityDescriptor::for_dialect(kind);
registry.register_descriptor(dialect_name, descriptor, yaml);
registry
}
#[test]
fn list_mysql_types() {
let registry = make_registry_with_types("MySQL", &["VARCHAR", "INT", "BIGINT"]);
let types = list_dialect_type_names_in("MySQL", &registry);
assert_eq!(types, vec!["VARCHAR", "INT", "BIGINT"]);
assert!(types.contains(&"VARCHAR".to_string()));
}
#[test]
fn list_dameng_types() {
let registry = make_registry_with_types(
"Dameng",
&[
"VARCHAR",
"VARCHAR2",
"INT",
"INTEGER",
"BIGINT",
"FLOAT",
"DOUBLE",
"DATE",
"TIMESTAMP",
"CLOB",
"BLOB",
"TEXT",
],
);
let types = list_dialect_type_names_in("Dameng", &registry);
assert!(!types.is_empty(), "Dameng should have types");
assert!(types.contains(&"VARCHAR2".to_string()));
assert_eq!(types.len(), 12);
}
#[test]
fn list_unknown_dialect_returns_empty() {
let registry = DialectRegistry::new();
let types = list_dialect_type_names_in("nonexistent_db", &registry);
assert!(types.is_empty());
}
#[test]
fn lists_embedded_postgres_types_by_name_and_label() {
let by_name = list_dialect_type_names("PostgreSQL");
let by_label = list_dialect_type_names("postgres");
assert!(!by_name.is_empty());
assert_eq!(by_label, by_name);
assert!(by_name.contains(&"SMALLINT".to_string()));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,143 @@
use std::path::PathBuf;
use std::time::Duration;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
use super::dialect_loader::DialectPluginLoader;
/// Configuration for the hot-reload watcher.
#[derive(Debug, Clone)]
pub struct HotReloadConfig {
/// Debounce duration — multiple changes within this window are coalesced.
pub debounce_ms: u64,
/// Whether to enable polling (needed for some network filesystems).
pub enable_polling: bool,
/// Polling interval when polling is enabled.
pub poll_interval_ms: u64,
}
impl Default for HotReloadConfig {
fn default() -> Self {
Self { debounce_ms: 500, enable_polling: false, poll_interval_ms: 2000 }
}
}
/// Result of a hot-reload operation on a changed file.
#[derive(Debug, Clone)]
pub enum ReloadEvent {
/// A dialect YAML file was successfully loaded/updated.
Loaded { path: PathBuf },
/// A dialect YAML file was removed — dialect unregistered.
Removed { path: PathBuf, dialect: String },
/// A file changed but failed to load.
Error { path: PathBuf, error: String },
}
/// Hot-reload watcher that monitors `plugins/dialects/` for YAML changes
/// and updates the `DialectRegistry` in real time.
pub struct DialectHotReload {
watcher: Option<RecommendedWatcher>,
config: HotReloadConfig,
}
impl DialectHotReload {
pub fn new(config: HotReloadConfig) -> Self {
Self { watcher: None, config }
}
/// Start watching. Returns a channel receiver for reload events.
pub fn start(&mut self, watch_dirs: Vec<PathBuf>) -> Result<mpsc::UnboundedReceiver<ReloadEvent>, String> {
let (tx, rx) = mpsc::unbounded_channel();
let _debounce = Duration::from_millis(self.config.debounce_ms);
let tx_clone = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| match res {
Ok(event) => {
if !Self::is_relevant(&event) {
return;
}
for path in &event.paths {
let _ = tx_clone.send(match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => ReloadEvent::Loaded { path: path.clone() },
EventKind::Remove(_) => ReloadEvent::Removed {
path: path.clone(),
dialect: path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string(),
},
_ => continue,
});
}
}
Err(e) => {
let _ = tx_clone
.send(ReloadEvent::Error { path: PathBuf::from("."), error: format!("Watcher error: {e}") });
}
},
Config::default()
.with_poll_interval(Duration::from_millis(self.config.poll_interval_ms))
.with_compare_contents(true),
)
.map_err(|e| format!("Failed to create file watcher: {e}"))?;
for dir in &watch_dirs {
if dir.exists() {
watcher
.watch(dir, RecursiveMode::NonRecursive)
.map_err(|e| format!("Failed to watch {}: {e}", dir.display()))?;
}
}
self.watcher = Some(watcher);
Ok(rx)
}
/// Process reload events in a loop, updating the registry.
/// This is a convenience function that runs forever on the current task.
pub async fn run_forever(
watch_dirs: Vec<PathBuf>,
registry: &'static super::dialect_loader::DialectRegistry,
) -> Result<(), String> {
let mut reload = Self::new(HotReloadConfig::default());
let mut rx = reload.start(watch_dirs)?;
while let Some(event) = rx.recv().await {
match &event {
ReloadEvent::Loaded { path } => match DialectPluginLoader::load_file(path) {
Ok((kind, yaml, descriptor)) => {
registry.register_yaml(path, yaml, descriptor);
log::info!("Hot-reload: loaded dialect {:?} from {}", kind, path.display());
}
Err(e) => {
log::warn!("Hot-reload: failed to load {}: {e}", path.display());
}
},
ReloadEvent::Removed { path, dialect } => {
registry.unregister(dialect);
log::info!("Hot-reload: unregistered dialect {dialect} ({})", path.display());
}
ReloadEvent::Error { path, error } => {
log::error!("Hot-reload: error watching {}: {error}", path.display());
}
}
}
Ok(())
}
fn is_relevant(event: &Event) -> bool {
for path in &event.paths {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if matches!(ext, "yaml" | "yml") {
return true;
}
}
}
false
}
pub fn stop(&mut self) {
self.watcher = None;
}
}
@@ -0,0 +1,466 @@
use crate::models::connection::DatabaseType;
use percent_encoding::percent_decode_str;
use super::capabilities::{is_schema_aware, is_simple_informix_identifier};
pub const SQLSERVER_LINKED_SCHEMA_PREFIX: &str = "__dbx_sqlserver_linked__:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqlServerLinkedSchemaRef {
pub server: String,
pub catalog: String,
pub schema: String,
}
pub fn parse_sqlserver_linked_schema_ref(schema: &str) -> Option<SqlServerLinkedSchemaRef> {
let payload = schema.strip_prefix(SQLSERVER_LINKED_SCHEMA_PREFIX)?;
let mut parts = payload.split('|');
let server = decode_linked_schema_part(parts.next()?)?;
let catalog = decode_linked_schema_part(parts.next()?)?;
let schema = decode_linked_schema_part(parts.next()?)?;
if parts.next().is_some() || server.trim().is_empty() || catalog.trim().is_empty() || schema.trim().is_empty() {
return None;
}
Some(SqlServerLinkedSchemaRef { server, catalog, schema })
}
pub fn sqlserver_linked_table_name(linked: &SqlServerLinkedSchemaRef, table_name: &str) -> String {
[linked.server.as_str(), linked.catalog.as_str(), linked.schema.as_str(), table_name]
.into_iter()
.map(|part| quote_table_identifier(Some(DatabaseType::SqlServer), part))
.collect::<Vec<_>>()
.join(".")
}
fn decode_linked_schema_part(value: &str) -> Option<String> {
percent_decode_str(value).decode_utf8().ok().map(|value| value.into_owned())
}
pub fn qualified_table_name(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
if database_type == Some(DatabaseType::Iotdb) {
let table_name = quote_table_identifier(database_type, table_name);
let schema = schema.map(str::trim).filter(|schema| !schema.is_empty());
if let Some(schema) = schema {
if table_name == schema || table_name.starts_with(&format!("{schema}.")) {
return table_name;
}
return format!("{}.{}", quote_table_identifier(database_type, schema), table_name);
}
return table_name;
}
let supports_qualifier = database_type.is_some_and(is_schema_aware)
|| matches!(
database_type,
Some(DatabaseType::Mysql | DatabaseType::Goldendb | DatabaseType::StarRocks | DatabaseType::Doris)
);
if supports_qualifier
&& database_type != Some(DatabaseType::Jdbc)
&& schema.is_some_and(|schema| !schema.trim().is_empty())
{
if database_type == Some(DatabaseType::SqlServer) {
if let Some(linked) = schema.and_then(parse_sqlserver_linked_schema_ref) {
return sqlserver_linked_table_name(&linked, table_name);
}
}
return format!(
"{}.{}",
quote_table_identifier(database_type, schema.unwrap()),
quote_table_identifier(database_type, table_name)
);
}
quote_table_identifier(database_type, table_name)
}
/// Like `qualified_table_name`, but also supports 3-part names for SQL Server
/// (`<database>.<schema>.<table>`) and Doris/StarRocks external catalogs
/// (`<catalog>.<database>.<table>`). SQL parsing stores the first segment of a
/// 3-part source in `catalog`; for SQL Server that segment is its database.
/// Doris/StarRocks use `schema` as the middle segment when present, otherwise
/// `database`, and ignore their built-in `internal` catalog.
pub fn qualified_table_name_with_catalog(
database_type: Option<DatabaseType>,
catalog: Option<&str>,
schema: Option<&str>,
database: Option<&str>,
table_name: &str,
) -> String {
let catalog = catalog.map(str::trim).filter(|catalog| !catalog.is_empty());
match (catalog, database_type) {
(Some(database), Some(DatabaseType::SqlServer)) => {
let table = qualified_table_name(database_type, schema, table_name);
format!("{}.{}", quote_table_identifier(database_type, database), table)
}
(Some(catalog), Some(DatabaseType::Doris | DatabaseType::StarRocks)) if catalog != "internal" => {
let middle = schema
.map(str::trim)
.filter(|schema| !schema.is_empty())
.or_else(|| database.map(str::trim).filter(|database| !database.is_empty()));
let table = match middle {
Some(middle) => format!(
"{}.{}",
quote_table_identifier(database_type, middle),
quote_table_identifier(database_type, table_name)
),
None => quote_table_identifier(database_type, table_name),
};
format!("{}.{}", quote_table_identifier(database_type, catalog), table)
}
_ => qualified_table_name(database_type, schema, table_name),
}
}
pub fn quote_table_identifier(database_type: Option<DatabaseType>, name: &str) -> String {
if matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss))
&& is_explicitly_quoted_identifier(name)
{
return name.to_string();
}
match database_type {
Some(DatabaseType::Iotdb) => name.to_string(),
// JDBC connections use the driver-reported identifier quote string
// (DatabaseMetaData.getIdentifierQuoteString()) inside the JDBC agent,
// so the Rust layer passes identifiers through unquoted.
Some(DatabaseType::Jdbc) => name.to_string(),
Some(
DatabaseType::Mysql
| DatabaseType::ClickHouse
| DatabaseType::Doris
| DatabaseType::Goldendb
| DatabaseType::StarRocks
| DatabaseType::ManticoreSearch
| DatabaseType::Hive
| DatabaseType::Kyuubi
| DatabaseType::Impala
| DatabaseType::Argo
| DatabaseType::Spark
| DatabaseType::Databricks
| DatabaseType::Databend
| DatabaseType::Tdengine
| DatabaseType::Access
| DatabaseType::Bigquery
// GoogleSQL (Spanner's default dialect) quotes identifiers with backticks;
// double quotes are string literals there, so `SELECT * FROM "users"` is a
// syntax error. PostgreSQL-dialect Spanner databases override this through
// the identifier quote reported by the agent on connect.
| DatabaseType::Spanner
| DatabaseType::Questdb,
) => {
format!("`{}`", name.replace('`', "``"))
}
Some(DatabaseType::Informix) if is_simple_informix_identifier(name) => name.to_string(),
Some(DatabaseType::Neo4j) => format!("`{}`", name.replace('`', "``")),
Some(DatabaseType::SqlServer) => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
/// Quote an IRIS identifier only when its spelling requires a delimited name.
///
/// Caché/IRIS installations may disable delimited identifiers. The IRIS JDBC
/// preparser turns quoted names into `:%qpar(...)` parameters in that mode,
/// which is not valid in table references. Ordinary metadata names are
/// case-insensitive and can be sent unquoted; names containing separators or
/// other punctuation still need the identifier quote character.
pub fn quote_iris_identifier(name: &str, identifier_quote: Option<&str>) -> String {
let mut chars = name.chars();
let ordinary = chars.next().is_some_and(|first| first == '_' || first == '%' || first.is_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_alphanumeric());
if ordinary {
return name.to_string();
}
let quote = identifier_quote.map(str::trim).filter(|quote| !quote.is_empty()).unwrap_or("\"");
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
}
pub fn quote_gaussdb_jdbc_identifier(name: &str, identifier_quote: &str) -> String {
if is_explicitly_quoted_identifier(name) {
return name.to_string();
}
let quote = identifier_quote.trim();
if quote.is_empty() {
return name.to_string();
}
let requires_quote = !is_simple_lower_identifier(name)
|| is_postgres_reserved_identifier(name)
|| (quote == "`" && is_mysql_only_reserved_identifier(name));
if !requires_quote {
return name.to_string();
}
format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}")))
}
fn is_explicitly_quoted_identifier(name: &str) -> bool {
name.len() >= 2
&& ((name.starts_with('"') && name.ends_with('"'))
|| (name.starts_with('`') && name.ends_with('`'))
|| (name.starts_with('[') && name.ends_with(']')))
}
fn is_simple_lower_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_lowercase())
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_lowercase() || ch.is_ascii_digit())
}
fn is_postgres_reserved_identifier(name: &str) -> bool {
matches!(
name,
"all"
| "analyse"
| "analyze"
| "and"
| "any"
| "array"
| "as"
| "asc"
| "asymmetric"
| "authorization"
| "binary"
| "both"
| "case"
| "cast"
| "check"
| "collate"
| "collation"
| "column"
| "concurrently"
| "constraint"
| "create"
| "cross"
| "current_catalog"
| "current_date"
| "current_role"
| "current_schema"
| "current_time"
| "current_timestamp"
| "current_user"
| "default"
| "deferrable"
| "desc"
| "distinct"
| "do"
| "else"
| "end"
| "except"
| "false"
| "fetch"
| "for"
| "foreign"
| "freeze"
| "from"
| "full"
| "grant"
| "group"
| "having"
| "ilike"
| "in"
| "initially"
| "inner"
| "intersect"
| "into"
| "is"
| "isnull"
| "join"
| "lateral"
| "leading"
| "left"
| "like"
| "limit"
| "localtime"
| "localtimestamp"
| "natural"
| "not"
| "notnull"
| "null"
| "offset"
| "on"
| "only"
| "or"
| "order"
| "outer"
| "overlaps"
| "placing"
| "primary"
| "references"
| "returning"
| "right"
| "select"
| "session_user"
| "similar"
| "some"
| "symmetric"
| "system_user"
| "table"
| "tablesample"
| "then"
| "to"
| "trailing"
| "true"
| "union"
| "unique"
| "user"
| "using"
| "variadic"
| "verbose"
| "when"
| "where"
| "window"
| "with"
)
}
fn is_mysql_only_reserved_identifier(name: &str) -> bool {
matches!(
name,
"accessible"
| "auto_increment"
| "change"
| "database"
| "databases"
| "delayed"
| "describe"
| "div"
| "dual"
| "enclosed"
| "escaped"
| "explain"
| "force"
| "fulltext"
| "high_priority"
| "ignore"
| "index"
| "infile"
| "key"
| "keys"
| "kill"
| "linear"
| "lines"
| "load"
| "lock"
| "low_priority"
| "master_ssl_verify_server_cert"
| "maxvalue"
| "mediumint"
| "mod"
| "no_write_to_binlog"
| "optimize"
| "optionally"
| "outfile"
| "partition"
| "purge"
| "range"
| "read_write"
| "regexp"
| "release"
| "rename"
| "replace"
| "require"
| "rlike"
| "schema"
| "schemas"
| "separator"
| "show"
| "spatial"
| "sql_big_result"
| "sql_calc_found_rows"
| "sql_small_result"
| "ssl"
| "starting"
| "straight_join"
| "terminated"
| "tinyint"
| "unlock"
| "unsigned"
| "use"
| "utc_date"
| "utc_time"
| "utc_timestamp"
| "values"
| "varbinary"
| "varchar"
| "write"
| "xor"
| "zerofill"
)
}
pub fn normalize_where_input(where_input: Option<&str>) -> String {
let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim();
let mut chars = trimmed.chars();
let prefix = chars.by_ref().take(5).collect::<String>();
if prefix.eq_ignore_ascii_case("where") {
chars.as_str().trim().to_string()
} else {
trimmed.to_string()
}
}
pub fn quote_transfer_identifier(name: &str, database_type: &DatabaseType) -> String {
match database_type {
DatabaseType::Mysql
| DatabaseType::ClickHouse
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::Hive
| DatabaseType::Kyuubi
| DatabaseType::Impala
| DatabaseType::Argo
| DatabaseType::Spark
| DatabaseType::Questdb => format!("`{}`", name.replace('`', "``")),
DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('\"', "\"\"")),
}
}
pub fn transfer_column_identifier(name: &str, database_type: &DatabaseType, quote_target_column_names: bool) -> String {
if quote_target_column_names
|| !matches!(database_type, DatabaseType::Gaussdb | DatabaseType::OpenGauss)
|| !is_simple_unquoted_identifier(name)
|| is_postgres_reserved_identifier(&name.to_ascii_lowercase())
{
quote_transfer_identifier(name, database_type)
} else {
name.to_string()
}
}
fn is_simple_unquoted_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
}
/// Qualified table name for transfer SQL.
///
/// * Without catalog: produces `schema.table` (or just `table` for MySQL family).
/// * With catalog AND the database type supports external catalogs (Doris/StarRocks):
/// produces `catalog.schema.table` — the 3-part form those engines require to
/// address objects in an external (non-internal) catalog.
pub fn qualified_transfer_table(
table_name: &str,
schema: &str,
database_type: &DatabaseType,
catalog: Option<&str>,
) -> String {
let table = quote_transfer_identifier(table_name, database_type);
if let Some(catalog) = catalog {
format!(
"{}.{}.{}",
quote_transfer_identifier(catalog, database_type),
quote_transfer_identifier(schema, database_type),
table
)
} else if schema.is_empty()
|| matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb)
{
table
} else {
format!("{}.{}", quote_transfer_identifier(schema, database_type), table)
}
}
+320
View File
@@ -0,0 +1,320 @@
use super::descriptor::{DialectKind, TypeMappingMatrix};
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnType {
pub base_type: String,
pub precision: Option<u32>,
pub scale: Option<u32>,
pub length: Option<u32>,
pub is_array: bool,
pub is_unsigned: bool,
pub extras: Vec<String>,
}
impl ColumnType {
pub fn parse(type_str: &str) -> Self {
let trimmed = type_str.trim();
let (base_type, params_str) = match trimmed.find('(') {
Some(pos) if trimmed.ends_with(')') => {
let base = trimmed[..pos].trim().to_string();
let params = trimmed[pos + 1..trimmed.len() - 1].trim();
(base, Some(params))
}
_ => (trimmed.to_string(), None),
};
let is_unsigned = base_type.to_ascii_lowercase().contains("unsigned");
let is_array = trimmed.ends_with("[]") || trimmed.to_ascii_lowercase().contains(" array");
let base_type_clean =
base_type.replace(" unsigned", "").replace(" UNSIGNED", "").replace("[]", "").trim().to_string();
let (precision, scale, length) = match params_str {
Some(params) if params.contains(',') => {
let parts: Vec<&str> = params.split(',').collect();
if parts.len() >= 2 {
let p = parts[0].trim().parse().ok();
let s = parts[1].trim().parse().ok();
(p, s, None)
} else {
(None, None, params.parse().ok())
}
}
Some(params) => {
let v = params.parse().ok();
(None, None, v)
}
None => (None, None, None),
};
Self { base_type: base_type_clean, precision, scale, length, is_array, is_unsigned, extras: Vec::new() }
}
pub fn to_string(&self, dialect: DialectKind) -> String {
let mut result = self.base_type.clone();
if self.is_unsigned && dialect == DialectKind::Mysql {
result.push_str(" UNSIGNED");
}
if let Some(len) = self.length {
result.push_str(&format!("({len})"));
} else if let Some(p) = self.precision {
if let Some(s) = self.scale {
result.push_str(&format!("({p},{s})"));
} else {
result.push_str(&format!("({p})"));
}
}
if self.is_array && (dialect == DialectKind::Postgres || dialect == DialectKind::DuckDb) {
result.push_str("[]");
}
result
}
}
pub trait TypeInferenceEngine {
fn infer_type(&self, source_type: &str, source_dialect: DialectKind, target_dialect: DialectKind) -> ColumnType;
fn suggest_type(&self, column: &ColumnType, target_dialect: DialectKind) -> String;
fn convert_default_value(
&self,
default_expr: &str,
source_dialect: DialectKind,
target_dialect: DialectKind,
) -> String;
fn type_compatibility_score(&self, source: &ColumnType, target: &ColumnType) -> f64;
}
const SCORE_IDENTICAL: f64 = 1.0;
const SCORE_ALIAS: f64 = 0.9;
const SCORE_INTEGER_FAMILY: f64 = 0.8;
const SCORE_TEXT_FAMILY: f64 = 0.7;
const SCORE_UNRELATED: f64 = 0.3;
pub struct DefaultTypeInferenceEngine;
impl DefaultTypeInferenceEngine {
fn map_type_name(&self, type_name: &str, from: DialectKind, to: DialectKind) -> String {
let matrix = TypeMappingMatrix::for_dialects(from, to);
let (converted, _) = matrix.convert_type(type_name);
converted
}
}
impl TypeInferenceEngine for DefaultTypeInferenceEngine {
fn infer_type(&self, source_type: &str, source_dialect: DialectKind, target_dialect: DialectKind) -> ColumnType {
let source_parsed = ColumnType::parse(source_type);
let mapped_base = self.map_type_name(&source_parsed.base_type, source_dialect, target_dialect);
let target_caps = crate::sql_dialect::resolve(target_dialect);
let needs_precision = mapped_base.to_ascii_lowercase().contains("decimal")
|| mapped_base.to_ascii_lowercase().contains("numeric")
|| mapped_base.to_ascii_lowercase().contains("varchar");
let (precision, scale, length) = if needs_precision {
(source_parsed.precision, source_parsed.scale, source_parsed.length)
} else if mapped_base.eq_ignore_ascii_case("integer") && source_parsed.precision.is_some() {
(None, None, None)
} else {
(source_parsed.precision, source_parsed.scale, source_parsed.length)
};
ColumnType {
base_type: mapped_base,
precision,
scale,
length,
is_array: source_parsed.is_array && target_caps.supports_array_type,
is_unsigned: source_parsed.is_unsigned && target_dialect == DialectKind::Mysql,
extras: source_parsed.extras,
}
}
fn suggest_type(&self, column: &ColumnType, target_dialect: DialectKind) -> String {
column.to_string(target_dialect)
}
fn convert_default_value(
&self,
default_expr: &str,
source_dialect: DialectKind,
target_dialect: DialectKind,
) -> String {
let trimmed = default_expr.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") || trimmed.eq_ignore_ascii_case("NULL") {
return String::new();
}
match (source_dialect, target_dialect) {
(DialectKind::Mysql, DialectKind::Postgres) => {
let lower = trimmed.to_ascii_lowercase();
if lower == "current_timestamp" || lower == "current_timestamp()" || lower == "now()" {
return "CURRENT_TIMESTAMP".to_string();
}
if lower.starts_with("on update ") {
return String::new();
}
}
(DialectKind::Postgres, DialectKind::Mysql) => {
let lower = trimmed.to_ascii_lowercase();
if lower == "current_timestamp" || lower == "now()" || lower == "transaction_timestamp()" {
return "CURRENT_TIMESTAMP".to_string();
}
}
(DialectKind::Mysql, DialectKind::Sqlite) => {
let lower = trimmed.to_ascii_lowercase();
if lower == "current_timestamp" || lower == "current_timestamp()" || lower == "now()" {
return "CURRENT_TIMESTAMP".to_string();
}
}
_ => {}
}
trimmed.to_string()
}
fn type_compatibility_score(&self, source: &ColumnType, target: &ColumnType) -> f64 {
let source_lower = source.base_type.to_ascii_lowercase();
let target_lower = target.base_type.to_ascii_lowercase();
if source_lower == target_lower {
return SCORE_IDENTICAL;
}
let exact_matches = [
("int", "integer"),
("integer", "int"),
("float", "real"),
("real", "float"),
("double", "double precision"),
("double precision", "double"),
("bool", "boolean"),
("boolean", "bool"),
("timestamp", "datetime"),
("datetime", "timestamp"),
];
if exact_matches.contains(&(source_lower.as_str(), target_lower.as_str())) {
return SCORE_ALIAS;
}
let integer_family = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "serial", "bigserial"];
let text_family = ["char", "varchar", "text", "tinytext", "mediumtext", "longtext", "clob", "nclob"];
if integer_family.contains(&source_lower.as_str()) && integer_family.contains(&target_lower.as_str()) {
return SCORE_INTEGER_FAMILY;
}
if text_family.contains(&source_lower.as_str()) && text_family.contains(&target_lower.as_str()) {
return SCORE_TEXT_FAMILY;
}
SCORE_UNRELATED
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_type() {
let t = ColumnType::parse("INT");
assert_eq!(t.base_type, "INT");
assert!(t.precision.is_none());
assert!(t.length.is_none());
}
#[test]
fn parse_type_with_length() {
let t = ColumnType::parse("VARCHAR(255)");
assert_eq!(t.base_type, "VARCHAR");
assert_eq!(t.length, Some(255));
}
#[test]
fn parse_type_with_precision_scale() {
let t = ColumnType::parse("DECIMAL(10,2)");
assert_eq!(t.base_type, "DECIMAL");
assert_eq!(t.precision, Some(10));
assert_eq!(t.scale, Some(2));
}
#[test]
fn parse_unsigned_type() {
let t = ColumnType::parse("INT UNSIGNED");
assert_eq!(t.base_type, "INT");
assert!(t.is_unsigned);
}
#[test]
fn infer_mysql_int_to_postgres() {
let engine = DefaultTypeInferenceEngine;
let result = engine.infer_type("INT", DialectKind::Mysql, DialectKind::Postgres);
assert_eq!(result.base_type, "INTEGER");
}
#[test]
fn infer_mysql_datetime_to_postgres() {
let engine = DefaultTypeInferenceEngine;
let result = engine.infer_type("DATETIME", DialectKind::Mysql, DialectKind::Postgres);
assert_eq!(result.base_type, "TIMESTAMP");
}
#[test]
fn infer_postgres_text_to_mysql() {
let engine = DefaultTypeInferenceEngine;
let result = engine.infer_type("TEXT", DialectKind::Postgres, DialectKind::Mysql);
assert_eq!(result.base_type, "LONGTEXT");
}
#[test]
fn type_compatibility_exact_match() {
let engine = DefaultTypeInferenceEngine;
let a = ColumnType::parse("INT");
let b = ColumnType::parse("INTEGER");
let score = engine.type_compatibility_score(&a, &b);
assert!((score - 0.9).abs() < 0.01);
}
#[test]
fn type_compatibility_integer_family() {
let engine = DefaultTypeInferenceEngine;
let a = ColumnType::parse("SMALLINT");
let b = ColumnType::parse("BIGINT");
let score = engine.type_compatibility_score(&a, &b);
assert!((score - 0.8).abs() < 0.01);
}
#[test]
fn convert_default_value_mysql_to_postgres() {
let engine = DefaultTypeInferenceEngine;
assert_eq!(
engine.convert_default_value("CURRENT_TIMESTAMP", DialectKind::Mysql, DialectKind::Postgres),
"CURRENT_TIMESTAMP"
);
assert_eq!(
engine.convert_default_value("NOW()", DialectKind::Mysql, DialectKind::Postgres),
"CURRENT_TIMESTAMP"
);
assert!(engine
.convert_default_value("ON UPDATE CURRENT_TIMESTAMP", DialectKind::Mysql, DialectKind::Postgres)
.is_empty());
}
#[test]
fn unknown_type_passthrough() {
let engine = DefaultTypeInferenceEngine;
let result = engine.infer_type("GEOGRAPHY(POINT)", DialectKind::Postgres, DialectKind::Mysql);
assert_eq!(result.base_type, "GEOGRAPHY");
}
#[test]
fn parse_array_type() {
let t = ColumnType::parse("INTEGER[]");
assert!(t.is_array);
assert_eq!(t.base_type, "INTEGER");
}
}
@@ -0,0 +1,430 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 67
expression: all
---
[
{
"kind": "mysql",
"label": "mysql",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"reorder_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"index_comment",
"alter_primary_key",
"foreign_key",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"temporary_table",
"auto_increment"
],
"max_identifier_length": 64,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 4096,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": true,
"supports_uuid_type": false,
"supports_auto_increment": true,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": false
},
{
"kind": "postgres",
"label": "postgres",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"index_include",
"index_filter",
"index_comment",
"alter_primary_key",
"foreign_key",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"create_or_replace",
"temporary_table",
"transactional_ddl",
"identity_columns"
],
"max_identifier_length": 63,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"supports_json_type": true,
"supports_array_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": true,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": true
},
{
"kind": "sqlite",
"label": "sqlite",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"create_index",
"drop_index",
"rebuild_index",
"index_filter",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"if_not_exists",
"auto_increment"
],
"max_identifier_length": 255,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 2000,
"supports_json_type": false,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": true,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": false,
"supports_full_text_index": true,
"supports_spatial_index": false,
"supports_transactional_ddl": false
},
{
"kind": "duck_db",
"label": "duckdb",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"create_index",
"drop_index",
"rebuild_index",
"create_table",
"drop_table",
"truncate_table",
"if_not_exists",
"create_or_replace",
"temporary_table"
],
"max_identifier_length": 255,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"supports_json_type": true,
"supports_array_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": false,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": false,
"supports_spatial_index": true,
"supports_transactional_ddl": false
},
{
"kind": "sql_server",
"label": "sqlserver",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"index_include",
"index_filter",
"index_comment",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"temporary_table",
"transactional_ddl",
"identity_columns"
],
"max_identifier_length": 128,
"supports_schemas": true,
"supports_catalogs": true,
"max_columns_per_table": 1024,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": true,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": true
},
{
"kind": "oracle",
"label": "oracle",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"temporary_table",
"identity_columns"
],
"max_identifier_length": 30,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1000,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": false,
"supports_identity_columns": true,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": false
},
{
"kind": "h2",
"label": "h2",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"if_not_exists",
"temporary_table",
"identity_columns"
],
"max_identifier_length": 256,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": true,
"supports_sequences": true,
"supports_partitioning": false,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": false
},
{
"kind": "click_house",
"label": "clickhouse",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"reorder_column",
"comment",
"create_table",
"drop_table",
"truncate_table",
"if_not_exists",
"temporary_table"
],
"max_identifier_length": 256,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1000,
"supports_json_type": true,
"supports_array_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": false,
"supports_transactional_ddl": false
},
{
"kind": "manticore_search",
"label": "manticoresearch",
"capabilities": [
"add_column",
"drop_column",
"create_table",
"drop_table",
"truncate_table",
"auto_increment"
],
"max_identifier_length": 256,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 512,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": true,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": false,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": false
},
{
"kind": "informix",
"label": "informix",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"create_index",
"drop_index",
"rebuild_index",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"grant_revoke"
],
"max_identifier_length": 128,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 32767,
"supports_json_type": false,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": false,
"supports_identity_columns": false,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": false,
"supports_spatial_index": false,
"supports_transactional_ddl": false
},
{
"kind": "questdb",
"label": "questdb",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"create_table",
"drop_table",
"truncate_table",
"if_not_exists"
],
"max_identifier_length": 256,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 1024,
"supports_json_type": false,
"supports_array_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_auto_increment": false,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": true,
"supports_full_text_index": false,
"supports_spatial_index": false,
"supports_transactional_ddl": false
}
]
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 37
expression: desc
---
{
"dialect": "duck_db",
"flags": 469991879,
"max_identifier_length": 255,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"max_indexes_per_table": 100,
"max_query_size_bytes": 268435456,
"supports_full_text_index": false,
"supports_spatial_index": true,
"supports_partitioning": true,
"supports_table_sampling": true,
"max_foreign_key_name_length": 255,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": false,
"supports_array_type": true,
"supports_json_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_identity_columns": false,
"supports_auto_increment": false,
"supports_sequences": true
}
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 7
expression: desc
---
{
"dialect": "mysql",
"flags": 1476391935,
"max_identifier_length": 64,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 4096,
"max_indexes_per_table": 64,
"max_query_size_bytes": 16777216,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_partitioning": true,
"supports_table_sampling": false,
"max_foreign_key_name_length": 64,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": false,
"supports_array_type": false,
"supports_json_type": true,
"supports_enum_type": true,
"supports_uuid_type": false,
"supports_identity_columns": false,
"supports_auto_increment": true,
"supports_sequences": false
}
@@ -0,0 +1,53 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 43
expression: info
---
{
"kind": "mysql",
"label": "mysql",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"reorder_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"index_comment",
"alter_primary_key",
"foreign_key",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"temporary_table",
"auto_increment"
],
"max_identifier_length": 64,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 4096,
"supports_json_type": true,
"supports_array_type": false,
"supports_enum_type": true,
"supports_uuid_type": false,
"supports_auto_increment": true,
"supports_identity_columns": false,
"supports_sequences": false,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": false
}
@@ -0,0 +1,103 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 55
expression: "&matrix.rules"
---
[
{
"source_type": "TINYINT(1)",
"target_type": "BOOLEAN",
"precision_loss": false,
"requires_cast": true
},
{
"source_type": "TINYINT",
"target_type": "SMALLINT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "MEDIUMINT",
"target_type": "INTEGER",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "INT",
"target_type": "INTEGER",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "BIGINT",
"target_type": "BIGINT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "FLOAT",
"target_type": "REAL",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "DOUBLE",
"target_type": "DOUBLE PRECISION",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "TINYTEXT",
"target_type": "TEXT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "TEXT",
"target_type": "TEXT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "MEDIUMTEXT",
"target_type": "TEXT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "LONGTEXT",
"target_type": "TEXT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "BLOB",
"target_type": "BYTEA",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "TINYBLOB",
"target_type": "BYTEA",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "MEDIUMBLOB",
"target_type": "BYTEA",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "LONGBLOB",
"target_type": "BYTEA",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "DATETIME",
"target_type": "TIMESTAMP",
"precision_loss": false,
"requires_cast": false
}
]
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 31
expression: desc
---
{
"dialect": "oracle",
"flags": 402621423,
"max_identifier_length": 30,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1000,
"max_indexes_per_table": 100,
"max_query_size_bytes": 65536,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_partitioning": true,
"supports_table_sampling": true,
"max_foreign_key_name_length": 30,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": true,
"supports_array_type": false,
"supports_json_type": true,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_identity_columns": true,
"supports_auto_increment": false,
"supports_sequences": true
}
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 13
expression: desc
---
{
"dialect": "postgres",
"flags": 3221225455,
"max_identifier_length": 63,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"max_indexes_per_table": 100,
"max_query_size_bytes": 268435456,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_partitioning": true,
"supports_table_sampling": true,
"max_foreign_key_name_length": 63,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": true,
"supports_array_type": true,
"supports_json_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_identity_columns": true,
"supports_auto_increment": false,
"supports_sequences": true
}
@@ -0,0 +1,56 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 49
expression: info
---
{
"kind": "postgres",
"label": "postgres",
"capabilities": [
"add_column",
"drop_column",
"rename_column",
"alter_existing_column",
"comment",
"create_index",
"drop_index",
"rebuild_index",
"index_type",
"index_include",
"index_filter",
"index_comment",
"alter_primary_key",
"foreign_key",
"create_table",
"drop_table",
"truncate_table",
"create_trigger",
"drop_trigger",
"create_function",
"drop_function",
"create_sequence",
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"create_or_replace",
"temporary_table",
"transactional_ddl",
"identity_columns"
],
"max_identifier_length": 63,
"supports_schemas": true,
"supports_catalogs": false,
"max_columns_per_table": 1600,
"supports_json_type": true,
"supports_array_type": true,
"supports_enum_type": true,
"supports_uuid_type": true,
"supports_auto_increment": false,
"supports_identity_columns": true,
"supports_sequences": true,
"supports_partitioning": true,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_transactional_ddl": true
}
@@ -0,0 +1,79 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 61
expression: "&matrix.rules"
---
[
{
"source_type": "SMALLINT",
"target_type": "SMALLINT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "INTEGER",
"target_type": "INT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "BIGINT",
"target_type": "BIGINT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "REAL",
"target_type": "FLOAT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "DOUBLE PRECISION",
"target_type": "DOUBLE",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "TEXT",
"target_type": "LONGTEXT",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "BYTEA",
"target_type": "BLOB",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "BOOLEAN",
"target_type": "TINYINT(1)",
"precision_loss": false,
"requires_cast": true
},
{
"source_type": "TIMESTAMP",
"target_type": "DATETIME",
"precision_loss": false,
"requires_cast": false
},
{
"source_type": "TIMESTAMPTZ",
"target_type": "DATETIME",
"precision_loss": true,
"requires_cast": true
},
{
"source_type": "UUID",
"target_type": "CHAR(36)",
"precision_loss": false,
"requires_cast": true
},
{
"source_type": "JSONB",
"target_type": "JSON",
"precision_loss": false,
"requires_cast": false
}
]
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 19
expression: desc
---
{
"dialect": "sqlite",
"flags": 1157597639,
"max_identifier_length": 255,
"supports_schemas": false,
"supports_catalogs": false,
"max_columns_per_table": 2000,
"max_indexes_per_table": 200,
"max_query_size_bytes": 0,
"supports_full_text_index": true,
"supports_spatial_index": false,
"supports_partitioning": false,
"supports_table_sampling": false,
"max_foreign_key_name_length": 255,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": false,
"supports_array_type": false,
"supports_json_type": false,
"supports_enum_type": false,
"supports_uuid_type": false,
"supports_identity_columns": false,
"supports_auto_increment": true,
"supports_sequences": false
}
@@ -0,0 +1,30 @@
---
source: crates/dbx-sql/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 25
expression: desc
---
{
"dialect": "sql_server",
"flags": 3019874287,
"max_identifier_length": 128,
"supports_schemas": true,
"supports_catalogs": true,
"max_columns_per_table": 1024,
"max_indexes_per_table": 999,
"max_query_size_bytes": 268435456,
"supports_full_text_index": true,
"supports_spatial_index": true,
"supports_partitioning": true,
"supports_table_sampling": true,
"max_foreign_key_name_length": 128,
"supports_on_update_cascade": true,
"supports_on_delete_set_null": true,
"supports_deferrable_constraints": false,
"supports_array_type": false,
"supports_json_type": true,
"supports_enum_type": false,
"supports_uuid_type": true,
"supports_identity_columns": true,
"supports_auto_increment": false,
"supports_sequences": true
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,616 @@
//! Column type rewrite pipeline for cross-database DDL generation.
//!
//! Order:
//! 1. Caller-applied user field mappings (highest priority — not handled here)
//! 2. Target [`DdlDialectProfile::type_map`] (data-driven)
//! 3. [`TypeMappingMatrix`] between source/target [`DialectKind`] when available
//! 4. Generic normalization (strip display width when profile disallows it)
use crate::models::connection::DatabaseType;
use crate::sql_dialect::ddl_profile::{profile_for, DdlDialectProfile};
use crate::sql_dialect::descriptor::{DialectKind, TypeMappingMatrix};
use crate::types::ColumnInfo;
/// Split `INT(11)` / `DECIMAL(10,2)` / `VARCHAR` into base + params.
pub fn split_type_base_params(source_type: &str) -> (String, Option<String>) {
let trimmed = source_type.trim();
let upper = trimmed.to_ascii_uppercase();
match upper.find('(') {
Some(i) => {
let base = upper[..i].trim().to_string();
let end = upper.rfind(')').unwrap_or(upper.len());
let params = upper[i + 1..end].trim().to_string();
(base, Some(params))
}
None => (upper, None),
}
}
fn first_length_param(params: Option<&str>) -> Option<u32> {
params.and_then(|p| p.split(',').next()).and_then(|p| p.trim().parse::<u32>().ok())
}
/// Strip Oracle-specific length-unit qualifiers (`CHAR` / `BYTE`) from a
/// parenthesized length slice such as `(50 CHAR)` → `(50)`.
///
/// Oracle allows character columns to declare their length unit explicitly:
/// `VARCHAR2(50 CHAR)` (characters) or `VARCHAR2(50 BYTE)` (bytes). The unit
/// qualifier is Oracle-only syntax — MySQL's `VARCHAR(n)` always means
/// characters and rejects `VARCHAR(50 CHAR)` with ERROR 1064. Stripping the
/// qualifier here keeps the generated target DDL valid while preserving the
/// numeric length.
///
/// Only single numeric parameters followed by `CHAR`/`BYTE` are touched;
/// precision/scale lists (`(10,2)`) and enum/set lists (`('a','b')`) are
/// returned unchanged.
pub fn normalize_len_params(parenthesized: &str) -> String {
let Some(open) = parenthesized.find('(') else {
return parenthesized.to_string();
};
let Some(close) = parenthesized.rfind(')') else {
return parenthesized.to_string();
};
if open >= close {
return parenthesized.to_string();
}
// Keep any prefix (base type or whitespace) so a full type string such as
// `varchar(50)` round-trips as-is instead of losing its base name.
format!("{}({})", &parenthesized[..open], strip_length_unit(&parenthesized[open + 1..close]))
}
/// Strip a trailing `CHAR`/`BYTE` unit from a single length parameter,
/// e.g. `50 CHAR` → `50`. Multi-part or non-numeric parameters are unchanged.
fn strip_length_unit(params: &str) -> String {
let trimmed = params.trim();
if trimmed.is_empty() || trimmed.contains(',') {
return trimmed.to_string();
}
let upper = trimmed.to_ascii_uppercase();
for unit in ["CHAR", "BYTE"] {
if let Some(head) = upper.strip_suffix(unit) {
let head = head.trim_end();
if !head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()) {
return head.to_string();
}
}
}
trimmed.to_string()
}
fn apply_template(template: &str, params: Option<&str>, max_varchar_len: Option<u32>) -> String {
if !template.contains("{}") {
return template.to_string();
}
// Prefer full params for DECIMAL(p,s); single length for TEXT({})
if template.starts_with("DECIMAL") || template.starts_with("NUMERIC") {
return match params {
Some(p) if !p.is_empty() => template.replacen("{}", p, 1),
_ => template.replace("({})", "").replace("{}", ""),
};
}
let mut len = first_length_param(params).unwrap_or(255);
if let Some(max) = max_varchar_len {
len = len.clamp(1, max);
} else {
len = len.max(1);
}
template.replacen("{}", &len.to_string(), 1)
}
/// Strip MySQL-style display width: `INT(11)` → `INT`, keep `DECIMAL(10,2)` / `VARCHAR(100)`.
///
/// When keeping length/precision params, original casing is preserved (passthrough-ish).
/// When stripping display width, the base is uppercased as a normalized form.
pub fn strip_display_width_if_needed(source_type: &str, profile: &DdlDialectProfile) -> String {
if profile.supports_display_width {
return source_type.trim().to_string();
}
let trimmed = source_type.trim();
let (base, params) = split_type_base_params(trimmed);
let Some(params) = params else {
return trimmed.to_string();
};
// Keep types where parentheses are meaningful precision/length for most targets.
let keep_params = matches!(
base.as_str(),
"VARCHAR"
| "CHARACTER VARYING"
| "CHAR"
| "CHARACTER"
| "NVARCHAR"
| "NVARCHAR2"
| "VARCHAR2"
| "DECIMAL"
| "NUMERIC"
| "NUMBER"
| "FLOAT"
| "DOUBLE"
| "TIME"
| "TIMESTAMP"
| "DATETIME"
| "ENUM"
| "SET"
);
if keep_params {
// Reconstruct from the original slice so casing stays source-faithful.
let orig_base = trimmed.split('(').next().unwrap_or(trimmed).trim();
let open = trimmed.find('(').unwrap_or(orig_base.len());
let close = trimmed.rfind(')').unwrap_or(trimmed.len());
let orig_params = if open < close { trimmed[open + 1..close].trim() } else { params.as_str() };
format!("{orig_base}({orig_params})")
} else {
// INT(11), BIGINT(20), TINYINT(2), YEAR(4), … — normalized uppercase base.
base
}
}
/// Special-case: TINYINT(1) often means boolean on MySQL-like sources.
fn tinyint1_as_boolean_key(base: &str, params: Option<&str>) -> Option<&'static str> {
if base.eq_ignore_ascii_case("TINYINT") && params == Some("1") {
Some("BOOL")
} else {
None
}
}
fn sqlserver_decimal_type(params: Option<&str>) -> String {
let Some(params) = params else {
return "DECIMAL".to_string();
};
let values = params.split(',').map(str::trim).collect::<Vec<_>>();
let Ok(precision) = values.first().copied().unwrap_or_default().parse::<u32>() else {
return "DECIMAL".to_string();
};
let precision = precision.clamp(1, 38);
if values.len() == 1 {
return format!("DECIMAL({precision})");
}
let Ok(scale) = values[1].parse::<u32>() else {
return format!("DECIMAL({precision})");
};
format!("DECIMAL({precision},{})", scale.min(precision))
}
fn sqlserver_length_type(target_type: &str, params: Option<&str>, max_length: u32) -> String {
let Some(param) = params.map(str::trim).filter(|param| !param.is_empty()) else {
return if matches!(target_type, "VARCHAR" | "NVARCHAR" | "VARBINARY") {
format!("{target_type}(MAX)")
} else {
target_type.to_string()
};
};
if param.eq_ignore_ascii_case("MAX") {
return match target_type {
"NCHAR" => "NVARCHAR(MAX)".to_string(),
"CHAR" => "VARCHAR(MAX)".to_string(),
"BINARY" => "VARBINARY(MAX)".to_string(),
_ => format!("{target_type}(MAX)"),
};
}
match param.parse::<u32>() {
Ok(length) if length > max_length => match target_type {
"NCHAR" | "NVARCHAR" => "NVARCHAR(MAX)".to_string(),
"BINARY" | "VARBINARY" => "VARBINARY(MAX)".to_string(),
_ => "VARCHAR(MAX)".to_string(),
},
Ok(length) => format!("{target_type}({})", length.max(1)),
Err(_) => format!("{target_type}(MAX)"),
}
}
fn sqlserver_temporal_type(target_type: &str, params: Option<&str>) -> String {
match params.and_then(|param| param.trim().parse::<u32>().ok()) {
Some(precision) => format!("{target_type}({})", precision.min(7)),
None => target_type.to_string(),
}
}
/// Convert common source types to native SQL Server types before the generic
/// mapping matrix runs. This is source-dialect aware because names such as
/// `timestamp` mean a date/time in MySQL/PostgreSQL but `rowversion` in SQL Server.
fn rewrite_cross_dialect_to_sqlserver(source_type: &str, source: DialectKind) -> Option<String> {
if source == DialectKind::SqlServer {
return None;
}
let source_upper = source_type.trim().to_ascii_uppercase();
// MySQL places UNSIGNED/ZEROFILL after an optional parameter list, so it
// cannot be recovered from `raw_base` alone (`INT(11) UNSIGNED`). ZEROFILL
// also implies UNSIGNED in MySQL.
let mysql_unsigned = source_upper.split_whitespace().any(|part| matches!(part, "UNSIGNED" | "ZEROFILL"));
let (raw_base, params) = split_type_base_params(source_type);
let base = raw_base
.split_whitespace()
.filter(|part| !part.eq_ignore_ascii_case("UNSIGNED") && !part.eq_ignore_ascii_case("ZEROFILL"))
.collect::<Vec<_>>()
.join(" ");
let params = params.as_deref();
let mapped = match source {
DialectKind::Mysql => match base.as_str() {
"BOOL" | "BOOLEAN" => "BIT".to_string(),
"TINYINT" if params == Some("1") && !mysql_unsigned => "BIT".to_string(),
// Preserve the complete MySQL value range. SQL Server TINYINT is
// unsigned, while MySQL TINYINT is signed unless explicitly marked.
"TINYINT" if mysql_unsigned => "TINYINT".to_string(),
"TINYINT" => "SMALLINT".to_string(),
"SMALLINT" if mysql_unsigned => "INT".to_string(),
"SMALLINT" => "SMALLINT".to_string(),
"MEDIUMINT" => "INT".to_string(),
"INT" | "INTEGER" if mysql_unsigned => "BIGINT".to_string(),
"INT" | "INTEGER" => "INT".to_string(),
"BIGINT" if mysql_unsigned => "DECIMAL(20,0)".to_string(),
"BIGINT" => "BIGINT".to_string(),
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
"FLOAT" | "DOUBLE" | "DOUBLE PRECISION" | "REAL" => "FLOAT".to_string(),
"CHAR" | "VARCHAR" => sqlserver_length_type(&base, params, 8000),
"NCHAR" | "NVARCHAR" => sqlserver_length_type(&base, params, 4000),
"TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "JSON" | "ENUM" | "SET" => "NVARCHAR(MAX)".to_string(),
"BINARY" | "VARBINARY" => sqlserver_length_type(&base, params, 8000),
"TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" => "VARBINARY(MAX)".to_string(),
"DATE" => "DATE".to_string(),
"TIME" => sqlserver_temporal_type("TIME", params),
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
"YEAR" => "SMALLINT".to_string(),
"GEOMETRY" => "GEOMETRY".to_string(),
_ => return None,
},
DialectKind::Postgres => match base.as_str() {
"BOOL" | "BOOLEAN" => "BIT".to_string(),
"SMALLINT" | "INT2" | "SMALLSERIAL" => "SMALLINT".to_string(),
"INTEGER" | "INT" | "INT4" | "SERIAL" => "INT".to_string(),
"BIGINT" | "INT8" | "BIGSERIAL" => "BIGINT".to_string(),
"NUMERIC" | "DECIMAL" => sqlserver_decimal_type(params),
"REAL" | "FLOAT4" => "REAL".to_string(),
"DOUBLE PRECISION" | "FLOAT8" => "FLOAT".to_string(),
"CHAR" | "CHARACTER" | "VARCHAR" | "CHARACTER VARYING" => {
sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000)
}
"TEXT" | "JSON" | "JSONB" | "ARRAY" => "NVARCHAR(MAX)".to_string(),
"BIT VARYING" | "VARBIT" => "VARBINARY(MAX)".to_string(),
"BYTEA" => "VARBINARY(MAX)".to_string(),
"UUID" => "UNIQUEIDENTIFIER".to_string(),
"DATE" => "DATE".to_string(),
"TIME" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
sqlserver_temporal_type("DATETIMEOFFSET", params)
}
"TIME" | "TIME WITHOUT TIME ZONE" => sqlserver_temporal_type("TIME", params),
"TIMETZ" | "TIME WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
"TIMESTAMP" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => {
sqlserver_temporal_type("DATETIMEOFFSET", params)
}
"TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => sqlserver_temporal_type("DATETIME2", params),
"TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params),
"XML" => "XML".to_string(),
"MONEY" => "MONEY".to_string(),
"INET" | "CIDR" | "MACADDR" | "MACADDR8" | "INTERVAL" => "NVARCHAR(100)".to_string(),
_ if base.ends_with("[]") => "NVARCHAR(MAX)".to_string(),
_ => return None,
},
DialectKind::Sqlite | DialectKind::DuckDb => match base.as_str() {
"BOOL" | "BOOLEAN" => "BIT".to_string(),
"TINYINT" => "TINYINT".to_string(),
"SMALLINT" => "SMALLINT".to_string(),
"INT" | "INTEGER" => "BIGINT".to_string(),
"REAL" | "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" => "FLOAT".to_string(),
"DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params),
"CHAR" | "VARCHAR" => sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000),
"TEXT" | "JSON" => "NVARCHAR(MAX)".to_string(),
"BLOB" | "BYTEA" => "VARBINARY(MAX)".to_string(),
"DATE" => "DATE".to_string(),
"TIME" => sqlserver_temporal_type("TIME", params),
"DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params),
"UUID" => "UNIQUEIDENTIFIER".to_string(),
_ => return None,
},
_ => return None,
};
Some(mapped)
}
/// Rewrite a source column type for `target` database.
///
/// User field mappings must be applied by the caller *before* this function.
pub fn rewrite_column_type(source_type: &str, target: DatabaseType, source_dialect: Option<DialectKind>) -> String {
let profile = profile_for(target);
if target == DatabaseType::SqlServer {
let source = source_dialect.unwrap_or(DialectKind::SqlServer);
if source == DialectKind::SqlServer {
// Same-dialect metadata already contains a valid native type. The
// generic display-width normalizer would otherwise truncate valid
// SQL Server parameters such as VARBINARY(MAX), DATETIME2(7), and
// DATETIMEOFFSET(7).
return source_type.trim().to_string();
}
if let Some(mapped) = rewrite_cross_dialect_to_sqlserver(source_type, source) {
return mapped;
}
}
let (base, params) = split_type_base_params(source_type);
// Profile type_map (includes synthetic BOOL for TINYINT(1) when mapped)
let lookup_key = tinyint1_as_boolean_key(&base, params.as_deref()).unwrap_or(base.as_str());
if let Some(template) = profile.lookup_type(lookup_key) {
return apply_template(template, params.as_deref(), profile.max_varchar_len);
}
// Also try original base if we used BOOL alias miss
if lookup_key != base && profile.lookup_type(&base).is_some() {
if let Some(template) = profile.lookup_type(&base) {
return apply_template(template, params.as_deref(), profile.max_varchar_len);
}
}
// Dialect-kind matrix
if let Some(src) = source_dialect {
let target_kind = DialectKind::from_database_type(target);
if src != target_kind {
let matrix = TypeMappingMatrix::for_dialects(src, target_kind);
let (converted, _) = matrix.convert_type(source_type);
if converted != source_type {
return strip_display_width_if_needed(&converted, &profile);
}
// No matrix rule for this cross-dialect pair, and it's a
// *varying*-length string type reported without a length — e.g.
// Postgres/Kingbase's unbounded `character varying` via
// format_type(). A bare passthrough would be invalid DDL for a
// target that requires an explicit VARCHAR length (MySQL and its
// family: VARCHAR/CHARACTER VARYING with no length is a syntax
// error there). Default to 255, matching the same fallback
// apply_template() already uses when a mapped type's length is
// missing. CHAR/CHARACTER are deliberately excluded: MySQL
// accepts a bare CHAR as CHAR(1), so defaulting it to 255 would
// silently change valid output into a different, wrong length.
// VARCHAR2/NVARCHAR2 are also excluded: they aren't valid MySQL
// type names at all, with or without a length, so defaulting
// their length fixes nothing.
if params.is_none()
&& profile.requires_explicit_varchar_length
&& profile.max_varchar_len.is_some()
&& matches!(base.as_str(), "VARCHAR" | "CHARACTER VARYING" | "NVARCHAR")
{
return format!("{}(255)", source_type.trim());
}
}
}
strip_display_width_if_needed(source_type, &profile)
}
/// Whether the column metadata indicates auto-increment / identity / serial.
pub fn column_is_auto_increment(col: &ColumnInfo) -> bool {
col.extra.as_deref().is_some_and(|extra| {
let lower = extra.to_ascii_lowercase();
lower.contains("auto_increment")
|| lower.contains("autoincrement")
|| lower.contains("identity")
|| lower.contains("serial")
})
}
/// Whether a rewritten type looks integer-like (for PK auto-inc heuristics).
pub fn type_looks_integer(mapped_type: &str) -> bool {
let lower = mapped_type.to_ascii_lowercase();
lower.contains("int")
|| lower.contains("integer")
|| lower.contains("serial")
|| lower.eq_ignore_ascii_case("counter")
|| lower.eq_ignore_ascii_case("byte")
|| lower.starts_with("decimal")
|| lower.starts_with("number")
|| lower.starts_with("numeric")
}
/// Build column type + optional auto-inc contribution using profile only (no DatabaseType branches).
pub fn apply_auto_inc_to_column_def(
profile: &DdlDialectProfile,
quoted_name: &str,
_mapped_type: &str,
col: &ColumnInfo,
is_integer_like: bool,
) -> AutoIncColumnBuild {
let wants_auto = col.is_primary_key
&& (column_is_auto_increment(col)
|| (is_integer_like
&& !matches!(
profile.auto_inc,
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(" AUTOINCREMENT")
)));
match profile.auto_inc {
crate::sql_dialect::ddl_profile::AutoIncSyntax::ReplaceTypeWith(type_name)
if wants_auto && col.is_primary_key =>
{
let mut def = format!("{quoted_name} {type_name}");
if !col.is_nullable {
def.push_str(" NOT NULL");
}
AutoIncColumnBuild::Complete { def, skip_default: true }
}
// SQLite's AUTOINCREMENT changes semantics (sqlite_sequence, no rowid
// reuse), so it must only be emitted for sources that explicitly ask
// for auto-increment; other suffixes keep the implicit PK heuristic.
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(suffix)
if col.is_primary_key && is_integer_like && (wants_auto || suffix != " AUTOINCREMENT") =>
{
AutoIncColumnBuild::AppendSuffix { suffix, skip_default: false, postgres_sequence: false }
}
crate::sql_dialect::ddl_profile::AutoIncSyntax::PostgresSequence if col.is_primary_key && is_integer_like => {
AutoIncColumnBuild::AppendSuffix { suffix: "", skip_default: false, postgres_sequence: true }
}
_ => AutoIncColumnBuild::Normal { skip_default: false },
}
}
#[derive(Debug)]
pub enum AutoIncColumnBuild {
/// Full column definition already built (e.g. COUNTER).
Complete {
def: String,
skip_default: bool,
},
/// Continue normal type def; optionally append suffix; maybe register PG sequence.
AppendSuffix {
suffix: &'static str,
skip_default: bool,
postgres_sequence: bool,
},
Normal {
skip_default: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_rewrites_mysql_types() {
let t = DatabaseType::Access;
let src = Some(DialectKind::Mysql);
assert_eq!(rewrite_column_type("int(11)", t, src), "INTEGER");
assert_eq!(rewrite_column_type("varchar(120)", t, src), "TEXT(120)");
assert_eq!(rewrite_column_type("tinyint(2)", t, src), "BYTE");
assert_eq!(rewrite_column_type("tinyint(1)", t, src), "YESNO");
assert_eq!(rewrite_column_type("datetime", t, src), "DATETIME");
assert_eq!(rewrite_column_type("longtext", t, src), "LONGTEXT");
}
#[test]
fn sqlserver_keeps_identity_path_not_counter() {
let profile = profile_for(DatabaseType::SqlServer);
assert!(matches!(
profile.auto_inc,
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(s) if s.contains("IDENTITY")
));
// MySQL display width is not part of the SQL Server type.
let t = rewrite_column_type("int(11)", DatabaseType::SqlServer, Some(DialectKind::Mysql));
assert_eq!(t, "INT");
}
#[test]
fn sqlserver_rewrites_common_mysql_types() {
let target = DatabaseType::SqlServer;
let source = Some(DialectKind::Mysql);
let cases = [
("tinyint(1)", "BIT"),
("tinyint", "SMALLINT"),
("tinyint unsigned", "TINYINT"),
("tinyint(1) unsigned", "TINYINT"),
("tinyint(1) zerofill", "TINYINT"),
("smallint unsigned", "INT"),
("mediumint unsigned", "INT"),
("int(11) unsigned", "BIGINT"),
("integer zerofill", "BIGINT"),
("bigint unsigned", "DECIMAL(20,0)"),
("double", "FLOAT"),
("decimal(65,30)", "DECIMAL(38,30)"),
("varchar(9000)", "VARCHAR(MAX)"),
("nvarchar(5000)", "NVARCHAR(MAX)"),
("longtext", "NVARCHAR(MAX)"),
("json", "NVARCHAR(MAX)"),
("longblob", "VARBINARY(MAX)"),
("datetime(9)", "DATETIME2(7)"),
];
for (input, expected) in cases {
assert_eq!(rewrite_column_type(input, target, source), expected, "{input}");
}
}
#[test]
fn sqlserver_rewrites_common_postgres_types_without_rewriting_its_own_timestamp() {
let target = DatabaseType::SqlServer;
let postgres = Some(DialectKind::Postgres);
let cases = [
("serial", "INT"),
("boolean", "BIT"),
("character varying(64)", "NVARCHAR(64)"),
("text", "NVARCHAR(MAX)"),
("bytea", "VARBINARY(MAX)"),
("uuid", "UNIQUEIDENTIFIER"),
("time(4) with time zone", "DATETIMEOFFSET(4)"),
("timestamp(6) without time zone", "DATETIME2(6)"),
("timestamp(6) with time zone", "DATETIMEOFFSET(6)"),
("jsonb", "NVARCHAR(MAX)"),
];
for (input, expected) in cases {
assert_eq!(rewrite_column_type(input, target, postgres), expected, "{input}");
}
assert_eq!(rewrite_column_type("timestamp", target, Some(DialectKind::SqlServer)), "timestamp");
assert_eq!(rewrite_column_type("varbinary(max)", target, None), "varbinary(max)");
assert_eq!(rewrite_column_type("datetime2(7)", target, Some(DialectKind::SqlServer)), "datetime2(7)");
}
#[test]
fn mysql_keeps_display_width() {
let t = rewrite_column_type("int(11)", DatabaseType::Mysql, None);
assert_eq!(t, "int(11)");
}
#[test]
fn mysql_defaults_missing_length_on_cross_dialect_varchar() {
// Postgres/Kingbase report an unbounded varchar column as a bare
// `character varying` (no length) via format_type(). MySQL's
// VARCHAR/CHARACTER VARYING requires an explicit length, so passing
// that through verbatim is a syntax error (issue #8011).
let source = Some(DialectKind::Postgres);
assert_eq!(rewrite_column_type("character varying", DatabaseType::Mysql, source), "character varying(255)");
assert_eq!(rewrite_column_type("varchar", DatabaseType::Mysql, source), "varchar(255)");
}
#[test]
fn mysql_leaves_bare_char_untouched() {
// Unlike VARCHAR, a bare CHAR/CHARACTER is already valid MySQL
// syntax (implicitly CHAR(1)); defaulting it to 255 would silently
// change correct output into a different, wrong length.
let source = Some(DialectKind::Postgres);
assert_eq!(rewrite_column_type("character", DatabaseType::Mysql, source), "character");
assert_eq!(rewrite_column_type("char", DatabaseType::Mysql, source), "char");
}
#[test]
fn mysql_keeps_explicit_length_on_cross_dialect_varchar() {
let source = Some(DialectKind::Postgres);
assert_eq!(
rewrite_column_type("character varying(120)", DatabaseType::Mysql, source),
"character varying(120)"
);
}
#[test]
fn mysql_same_dialect_varchar_is_untouched_regardless_of_length() {
// Same-dialect metadata (or unknown source dialect) must never be
// rewritten — only a genuine cross-dialect gap should trigger the
// missing-length default.
assert_eq!(rewrite_column_type("varchar(255)", DatabaseType::Mysql, Some(DialectKind::Mysql)), "varchar(255)");
assert_eq!(rewrite_column_type("varchar(255)", DatabaseType::Mysql, None), "varchar(255)");
}
#[test]
fn normalize_len_params_strips_oracle_char_unit() {
assert_eq!(normalize_len_params("(6 CHAR)"), "(6)");
assert_eq!(normalize_len_params("(50 char)"), "(50)");
assert_eq!(normalize_len_params("(50 CHAR)"), "(50)");
assert_eq!(normalize_len_params("(20 BYTE)"), "(20)");
assert_eq!(normalize_len_params("(20 byte)"), "(20)");
}
#[test]
fn normalize_len_params_leaves_non_unit_params_untouched() {
// Plain length without a unit qualifier.
assert_eq!(normalize_len_params("(50)"), "(50)");
// Precision/scale must never be disturbed.
assert_eq!(normalize_len_params("(10,2)"), "(10,2)");
// Enum/set lists must never be disturbed.
assert_eq!(normalize_len_params("('a','b')"), "('a','b')");
// Non-numeric head (defensive) is left alone.
assert_eq!(normalize_len_params("(MAX CHAR)"), "(MAX CHAR)");
}
#[test]
fn normalize_len_params_handles_malformed_input() {
assert_eq!(normalize_len_params("varchar(50)"), "varchar(50)");
assert_eq!(normalize_len_params("no-parens"), "no-parens");
assert_eq!(normalize_len_params("(unclosed"), "(unclosed");
assert_eq!(normalize_len_params(")"), ")");
}
}
+78
View File
@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
pub const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
pub const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
pub const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname";
#[derive(Debug, Clone, Copy)]
pub struct TableSelectSqlOptions<'a> {
pub database_type: Option<DatabaseType>,
pub schema: Option<&'a str>,
pub table_name: &'a str,
pub columns: &'a [String],
pub order_columns: &'a [String],
pub limit: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableDataSelectSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier_quote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub table_name: String,
/// Doris / StarRocks multi-catalog: when set to a non-`internal` catalog,
/// the FROM clause is prefixed with the catalog
/// (`<catalog>.<database>.<table>`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
/// Doris / StarRocks multi-catalog: the database under the external
/// catalog, used as the middle segment of the 3-part qualified name when
/// `schema` is absent (Doris/StarRocks have no separate schema concept).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table_type: Option<String>,
#[serde(default)]
pub primary_keys: Vec<String>,
#[serde(default)]
pub columns: Vec<String>,
/// Database type names parallel to `columns`. Table-data callers can use
/// these to request bounded server-side projections for variable-length
/// values without changing arbitrary query execution.
#[serde(default)]
pub column_types: Vec<String>,
/// Maximum characters (text-like values) or bytes (binary values) kept in
/// a table-data preview. Omitted for exports and ordinary SQL queries.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub large_value_preview_size: Option<usize>,
#[serde(default)]
pub fallback_order_columns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<usize>,
/// The JDBC driver advances the ResultSet before collecting the page.
/// This keeps database-specific identifier rules while avoiding SQL
/// pagination syntax that the selected driver does not support.
#[serde(default)]
pub use_driver_row_offset: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub where_input: Option<String>,
/// Time-series quick-open ergonomics: inject a rolling `time` window
/// when no WHERE was supplied. Opt-in so sampling and export callers
/// keep the historical unbounded scan semantics.
#[serde(default)]
pub inject_default_time_series_where: bool,
#[serde(default)]
pub include_row_id: bool,
}