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