feat(schema-diff): preserve dialect-specific DDL semantics

This commit is contained in:
dbin0123 authored and GitHub committed 2026-07-29 03:47:19 +08:00
1 parent 820b8cd26f
commit 002a2c85c0
146 files changed
+36532 -1375

No files matched your search

@@ -0,0 +1,750 @@
//! 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,
}
/// 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,
/// 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,
/// 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 trigger_template: TriggerTemplate,
pub rename_column: RenameColumnSyntax,
/// 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>,
/// 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()
}
}
}
}
/// 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 RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};";
const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};";
// ---------------------------------------------------------------------------
// 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,
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,
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,
trigger_template: TriggerTemplate::MysqlStyle,
rename_column: RenameColumnSyntax::MysqlChangeColumn,
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,
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,
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,
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,
trigger_template: TriggerTemplate::PostgresStyle,
rename_column: RenameColumnSyntax::RenameColumn,
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),
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,
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,
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,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
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,
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::DoubleQuote,
auto_inc: AutoIncSyntax::Suffix(" IDENTITY(1,1)"),
supports_display_width: 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,
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,
trigger_template: TriggerTemplate::SqlServerStyle,
rename_column: RenameColumnSyntax::SqlServerSpRename,
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: 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(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,
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::None,
supports_display_width: 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,
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,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
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,
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,
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,
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,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
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,
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,
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,
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,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::AlterColumnRenameTo,
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,
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,
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,
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,
trigger_template: TriggerTemplate::GenericRowBody,
rename_column: RenameColumnSyntax::RenameColumn,
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,
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
}
/// 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),
// PostgreSQL family
Postgres | Redshift | Gaussdb | Kingbase | Highgo | Vastbase | OpenGauss | Kwdb | Firebird | Vertica
| Exasol | Uxdb => postgres_family(db_type),
// Oracle family
Oracle | Dameng | 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(),
DuckDb | Questdb | SapHana | Teradata | Snowflake | Trino | PrestoSql | Hive | Spark | Db2 | Informix
| Bigquery | Kylin | Oscar | Tdengine | Iotdb | Databricks | Jdbc => conservative_ansi(db_type),
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | Elasticsearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra | Etcd
| ZooKeeper | Nacos | InfluxDb | MessageQueue | 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 / idempotent flags.
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::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;");
}
}
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,429 @@
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> {
let kind = DialectKind::from_database_type(db_type);
let label = kind.label();
let db_name = format!("{db_type:?}").to_ascii_lowercase();
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,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,431 @@
---
source: crates/dbx-core/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",
"if_not_exists",
"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-core/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-core/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-core/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-core/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-core/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-core/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-core/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-core/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-core/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-core/src/sql_dialect/descriptor_snapshots.rs
assertion_line: 25
expression: desc
---
{
"dialect": "sql_server",
"flags": 3086983151,
"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
}
@@ -0,0 +1,243 @@
//! 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())
}
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
}
}
/// 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);
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);
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);
}
}
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("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);
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 }
}
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(suffix) if col.is_primary_key && is_integer_like => {
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")
));
// No Access type map: display width stripped
let t = rewrite_column_type("int(11)", DatabaseType::SqlServer, Some(DialectKind::Mysql));
assert_eq!(t, "INT");
}
#[test]
fn mysql_keeps_display_width() {
let t = rewrite_column_type("int(11)", DatabaseType::Mysql, None);
assert_eq!(t, "int(11)");
}
}