feat(schema-diff): preserve dialect-specific DDL semantics
This commit is contained in:
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, ®istry);
|
||||
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", ®istry);
|
||||
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", ®istry);
|
||||
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", ®istry);
|
||||
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");
|
||||
}
|
||||
}
|
||||
+431
@@ -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
|
||||
}
|
||||
]
|
||||
+30
@@ -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
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
+103
@@ -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
|
||||
}
|
||||
]
|
||||
+30
@@ -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
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
]
|
||||
+30
@@ -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
|
||||
}
|
||||
+30
@@ -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)");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user