diff --git a/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java b/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java index 3f9f056a2..12aa4f676 100644 --- a/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java +++ b/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java @@ -2,8 +2,29 @@ package com.dbx.agent.oscar; import com.dbx.agent.ConfiguredJdbcAgent; import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JdbcIdentifiers; import com.dbx.agent.MultiSessionJsonRpcServer; +import com.dbx.agent.ObjectSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.Locale; +/** + * 神通 OSCAR v7 agent。 + * + *

神通是 Oracle 兼容的国产数据库,系统视图(ALL_VIEWS/ALL_SOURCE/ALL_TRIGGERS/ALL_SEQUENCES 等) + * 与 Oracle 基本一致,但有两处关键差异(实测 v7.0.8,见 issue #5505): + *

+ * + *

建表/表结构编辑由 DBX 核心的 Oracle 方言 SQL 生成器负责(神通实测支持 ALTER TABLE + * ADD/MODIFY/DROP/RENAME COLUMN、DROP/ADD PRIMARY KEY、COMMENT ON、CREATE/DROP INDEX), + * 本 agent 仅需提供元数据(继承 ConfiguredJdbcAgent 的标准 JDBC metadata)与对象源码。 + */ public final class OscarAgent extends ConfiguredJdbcAgent { public static final JdbcAgentProfile OSCAR_PROFILE = new JdbcAgentProfile( "com.oscar.Driver", @@ -18,4 +39,148 @@ public final class OscarAgent extends ConfiguredJdbcAgent { public static void main(String[] args) { new MultiSessionJsonRpcServer(OscarAgent::new).run(); } + + /** + * 神通用 {@code SET SEARCH_PATH TO "schema"} 切换默认 schema(PG 风格), + * 不支持 Oracle 的 {@code ALTER SESSION SET CURRENT_SCHEMA} 或 Dameng 的 {@code SET SCHEMA "x"}。 + * 切换后 ALL_* 视图仍按 OWNER 正确过滤,不影响 owner 语义。 + */ + @Override + public String setSchemaSQL(String schema) { + if (schema == null || schema.trim().isEmpty()) { + return ""; + } + return "SET SEARCH_PATH TO " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + /** + * 读取视图/过程/函数/触发器/序列等对象的 DDL 源码。 + * + *

神通不支持 DBMS_METADATA.GET_DDL,按对象类型走对应系统视图: + *

+ */ + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + String type = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT); + return unchecked(() -> { + String source = switch (type) { + case "VIEW" -> readViewSource(schema, name); + case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> readSourceText(schema, name, type); + case "TRIGGER" -> readTriggerSource(schema, name); + case "SEQUENCE" -> readSequenceSource(schema, name); + // 神通 v7 无物化视图系统视图(ALL_MVIEWS 不存在);返回空源码,标记不可编辑,避免 UI 误判为可改。 + case "MATERIALIZED_VIEW" -> ""; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + boolean editable = !"MATERIALIZED_VIEW".equals(type); + return new ObjectSource(name, objectType, schema, source, editable); + }); + } + + private String readViewSource(String schema, String name) throws Exception { + // ALL_VIEWS.TEXT 已包含完整视图定义(神通实测返回带 schema 限定的 SELECT 语句)。 + String sql = "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?"; + return scalarText(sql, schema, name); + } + + private String readSourceText(String schema, String name, String type) throws Exception { + // ALL_SOURCE 按 LINE 存储过程/函数/包/类型的源码行,需按 LINE 排序拼接。 + // 神通与 Oracle 的关键差异(实测 v7.0.8):function 也以 TYPE='PROCEDURE' 存储(不区分 FUNCTION), + // 包体随包一起存为 TYPE='PACKAGE',类型体随类型存为 TYPE='TYPE'。故按 objectType 归并到实际 TYPE 值。 + String sourceType = switch (type) { + case "PROCEDURE", "FUNCTION" -> "PROCEDURE"; + case "PACKAGE", "PACKAGE_BODY" -> "PACKAGE"; + case "TYPE", "TYPE_BODY" -> "TYPE"; + default -> type; + }; + String sql = "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? AND TYPE = ? ORDER BY LINE"; + StringBuilder sb = new StringBuilder(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + stmt.setString(3, sourceType); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String line = rs.getString(1); + if (line != null) { + sb.append(line); + } + } + } + } + return sb.toString(); + } + + private String readTriggerSource(String schema, String name) throws Exception { + String sql = "SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = ? AND TRIGGER_NAME = ?"; + return scalarText(sql, schema, name); + } + + private String readSequenceSource(String schema, String name) throws Exception { + // 神通无 DBMS_METADATA,序列源码由 ALL_SEQUENCES 元数据重建为 CREATE SEQUENCE 语句。 + String sql = "SELECT MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE " + + "FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = ? AND SEQUENCE_NAME = ?"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ""; + } + String ref = JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(name); + StringBuilder sb = new StringBuilder("CREATE SEQUENCE ").append(ref); + String minValue = rs.getString("MIN_VALUE"); + String maxValue = rs.getString("MAX_VALUE"); + String increment = rs.getString("INCREMENT_BY"); + if (increment != null && !"1".equals(increment.trim())) { + sb.append(" INCREMENT BY ").append(increment.trim()); + } + if (minValue != null && !"1".equals(minValue.trim())) { + sb.append(" MINVALUE ").append(minValue.trim()); + } + if (maxValue != null) { + sb.append(" MAXVALUE ").append(maxValue.trim()); + } + String cache = rs.getString("CACHE_SIZE"); + if (cache != null && !"0".equals(cache.trim())) { + sb.append(" CACHE ").append(cache.trim()); + } else { + sb.append(" NOCACHE"); + } + if ("Y".equalsIgnoreCase(rs.getString("CYCLE_FLAG"))) { + sb.append(" CYCLE"); + } else { + sb.append(" NOCYCLE"); + } + if ("Y".equalsIgnoreCase(rs.getString("ORDER_FLAG"))) { + sb.append(" ORDER"); + } else { + sb.append(" NOORDER"); + } + sb.append(";"); + return sb.toString(); + } + } + } + + /** 读取单行单列文本值(视图/触发器源码),空结果返回空串。 */ + private String scalarText(String sql, String schema, String name) throws Exception { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ""; + } + String value = rs.getString(1); + return value == null ? "" : value; + } + } + } } diff --git a/apps/desktop/src/lib/table/tableStructureCapabilities.ts b/apps/desktop/src/lib/table/tableStructureCapabilities.ts index 26f20db81..e9dcd9922 100644 --- a/apps/desktop/src/lib/table/tableStructureCapabilities.ts +++ b/apps/desktop/src/lib/table/tableStructureCapabilities.ts @@ -315,7 +315,7 @@ const capabilityByType: Partial starrocks: mysqlCapabilities, goldendb: mysqlCapabilities, sundb: mysqlCapabilities, - oscar: unsupportedCapabilities, + oscar: damengCapabilities, databend: mysqlCapabilities, gbase: gbaseCapabilities, postgres: postgresCapabilities, diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index bb3b06e97..22620dff6 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -1655,11 +1655,11 @@ "queryExecution": true, "metadataBrowse": true, "objectBrowser": true, - "objectSource": false, + "objectSource": true, "schemaSearch": true, "diagram": false, "tableDataEdit": true, - "tableStructureEdit": false, + "tableStructureEdit": true, "tableImport": false, "dataTransfer": false, "sqlFileExecution": true, diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 1ccb597f4..24fba5119 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -771,7 +771,8 @@ fn is_postgres_like_rename(database_type: DatabaseType) -> bool { } fn is_oracle_like_rename(database_type: DatabaseType) -> bool { - matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng) + // 神通 Oscar 实测支持 `ALTER TABLE old RENAME TO new`(PG 风格,与 Dameng/Oracle 一致)。 + matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::Oscar) } fn is_postgres_like_structure_copy(database_type: DatabaseType) -> bool { @@ -1933,6 +1934,22 @@ mod tests { ); } + #[test] + fn builds_oscar_table_rename_sql() { + // 神通实测支持 `ALTER TABLE old RENAME TO new`(issue #5505 探测)。 + assert_eq!( + build_rename_object_sql(RenameObjectSqlOptions { + database_type: Some(DatabaseType::Oscar), + object_type: DatabaseObjectType::Table, + schema: Some("SYSDBA".to_string()), + old_name: "OLD_USERS".to_string(), + new_name: "NEW_USERS".to_string(), + }) + .unwrap(), + "ALTER TABLE \"SYSDBA\".\"OLD_USERS\" RENAME TO \"NEW_USERS\";" + ); + } + #[test] fn rejects_unsupported_direct_routine_renames() { assert!(!supports_object_rename(Some(DatabaseType::Oracle), DatabaseObjectType::Function)); diff --git a/crates/dbx-core/src/table_structure_sql/column_alter.rs b/crates/dbx-core/src/table_structure_sql/column_alter.rs index ad787bc8d..2bfdfd65c 100644 --- a/crates/dbx-core/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-core/src/table_structure_sql/column_alter.rs @@ -96,6 +96,7 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta statements.extend(build_oracle_like_existing_column_sql(dialect, &table, &options.column)) } } + StructureDialect::Oscar => statements.extend(build_oscar_existing_column_sql(dialect, &table, &options.column)), StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, &options.column)), StructureDialect::ClickHouse => { statements.extend(build_clickhouse_existing_column_sql(&table, &options.column, "")) @@ -509,6 +510,64 @@ pub(super) fn build_iris_existing_column_sql(table: &str, column: &EditableStruc statements } +/// 神通 Oscar 的 ALTER 已有列 SQL。 +/// +/// 神通的 `ALTER TABLE ... MODIFY` 语法与 Oracle 有重要差异(实测 v7.0.8): +/// 带圆括号的 `MODIFY (col TYPE [DEFAULT ...])` 不允许出现 `NULL`/`NOT NULL`,否则 +/// parser 报 `syntax error at or near "NULL"`。要改可空性必须用不带括号、不带类型的 +/// `MODIFY col NOT NULL` / `MODIFY col NULL` 单独一条。因此类型/默认值变更与可空性 +/// 变更需拆成两条语句,而不能像 Oracle/Dameng 那样合并进单个 `MODIFY (...)`。 +pub(super) fn build_oscar_existing_column_sql( + dialect: StructureDialect, + table: &str, + column: &EditableStructureColumn, +) -> Vec { + let Some(original) = &column.original else { + return Vec::new(); + }; + let mut statements = Vec::new(); + let mut current_name = original.name.clone(); + if column.name != original.name { + statements.push(format!( + "ALTER TABLE {table} RENAME COLUMN {} TO {};", + quote_ident(dialect, &original.name), + quote_ident(dialect, &column.name) + )); + current_name = column.name.clone(); + } + let type_changed = column.data_type.trim() != original.data_type.trim(); + let nullable_changed = column.is_nullable != original.is_nullable; + let default_changed = normalize_default(Some(&column.default_value)) != original_default(column); + + // 类型或默认值变更:带括号的 MODIFY 只允许 "col TYPE [DEFAULT ...]",不含 NULL/NOT NULL。 + if type_changed || default_changed { + let data_type = column_data_type(dialect, column); + let mut parts = vec![quote_ident(dialect, ¤t_name), data_type]; + let default_value = normalize_default(Some(&column.default_value)); + if !default_value.is_empty() { + parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value))); + } else if default_changed { + // User cleared the default — explicitly drop it. MODIFY (col DEFAULT NULL) 合法。 + parts.push("DEFAULT NULL".to_string()); + } + statements.push(format!("ALTER TABLE {table} MODIFY ({});", parts.join(" "))); + } + + // 可空性变更:神通要求不带括号、不带类型的单独 MODIFY,否则 parser 报错。 + if nullable_changed { + let nullability = if column.is_nullable { "NULL" } else { "NOT NULL" }; + statements.push(format!("ALTER TABLE {table} MODIFY {} {};", quote_ident(dialect, ¤t_name), nullability)); + } + + if clean(&column.comment) != original_comment(column) { + let comment_value = + if clean(&column.comment).is_empty() { "NULL".to_string() } else { quote_string(&clean(&column.comment)) }; + statements + .push(format!("COMMENT ON COLUMN {table}.{} IS {comment_value};", quote_ident(dialect, ¤t_name))); + } + statements +} + pub(super) fn build_sqlserver_existing_column_sql( table: &str, column: &EditableStructureColumn, diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index 3d7c7913b..58b7c020b 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -323,7 +323,7 @@ pub(super) fn is_temporal_precision_type(dialect: StructureDialect, base_type: & | "timestamp with time zone" ), StructureDialect::SqlServer => matches!(normalized.as_str(), "time" | "datetime2" | "datetimeoffset"), - StructureDialect::Oracle | StructureDialect::Dameng => { + StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => { matches!(normalized.as_str(), "timestamp" | "timestamp with time zone" | "timestamp with local time zone") } _ => false, @@ -334,7 +334,11 @@ pub(super) fn is_valid_temporal_precision(params: &str, dialect: StructureDialec let Ok(value) = params.parse::() else { return false; }; - let max = if matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng) { 9 } else { 6 }; + let max = if matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar) { + 9 + } else { + 6 + }; value <= max && params == value.to_string() } diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index d75722436..941a7da6b 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -1,9 +1,9 @@ use super::column_alter::{ build_clickhouse_existing_column_sql, build_doris_existing_column_sql, build_h2_existing_column_sql, build_informix_existing_column_sql, build_iris_existing_column_sql, build_mysql_existing_column_sql, - build_oracle_like_existing_column_sql, build_postgres_existing_column_sql, build_questdb_existing_column_sql, - build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, build_xugu_existing_column_sql, - has_column_extra_change, has_existing_column_attribute_change, + build_oracle_like_existing_column_sql, build_oscar_existing_column_sql, build_postgres_existing_column_sql, + build_questdb_existing_column_sql, build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, + build_xugu_existing_column_sql, has_column_extra_change, has_existing_column_attribute_change, }; use super::column_format::{ column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type, @@ -186,6 +186,8 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu statements.extend(build_oracle_like_existing_column_sql(dialect, &table, column)) } } + // 神通 MODIFY 语法与 Oracle 有差异(NULL/NOT NULL 须单独一条),用专属实现。 + StructureDialect::Oscar => statements.extend(build_oscar_existing_column_sql(dialect, &table, column)), StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, column)), StructureDialect::ClickHouse => statements.extend(build_clickhouse_existing_column_sql( &table, @@ -301,7 +303,10 @@ fn drop_primary_key_statement( let pk_name = format!("{}_pkey", clean(raw_table)); Some(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, &pk_name))) } - StructureDialect::Mysql | StructureDialect::Dameng => Some(format!("ALTER TABLE {table} DROP PRIMARY KEY;")), + // 神通 Oscar 实测支持 `ALTER TABLE ... DROP PRIMARY KEY`(与 Dameng/MySQL 一致)。 + StructureDialect::Mysql | StructureDialect::Dameng | StructureDialect::Oscar => { + Some(format!("ALTER TABLE {table} DROP PRIMARY KEY;")) + } _ => None, } } @@ -362,7 +367,10 @@ pub(super) fn build_add_column_sql( vec![format!("ALTER TABLE {table} {add_keyword} {definition}{position_clause};")] }; if supports_comments - && matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng) + && matches!( + dialect, + StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar + ) && !clean(&column.comment).is_empty() { statements.push(format!( diff --git a/crates/dbx-core/src/table_structure_sql/comments.rs b/crates/dbx-core/src/table_structure_sql/comments.rs index ede1f2357..74f7a8ff9 100644 --- a/crates/dbx-core/src/table_structure_sql/comments.rs +++ b/crates/dbx-core/src/table_structure_sql/comments.rs @@ -23,7 +23,11 @@ pub(super) fn build_table_comment_sql(options: &TableStructureSqlOptions, warnin StructureDialect::Mysql => { vec![format!("ALTER TABLE {table} COMMENT = {quoted};")] } - StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::H2 => { + StructureDialect::Postgres + | StructureDialect::Oracle + | StructureDialect::Dameng + | StructureDialect::Oscar + | StructureDialect::H2 => { vec![format!("COMMENT ON TABLE {table} IS {quoted};")] } StructureDialect::ClickHouse => { diff --git a/crates/dbx-core/src/table_structure_sql/create_table.rs b/crates/dbx-core/src/table_structure_sql/create_table.rs index 198955096..01222b9ad 100644 --- a/crates/dbx-core/src/table_structure_sql/create_table.rs +++ b/crates/dbx-core/src/table_structure_sql/create_table.rs @@ -102,7 +102,11 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr } } else if matches!( dialect, - StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::H2 + StructureDialect::Postgres + | StructureDialect::Oracle + | StructureDialect::Dameng + | StructureDialect::Oscar + | StructureDialect::H2 ) { statements.push(format!("COMMENT ON TABLE {table} IS {};", quote_string(&table_comment))); } else if dialect == StructureDialect::ClickHouse { @@ -121,7 +125,11 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr if capabilities.comment && matches!( dialect, - StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::H2 + StructureDialect::Postgres + | StructureDialect::Oracle + | StructureDialect::Dameng + | StructureDialect::Oscar + | StructureDialect::H2 ) { for column in &active_columns { diff --git a/crates/dbx-core/src/table_structure_sql/dialect.rs b/crates/dbx-core/src/table_structure_sql/dialect.rs index 411f4834e..c2643eea8 100644 --- a/crates/dbx-core/src/table_structure_sql/dialect.rs +++ b/crates/dbx-core/src/table_structure_sql/dialect.rs @@ -11,6 +11,9 @@ pub(super) enum StructureDialect { SqlServer, Oracle, Dameng, + // 神通 Oscar:Oracle 兼容方言,且支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致, + // 不同于 Oracle)。DDL 生成行为与 Dameng 共享分支;单独成 dialect 以保证 label/反向映射准确。 + Oscar, H2, ClickHouse, ManticoreSearch, @@ -212,6 +215,22 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru alter_primary_key: true, ..base }, + // 神通 Oscar v7:Oracle 兼容,实测支持 ALTER TABLE ADD/MODIFY/DROP/RENAME COLUMN、 + // DROP/ADD PRIMARY KEY、COMMENT ON、CREATE/DROP INDEX(见 issue #5505 探测)。 + Some(DatabaseType::Oscar) => TableStructureCapabilities { + dialect: StructureDialect::Oscar, + add_column: true, + drop_column: true, + rename_column: true, + alter_existing_column: true, + comment: true, + create_index: true, + drop_index: true, + rebuild_index: true, + index_type: true, + alter_primary_key: true, + ..base + }, Some(DatabaseType::Iris) => TableStructureCapabilities { dialect: StructureDialect::Oracle, add_column: true, @@ -285,7 +304,7 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru } pub(super) fn is_oracle_like(dialect: StructureDialect) -> bool { - matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng) + matches!(dialect, StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar) } pub(super) fn database_label(database_type: Option) -> String { @@ -310,6 +329,7 @@ pub(super) fn dialect_label(dialect: StructureDialect) -> String { StructureDialect::SqlServer => "sqlserver", StructureDialect::Oracle => "oracle", StructureDialect::Dameng => "dameng", + StructureDialect::Oscar => "oscar", StructureDialect::H2 => "h2", StructureDialect::ClickHouse => "clickhouse", StructureDialect::ManticoreSearch => "manticoresearch", @@ -331,6 +351,7 @@ pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option Some(DatabaseType::SqlServer), StructureDialect::Oracle => Some(DatabaseType::Oracle), StructureDialect::Dameng => Some(DatabaseType::Dameng), + StructureDialect::Oscar => Some(DatabaseType::Oscar), StructureDialect::H2 => Some(DatabaseType::H2), StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse), StructureDialect::ManticoreSearch => Some(DatabaseType::ManticoreSearch), diff --git a/crates/dbx-core/src/table_structure_sql/indexes.rs b/crates/dbx-core/src/table_structure_sql/indexes.rs index 9525d98d0..7bff0e00a 100644 --- a/crates/dbx-core/src/table_structure_sql/indexes.rs +++ b/crates/dbx-core/src/table_structure_sql/indexes.rs @@ -144,6 +144,7 @@ pub(super) fn build_drop_index_sql( StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng + | StructureDialect::Oscar | StructureDialect::Informix | StructureDialect::Sqlite ) && schema.is_some_and(|schema| !schema.trim().is_empty()) diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 57aa1a519..748622144 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -4109,3 +4109,242 @@ fn mysql_character_column_preserves_charset_collation_on_other_change() { vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci` DEFAULT 'guest';"] ); } + +// ---- Oscar (神通) ---- +// 神通 v7 是 Oracle 兼容方言,且实测支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致, +// 不同于 Oracle)。DDL 生成走 StructureDialect::Oscar,与 Dameng 共享 Oracle-like 分支。 +// 这些测试锁定 issue #5505 的核心场景:建表/改列/主键/索引/注释,防回归。 + +#[test] +fn oscar_create_table_with_primary_key_and_comments() { + let mut id = column("ID"); + id.data_type = "NUMBER(10)".to_string(); + id.is_nullable = false; + id.is_primary_key = true; + let mut name = column("NAME"); + name.data_type = "VARCHAR2(100)".to_string(); + name.is_nullable = false; + name.comment = "name col".to_string(); + + let result = build_create_table_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Oscar), + schema: Some("SYSDBA".to_string()), + table_name: "USERS".to_string(), + columns: vec![id, name], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: Some("user table".to_string()), + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert!(result.statements[0].contains("CREATE TABLE \"SYSDBA\".\"USERS\""), "ddl: {}", result.statements[0]); + // Oracle 风格:PK 在表定义末尾单独声明;PK 列省略 NOT NULL(主键隐含),非 PK 非空列显式 NOT NULL。 + assert!(result.statements[0].contains("\"ID\" NUMBER(10),"), "ddl: {}", result.statements[0]); + assert!(result.statements[0].contains("\"NAME\" VARCHAR2(100) NOT NULL,"), "ddl: {}", result.statements[0]); + assert!(result.statements[0].contains("PRIMARY KEY (\"ID\")"), "ddl: {}", result.statements[0]); + assert!( + result.statements.iter().any(|s| s == "COMMENT ON TABLE \"SYSDBA\".\"USERS\" IS 'user table';"), + "comments: {:?}", + result.statements + ); + assert!( + result.statements.iter().any(|s| s == "COMMENT ON COLUMN \"SYSDBA\".\"USERS\".\"NAME\" IS 'name col';"), + "comments: {:?}", + result.statements + ); +} + +#[test] +fn oscar_add_column_with_comment() { + let mut age = column("AGE"); + age.data_type = "NUMBER(3)".to_string(); + age.is_nullable = true; + age.comment = "age col".to_string(); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oscar, + Some("SYSDBA"), + "users", + vec![age], + )); + + assert_eq!(result.warnings, Vec::::new()); + // Oracle 风格:ADD 用圆括号包裹列定义,可空列省略 NULL 关键字。 + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" ADD (\"AGE\" NUMBER(3));", + "COMMENT ON COLUMN \"SYSDBA\".\"users\".\"AGE\" IS 'age col';", + ] + ); +} + +#[test] +fn oscar_alter_existing_column_modify_type_and_nullability() { + let mut name = column("NAME"); + name.data_type = "VARCHAR2(100)".to_string(); + name.is_nullable = false; + name.original = Some(ColumnInfo { + name: "NAME".to_string(), + data_type: "VARCHAR2(50)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oscar, + Some("SYSDBA"), + "users", + vec![name], + )); + + assert_eq!(result.warnings, Vec::::new()); + // 神通 MODIFY 语法差异:类型变更与可空性变更需拆成两条(带括号的 MODIFY 不允许 NULL/NOT NULL)。 + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" MODIFY (\"NAME\" VARCHAR2(100));", + "ALTER TABLE \"SYSDBA\".\"users\" MODIFY \"NAME\" NOT NULL;", + ] + ); +} + +#[test] +fn oscar_alter_only_nullability_emits_single_unparenthesized_modify() { + // 只改可空性(类型不变):应只生成一条不带括号的 MODIFY col NOT NULL,不重复发类型变更。 + let mut name = column("NAME"); + name.data_type = "VARCHAR2(100)".to_string(); + name.is_nullable = false; + name.original = Some(ColumnInfo { + name: "NAME".to_string(), + data_type: "VARCHAR2(100)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oscar, + Some("SYSDBA"), + "users", + vec![name], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE \"SYSDBA\".\"users\" MODIFY \"NAME\" NOT NULL;"]); +} + +#[test] +fn oscar_alter_only_default_keeps_parenthesized_modify() { + // 只改默认值(类型与可空性不变):带括号的 MODIFY 允许 DEFAULT,不触发可空性单独语句。 + let mut name = column("NAME"); + name.data_type = "VARCHAR2(100)".to_string(); + name.is_nullable = true; + name.default_value = "'guest'".to_string(); + name.original = Some(ColumnInfo { + name: "NAME".to_string(), + data_type: "VARCHAR2(100)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oscar, + Some("SYSDBA"), + "users", + vec![name], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE \"SYSDBA\".\"users\" MODIFY (\"NAME\" VARCHAR2(100) DEFAULT 'guest');"] + ); +} + +#[test] +fn oscar_drop_and_readd_primary_key() { + // 神通实测支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致)。 + let mut old_pk = existing_pk_column("id", "INT", true, false); + old_pk.id = "old_id".to_string(); + let mut new_pk = existing_pk_column("code", "VARCHAR(50)", false, true); + new_pk.id = "new_code".to_string(); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oscar, + Some("SYSDBA"), + "users", + vec![old_pk, new_pk], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" DROP PRIMARY KEY;", + "ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"code\");", + ] + ); +} + +#[test] +fn oscar_drop_index_with_schema_qualifier() { + let mut idx = index("DBX_PROBE_IDX", &["NAME"]); + idx.marked_for_drop = true; + idx.original = Some(IndexInfo { + name: "DBX_PROBE_IDX".to_string(), + columns: vec!["NAME".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + }); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Oscar), + schema: Some("SYSDBA".to_string()), + table_name: "users".to_string(), + columns: Vec::new(), + indexes: vec![idx], + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["DROP INDEX \"SYSDBA\".\"DBX_PROBE_IDX\";"]); +} + +#[test] +fn oscar_table_comment_uses_comment_on_table() { + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Oscar), + schema: Some("SYSDBA".to_string()), + table_name: "users".to_string(), + columns: Vec::new(), + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: Some("new comment".to_string()), + original_table_comment: Some("old comment".to_string()), + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["COMMENT ON TABLE \"SYSDBA\".\"users\" IS 'new comment';"]); +} diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index f13b0c72e..2a097aafa 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -7,6 +7,7 @@ pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, t StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng + | StructureDialect::Oscar | StructureDialect::SqlServer | StructureDialect::H2 | StructureDialect::Informix @@ -144,7 +145,7 @@ pub(super) fn is_temporal_type_for_default(dialect: StructureDialect, base_type: normalized.as_str(), "date" | "time" | "datetime" | "datetime2" | "smalldatetime" | "datetimeoffset" ), - StructureDialect::Oracle | StructureDialect::Dameng => matches!( + StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!( normalized.as_str(), "date" | "timestamp" @@ -230,7 +231,7 @@ pub(super) fn is_string_type_for_default(dialect: StructureDialect, base_type: & normalized.as_str(), "char" | "varchar" | "nchar" | "nvarchar" | "text" | "ntext" | "xml" | "uniqueidentifier" | "sysname" ), - StructureDialect::Oracle | StructureDialect::Dameng => matches!( + StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Oscar => matches!( normalized.as_str(), "char" | "nchar" | "varchar2" | "nvarchar2" | "clob" | "nclob" | "long" | "raw" | "long raw" | "bfile" ),