diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index b404966af8..463a347bc7 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -129,6 +129,7 @@ const defaultForm = (): ConnectionForm => ({ redis_cluster_nodes: "", redis_key_separator: ":", etcd_endpoints: "", + gbase_server: "", read_only: false, visible_databases: undefined, }); @@ -388,7 +389,8 @@ const driverProfiles: Record< vertica: { type: "vertica", port: 5433, user: "dbadmin", label: "Vertica", icon: "vertica" }, firebird: { type: "firebird", port: 3050, user: "SYSDBA", label: "Firebird", icon: "firebird" }, exasol: { type: "exasol", port: 8563, user: "sys", label: "Exasol", icon: "exasol" }, - gbase: { type: "gbase", port: 5258, user: "gbasedbt", label: "GBase", icon: "gbase" }, + gbase: { type: "gbase", port: 5258, user: "gbasedbt", label: "GBase 8a", icon: "gbase" }, + gbase8a: { type: "gbase", port: 5258, user: "gbasedbt", label: "GBase 8a", icon: "gbase" }, gbase8s: { type: "gbase", port: 9088, user: "gbasedbt", label: "GBase 8s", icon: "gbase" }, opengauss: { type: "opengauss", @@ -545,7 +547,7 @@ function switchOceanbaseMode(mode: "mysql" | "oracle") { resetTestState(); } -function switchGbaseProfile(profile: "gbase" | "gbase8s") { +function switchGbaseProfile(profile: "gbase8a" | "gbase8s") { applyProfile(profile, false); selectedType.value = "gbase"; resetTestState(); @@ -603,7 +605,7 @@ watch( if (profile === "oceanbase") { oceanbaseSubMode.value = config.driver_profile === "oceanbase-oracle" ? "oracle" : "mysql"; } - if (profile === "gbase8s") { + if (profile === "gbase8a" || profile === "gbase8s") { selectedType.value = "gbase"; } mongoUseUrl.value = !!config.connection_string; @@ -1590,7 +1592,7 @@ function applyConnectionPrefill(draft: ConnectionDeepLinkDraft) { oceanbaseSubMode.value = "oracle"; selectedType.value = "oceanbase"; } - if (draft.driverProfile === "gbase8s") { + if (draft.driverProfile === "gbase8a" || draft.driverProfile === "gbase8s") { selectedType.value = "gbase"; } customDriverName.value = isCustomCompatibleProfile() ? draft.driverLabel : ""; @@ -2184,7 +2186,7 @@ function openExternalUrl(url: string) {
- +
@@ -2595,6 +2597,11 @@ function openExternalUrl(url: string) { +
+ + +
+
diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 650f23f78c..469593e6c6 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -66,6 +66,7 @@ const assetIcons: Record = { firebird: "firebird.webp", exasol: "exasol.webp", gbase: "gbase.webp", + gbase8a: "gbase.webp", gbase8s: "gbase.webp", tdsql: "tdsql.webp", polardb: "polardb.webp", diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index a04bb7aa03..1c042bba4f 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -643,6 +643,11 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); {{ t(queryExecutionLabelKey(activeTab)) }} · {{ queryRunningElapsedSeconds }}s
+
{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}
diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index a1b89739e5..9a713e666a 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -47,7 +47,7 @@ import * as api from "@/lib/api"; import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database"; import { isSchemaAware } from "@/lib/databaseCapabilities"; import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/databaseFeatureSupport"; -import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { buildTableSelectSql } from "@/lib/tableSelectSql"; import { buildDropObjectSql, buildDuplicateTableStructureSql, buildEmptyTableSql, buildTruncateTableSql, type TableAdminSqlOptions } from "@/lib/dbAdminSql"; import { useToast } from "@/composables/useToast"; @@ -106,6 +106,7 @@ const sourceError = ref(""); const sourceRow = ref(null); const sourceEditing = ref(false); const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type); +const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type); const sourceDraft = ref(""); const sourceSaving = ref(false); const sourceSaveError = ref(""); @@ -144,7 +145,7 @@ const procedureCount = computed(() => rows.value.filter((row) => row.type === "P const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); const sequenceCount = computed(() => rows.value.filter((row) => row.type === "SEQUENCE").length); const packageCount = computed(() => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length); -const canOpenStructureEditor = computed(() => supportsTableStructureEditing(effectiveDatabaseType.value)); +const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tableStructureDatabaseType.value)); const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value)); const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value)); const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value)); diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 446288ad79..1ce34a6cea 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -326,7 +326,9 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: if (!dbNode) return; const targetSchema = "schema" in target ? target.schema : undefined; const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0; - const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesTreeSchemaMode(config.db_type) && !connectionUsesDatabaseObjectTreeMode(config); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + const usesSchemaTree = usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config); + const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesSchemaTree; if (!force && databaseChildrenLoaded && !shouldLoadSchemaTables) return; // Load database contents @@ -335,7 +337,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: if (force || !databaseChildrenLoaded) { await store.loadSqlServerDatabaseObjects(connId, target.database, loadOptions); } - } else if (usesTreeSchemaMode(config.db_type) && !connectionUsesDatabaseObjectTreeMode(config)) { + } else if (usesSchemaTree) { if (force || !databaseChildrenLoaded) { await store.loadSchemas(connId, target.database, loadOptions); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index e3d3e534fe..cb4a101f08 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -91,7 +91,7 @@ import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor"; import { buildViewDdl } from "@/lib/viewDdl"; import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities"; -import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { hexToRgba } from "@/lib/color"; import { focusSidebarRenameInput } from "@/lib/sidebarRenameFocus"; import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext"; @@ -164,6 +164,10 @@ function currentDatabaseType(): DatabaseType | undefined { return props.node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined; } +function currentTableStructureDatabaseType(): DatabaseType | undefined { + return props.node.connectionId ? tableStructureDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined; +} + function rawDatabaseType(): DatabaseType | undefined { return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined; } @@ -172,6 +176,10 @@ function databaseTypeForNode(node: TreeNode): DatabaseType | undefined { return node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined; } +function tableStructureDatabaseTypeForNode(node: TreeNode): DatabaseType | undefined { + return node.connectionId ? tableStructureDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined; +} + function hasNodeDatabaseContext(node: TreeNode): node is TreeNode & { connectionId: string; database: string } { return !!node.connectionId && hasTreeNodeDatabaseContext(node); } @@ -361,9 +369,10 @@ async function toggle() { queryStore.updateSql(tab, node.label); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = connectionStore.getConfig(node.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); if (config?.db_type === "sqlserver") { await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database); - } else if (usesTreeSchemaMode(config?.db_type) && !connectionUsesDatabaseObjectTreeMode(config)) { + } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { await connectionStore.loadSchemas(node.connectionId, node.database); } else { await connectionStore.loadTables(node.connectionId, node.database); @@ -701,6 +710,20 @@ async function openData() { return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema); })(); console.info("[DBX][openData:tab-created]", { traceId, tabId, elapsed: elapsed() }); + + // Cancel any previous execution on this tab before starting a new one + const existingTab = queryStore.tabs.find((t) => t.id === tabId); + if (existingTab?.isExecuting && existingTab.executionId) { + await queryStore.cancelTabExecution(tabId); + } + + const openDataId = uuid(); + // Clear previous result so DataGrid doesn't show its internal loading overlay (without stop button) + const tab = queryStore.tabs.find((t) => t.id === tabId); + if (tab) { + tab.result = undefined; + tab.results = undefined; + } queryStore.setTableMeta(tabId, { schema: tableSchema, tableName: node.label, @@ -708,11 +731,15 @@ async function openData() { columns: [], primaryKeys: [], }); - queryStore.setExecuting(tabId, true); + queryStore.setExecutingWithId(tabId, openDataId); + + // Helper to check if this openData call is still active (not superseded by a newer click) + const isActive = () => queryStore.tabs.find((t) => t.id === tabId)?.executionId === openDataId; try { console.info("[DBX][openData:ensure-connected:start]", { traceId, elapsed: elapsed() }); await connectionStore.ensureConnected(node.connectionId); + if (!isActive()) return; console.info("[DBX][openData:ensure-connected:done]", { traceId, elapsed: elapsed() }); if (!config) throw new Error("Connection config not found"); @@ -730,6 +757,7 @@ async function openData() { elapsed: elapsed(), }); columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label); + if (!isActive()) return; primaryKeys = editablePrimaryKeys(effectiveDbType, columns, node.type === "view" ? "VIEW" : "TABLE"); console.info("[DBX][openData:get-columns:done]", { traceId, @@ -748,6 +776,9 @@ async function openData() { console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error }); } + // Check if superseded by a newer openData call + if (!isActive()) return; + const includeRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys); const fallbackOrderColumns = effectiveDbType === "sqlserver" && !primaryKeys.length ? columns.slice(0, 1).map((column) => column.name) : undefined; const sql = await buildTableSelectSql({ @@ -773,6 +804,7 @@ async function openData() { await queryStore.executeTabSql(tabId, sql); console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() }); } catch (e: any) { + if (!isActive()) return; console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e }); queryStore.setErrorResult(tabId, e); } @@ -980,7 +1012,7 @@ function dropObjectSqlOptions(): DropObjectSqlOptions | null { function dropObjectSqlOptionsForNode(node: TreeNode): DropObjectSqlOptions | null { if (node.type !== "view" && node.type !== "procedure" && node.type !== "function") return null; return { - databaseType: databaseTypeForNode(node), + databaseType: tableStructureDatabaseTypeForNode(node), objectType: node.type === "view" ? "VIEW" : node.type === "procedure" ? "PROCEDURE" : "FUNCTION", schema: node.schema, name: node.label, @@ -1421,7 +1453,7 @@ const supportsTruncate = computed(() => { const canCreateTable = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return (props.node.type === "database" || props.node.type === "schema" || props.node.type === "group-tables") && !!props.node.database && supportsTableStructureEditing(effectiveDatabaseTypeForConnection(config)); + return (props.node.type === "database" || props.node.type === "schema" || props.node.type === "group-tables") && !!props.node.database && supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config)); }); const canCreateDatabase = computed(() => { @@ -1446,12 +1478,12 @@ const canDropDatabase = computed(() => { const canCreateSchema = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "database" && usesTreeSchemaMode(config?.db_type) && !connectionUsesDatabaseObjectTreeMode(config); + return props.node.type === "database" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); }); const canDropSchema = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "schema" && usesTreeSchemaMode(config?.db_type) && !connectionUsesDatabaseObjectTreeMode(config); + return props.node.type === "schema" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); }); function tableAdminSqlOptions(): TableAdminSqlOptions { @@ -2325,7 +2357,7 @@ const canOpenTableImport = computed(() => { return props.node.type === "table" && !!props.node.database && supportsTableImport(currentDatabaseType()); }); const canOpenStructureEditor = computed(() => { - return props.node.type === "table" && !!props.node.database && supportsTableStructureEditing(currentDatabaseType()); + return props.node.type === "table" && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType()); }); const canOpenFieldLineage = computed(() => { return props.node.type === "column" && !!props.node.database && !!props.node.tableName && supportsFieldLineage(currentDatabaseType()); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 0ae0e7f14b..a364a47b21 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -24,7 +24,7 @@ import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout"; import { type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/tableStructureEditorSql"; import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities"; import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities"; -import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { buildStructureTargetLabel, combineDataTypeForDatabase, @@ -279,7 +279,7 @@ function onIndexColResize(e: MouseEvent, col: number) { } const connection = computed(() => (props.connectionId ? store.getConfig(props.connectionId) : undefined)); -const databaseType = computed(() => effectiveDatabaseTypeForConnection(connection.value)); +const databaseType = computed(() => tableStructureDatabaseTypeForConnection(connection.value)); const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value)); const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(databaseType.value)); const structureDialect = computed(() => structureCapabilities.value.dialect); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d50ec7f024..408e2e60f8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -167,6 +167,7 @@ driverName: "Driver Name", driverNamePlaceholder: "Vendor or environment name", urlParams: "URL Params", + gbaseServer: "GBASEDBTSERVER", sslEnable: "Enable encrypted connection", caCertPath: "CA Certificate", caCertPathPlaceholder: "Optional, e.g. ~/.yandex/RootCA.crt", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 41eb7463c9..18c9aaf94c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -168,6 +168,7 @@ driverName: "驱动名称", driverNamePlaceholder: "厂商或环境名称", urlParams: "URL 参数", + gbaseServer: "GBASEDBTSERVER", sslEnable: "启用加密连接", caCertPath: "CA 证书", caCertPathPlaceholder: "可选,例如 ~/.yandex/RootCA.crt", diff --git a/apps/desktop/src/lib/databaseCapabilitySets.ts b/apps/desktop/src/lib/databaseCapabilitySets.ts index 3f17edf063..65b82042e7 100644 --- a/apps/desktop/src/lib/databaseCapabilitySets.ts +++ b/apps/desktop/src/lib/databaseCapabilitySets.ts @@ -27,6 +27,7 @@ export const SCHEMA_AWARE_TYPES = new Set([ "hive", "databend", "db2", + "informix", "tdengine", "xugu", "iotdb", @@ -60,6 +61,7 @@ export const TREE_SCHEMA_TYPES = new Set([ "jdbc", "trino", "h2", + "informix", "tdengine", "xugu", "iotdb", diff --git a/apps/desktop/src/lib/jdbcDialect.ts b/apps/desktop/src/lib/jdbcDialect.ts index 0105635a7f..dbd8df6fd5 100644 --- a/apps/desktop/src/lib/jdbcDialect.ts +++ b/apps/desktop/src/lib/jdbcDialect.ts @@ -1,6 +1,8 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; +type JdbcDialectConnection = Pick & Partial>; + const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = [ { type: "databend", patterns: [/jdbc:databend:/i, /com\.databend\.jdbc\.DatabendDriver/i, /databend-jdbc/i] }, { type: "starrocks", patterns: [/starrocks/i] }, @@ -17,22 +19,30 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = { type: "informix", patterns: [/jdbc:informix/i, /informix/i] }, ]; -export function inferJdbcDialect(connection?: Pick): DatabaseType | undefined { +export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection || connection.db_type !== "jdbc") return undefined; const haystack = [connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n"); if (!haystack) return undefined; return JDBC_DIALECT_MATCHERS.find((matcher) => matcher.patterns.some((pattern) => pattern.test(haystack)))?.type; } -export function effectiveDatabaseTypeForConnection(connection?: Pick): DatabaseType | undefined { +export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection) return undefined; + if (connection.db_type === "gbase" && connection.driver_profile === "gbase8a") return "mysql"; + if (connection.db_type === "gbase" && connection.driver_profile === "gbase8s") return "informix"; if (connection.db_type !== "jdbc") return connection.db_type; return inferJdbcDialect(connection) ?? "jdbc"; } -export function connectionUsesDatabaseObjectTreeMode(connection?: Pick): boolean { +export function tableStructureDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined { + if (!connection) return undefined; + if (connection.db_type === "gbase" && connection.driver_profile === "gbase8a") return "gbase"; + return effectiveDatabaseTypeForConnection(connection); +} + +export function connectionUsesDatabaseObjectTreeMode(connection?: JdbcDialectConnection): boolean { if (!connection) return false; - if (connection.db_type !== "jdbc") return usesDatabaseObjectTreeMode(connection.db_type); + if (connection.db_type !== "jdbc") return usesDatabaseObjectTreeMode(effectiveDatabaseTypeForConnection(connection)); const dialect = inferJdbcDialect(connection); if (!dialect) return true; if (dialect === "hive" || dialect === "trino") return false; @@ -44,16 +54,18 @@ export function connectionUsesSchemaExecutionContext(connection?: Pick | undefined, database: string, schema?: string): string { +export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string { + if (connection?.db_type === "gbase" && connection.driver_profile === "gbase8a") return ""; if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connectionUsesDatabaseObjectTreeMode(connection)) return ""; return schema || database; } -export function connectionObjectTreeNodeSchema(connection: Pick | undefined, database: string, schema?: string): string | undefined { +export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string | undefined { + if (connection?.db_type === "gbase" && connection.driver_profile === "gbase8a") return undefined; if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connectionUsesDatabaseObjectTreeMode(connection)) return undefined; if (schema) return schema; - const type = connection?.db_type === "jdbc" ? effectiveDatabaseTypeForConnection(connection) : connection?.db_type; + const type = effectiveDatabaseTypeForConnection(connection); return isSchemaAware(type) ? database : undefined; } diff --git a/apps/desktop/src/lib/tableStructureCapabilities.ts b/apps/desktop/src/lib/tableStructureCapabilities.ts index 3a10220b31..3b36f42fe9 100644 --- a/apps/desktop/src/lib/tableStructureCapabilities.ts +++ b/apps/desktop/src/lib/tableStructureCapabilities.ts @@ -1,6 +1,6 @@ import type { DatabaseType } from "@/types/database"; -export type TableStructureDialect = "mysql" | "postgres" | "sqlite" | "duckdb" | "sqlserver" | "oracle" | "h2" | "clickhouse" | "influxdb" | "unsupported"; +export type TableStructureDialect = "mysql" | "postgres" | "sqlite" | "duckdb" | "sqlserver" | "oracle" | "h2" | "clickhouse" | "informix" | "influxdb" | "unsupported"; export interface TableStructureCapabilities { dialect: TableStructureDialect; @@ -70,6 +70,14 @@ const mysqlCapabilities = capabilities({ alterPrimaryKey: true, }); +const gbaseCapabilities = capabilities({ + dialect: "mysql", + createTable: true, + addColumn: true, + dropColumn: true, + renameColumn: true, +}); + const postgresCapabilities = capabilities({ dialect: "postgres", createTable: true, @@ -193,6 +201,21 @@ const clickhouseCapabilities = capabilities({ comment: true, }); +const informixCapabilities = capabilities({ + dialect: "informix", + createTable: true, + addColumn: true, + dropColumn: true, + renameColumn: true, + alterExistingColumn: true, + alterType: true, + alterNullability: true, + alterDefault: true, + createIndex: true, + dropIndex: true, + rebuildIndex: true, +}); + const accessCapabilities = capabilities({ dialect: "h2", createTable: true, @@ -221,7 +244,7 @@ const capabilityByType: Partial goldendb: mysqlCapabilities, sundb: mysqlCapabilities, databend: mysqlCapabilities, - gbase: mysqlCapabilities, + gbase: gbaseCapabilities, postgres: postgresCapabilities, gaussdb: postgresCapabilities, kwdb: postgresCapabilities, @@ -246,6 +269,7 @@ const capabilityByType: Partial h2: h2Capabilities, access: accessCapabilities, clickhouse: clickhouseCapabilities, + informix: informixCapabilities, influxdb: influxdbCapabilities, }; diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 4d566d4557..8ba7f3ec1c 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -236,6 +236,36 @@ export const DATA_TYPE_OPTIONS: Record = { "MultiPolygon", "JSON", ], + informix: [ + "smallint", + "integer", + "int", + "bigint", + "int8", + "serial", + "serial8", + "bigserial", + "decimal", + "numeric", + "money", + "smallfloat", + "float", + "real", + "char", + "varchar", + "lvarchar", + "nchar", + "nvarchar", + "text", + "clob", + "byte", + "blob", + "boolean", + "date", + "datetime year to second", + "datetime year to fraction", + "interval day to second", + ], }; const DATA_TYPE_OPTION_ALIASES: Partial> = { @@ -243,6 +273,7 @@ const DATA_TYPE_OPTION_ALIASES: Partial> = { starrocks: "mysql", goldendb: "mysql", sundb: "mysql", + gbase: "mysql", gaussdb: "postgres", kwdb: "postgres", opengauss: "postgres", diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index c9772cc8f0..d69e520cca 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -915,10 +915,11 @@ export const useConnectionStore = defineStore("connection", () => { ); const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); const children = withSavedSqlRoot( connectionId, buildDatabaseTreeNodes(connectionId, visibleDatabases, { - includeDefaultWhenEmpty: usesTreeSchemaMode(config?.db_type) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), + includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), }), node, ); @@ -1588,9 +1589,10 @@ export const useConnectionStore = defineStore("connection", () => { await loadMongoCollections(node.connectionId, node.database); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = getConfig(node.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); if (config?.db_type === "sqlserver") { await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); - } else if (usesTreeSchemaMode(config?.db_type) && !connectionUsesDatabaseObjectTreeMode(config)) { + } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { await loadSchemas(node.connectionId, node.database, options); } else { await loadTables(node.connectionId, node.database, undefined, options); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d6be134652..1e8fc47775 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -698,6 +698,15 @@ export const useQueryStore = defineStore("query", () => { } } + function setExecutingWithId(id: string, executionId: string) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab) return; + tab.isExecuting = true; + tab.executionId = executionId; + tab.isCancelling = false; + tab.queryExecutionStartedAt = Date.now(); + } + function clearExplain(tab: QueryTab) { tab.explainPlan = undefined; tab.explainError = undefined; @@ -952,7 +961,9 @@ export const useQueryStore = defineStore("query", () => { const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`; tab.isExecuting = true; tab.isCancelling = false; - tab.queryExecutionStartedAt = Date.now(); + if (!tab.queryExecutionStartedAt) { + tab.queryExecutionStartedAt = Date.now(); + } tab.executionId = executionId; tab.lastExecutedSql = sql; if (!options?.preserveTotalRowCountDuringExecution) { @@ -1413,7 +1424,12 @@ export const useQueryStore = defineStore("query", () => { const canceled = await api.cancelQuery(executionId); if (!canceled) { const current = tabs.value.find((t) => t.id === id); - if (current && current.executionId === executionId) current.isCancelling = false; + if (current && current.executionId === executionId) { + current.isExecuting = false; + current.isCancelling = false; + current.executionId = undefined; + current.queryExecutionStartedAt = undefined; + } } return canceled; } catch (e: any) { @@ -1698,6 +1714,7 @@ export const useQueryStore = defineStore("query", () => { tableStructureRefreshVersion, setObjectSource, setExecuting, + setExecutingWithId, setErrorResult, setActiveResultIndex, executeCurrentTab, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 7a491111f7..1121036a59 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -97,6 +97,7 @@ export interface ConnectionConfig { redis_cluster_nodes?: string; redis_key_separator?: string; etcd_endpoints?: string; + gbase_server?: string; one_time?: boolean; read_only?: boolean; } diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 17ef47063b..2361b3b446 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -945,10 +945,10 @@ }, { "dbType": "gbase", - "label": "GBase", + "label": "GBase 8a", "runtimeMode": "agent", "mcpMode": "bridge", - "agentKey": "gbase", + "agentKey": "gbase8a", "singleConnectionPool": false, "metadataConnectionScoped": false, "skipTcpProbe": true, @@ -958,6 +958,11 @@ "profile": "gbase8s", "label": "GBase 8s", "agentKey": "gbase8s" + }, + { + "profile": "gbase8a", + "label": "GBase 8a", + "agentKey": "gbase8a" } ], "supportLevel": "operate", @@ -1178,7 +1183,7 @@ "schemaSearch": true, "diagram": false, "tableDataEdit": true, - "tableStructureEdit": false, + "tableStructureEdit": true, "tableImport": false, "dataTransfer": false, "sqlFileExecution": true, diff --git a/crates/dbx-core/src/agent_catalog.rs b/crates/dbx-core/src/agent_catalog.rs index f0ea1352d9..507c225614 100644 --- a/crates/dbx-core/src/agent_catalog.rs +++ b/crates/dbx-core/src/agent_catalog.rs @@ -22,8 +22,10 @@ const ORACLE_PROFILES: &[AgentDriverProfile] = &[ AgentDriverProfile { profile: "oracle-10g", key: "oracle", label: "Oracle", store_visible: false }, ]; -const GBASE_PROFILES: &[AgentDriverProfile] = - &[AgentDriverProfile { profile: "gbase8s", key: "gbase8s", label: "GBase 8s", store_visible: true }]; +const GBASE_PROFILES: &[AgentDriverProfile] = &[ + AgentDriverProfile { profile: "gbase8s", key: "gbase8s", label: "GBase 8s", store_visible: true }, + AgentDriverProfile { profile: "gbase8a", key: "gbase8a", label: "GBase 8a", store_visible: true }, +]; const AGENT_CATALOG: &[AgentCatalogEntry] = &[ AgentCatalogEntry { @@ -119,8 +121,8 @@ const AGENT_CATALOG: &[AgentCatalogEntry] = &[ }, AgentCatalogEntry { db_type: DatabaseType::Gbase, - key: "gbase", - label: "GBase", + key: "gbase8a", + label: "GBase 8a", store_visible: true, profiles: GBASE_PROFILES, }, diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index d77664a4c0..eb11af88c1 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -48,6 +48,7 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da "client_cert_path": config.client_cert_path, "client_key_path": config.client_key_path, "etcd_endpoints": etcd_endpoints, + "gbase_server": config.gbase_server, "jdbc_driver_class": config.jdbc_driver_class.as_deref().unwrap_or(""), "jdbc_driver_paths": &config.jdbc_driver_paths, }) @@ -537,6 +538,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 78db49d20b..8d6e70cf67 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -596,6 +596,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 218479f2e2..3ca5ee3d31 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1338,6 +1338,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 12ad11bc54..b005e84ede 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -508,6 +508,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: crate::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 9df309ebe7..1329e9fe3b 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -1990,6 +1990,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: crate::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 93189a53fb..7063397496 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -64,6 +64,8 @@ pub struct ConnectionConfig { pub redis_key_separator: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub etcd_endpoints: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub gbase_server: String, /// Typed configuration for external tabular sources. #[serde(default)] pub external_config: Option, @@ -356,6 +358,8 @@ struct ConnectionConfigData { #[serde(default)] pub etcd_endpoints: String, #[serde(default)] + pub gbase_server: String, + #[serde(default)] pub external_config: Option, #[serde(default)] pub jdbc_driver_class: Option, @@ -404,6 +408,7 @@ impl From for ConnectionConfig { redis_cluster_nodes: data.redis_cluster_nodes, redis_key_separator: data.redis_key_separator, etcd_endpoints: data.etcd_endpoints, + gbase_server: data.gbase_server, external_config: data.external_config, jdbc_driver_class: data.jdbc_driver_class, jdbc_driver_paths: data.jdbc_driver_paths, @@ -1412,6 +1417,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index f56f707f51..12fd419c62 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1983,6 +1983,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index dc63184e41..d4b7a55671 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -195,6 +195,10 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB return ok(format!("{statement};")); } + if options.database_type == Some(DatabaseType::Informix) { + return ok(add_informix_first_limit(&statement, safe_limit, safe_offset)); + } + if options.database_type.is_some_and(uses_fetch_first) { return ok(add_fetch_first_limit(&statement, safe_limit, safe_offset)); } @@ -406,10 +410,48 @@ fn add_mysql_limit(statement: &str, limit: usize, offset: usize) -> String { format!("{statement} LIMIT {limit}{offset_sql};") } +fn add_informix_first_limit(statement: &str, limit: usize, offset: usize) -> String { + if has_top_level_informix_row_limit(statement) { + return format!("{statement};"); + } + let row_limit = if offset > 0 { format!("SKIP {offset} FIRST {limit}") } else { format!("FIRST {limit}") }; + if statement.len() >= 6 && statement[..6].eq_ignore_ascii_case("SELECT") { + let rest = &statement[6..]; + if let Some((leading, after_modifier)) = strip_sql_server_select_modifier(rest, "DISTINCT") { + return format!("SELECT{leading}DISTINCT {row_limit}{after_modifier};"); + } + if let Some((leading, after_modifier)) = strip_sql_server_select_modifier(rest, "UNIQUE") { + return format!("SELECT{leading}UNIQUE {row_limit}{after_modifier};"); + } + if let Some((leading, after_modifier)) = strip_sql_server_select_modifier(rest, "ALL") { + return format!("SELECT{leading}ALL {row_limit}{after_modifier};"); + } + return format!("SELECT {row_limit}{rest};"); + } + format!("SELECT {row_limit} * FROM ({statement}) dbx_page;") +} + fn has_top_level_limit(sql: &str) -> bool { top_level_sql_tokens(sql).iter().any(|token| token.text == "LIMIT") } +fn has_top_level_informix_row_limit(sql: &str) -> bool { + if has_top_level_limit(sql) { + return true; + } + let tokens = top_level_sql_tokens(sql); + let Some(select_index) = tokens.iter().position(|token| token.text == "SELECT") else { + return false; + }; + let from_index = tokens + .iter() + .enumerate() + .find(|(index, token)| *index > select_index && token.text == "FROM") + .map(|(index, _)| index) + .unwrap_or(tokens.len()); + tokens[select_index + 1..from_index].iter().any(|token| token.text == "FIRST" || token.text == "SKIP") +} + fn has_top_level_fetch_first(sql: &str) -> bool { let tokens = top_level_sql_tokens(sql); tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "FIRST") @@ -831,6 +873,30 @@ mod tests { assert_eq!(result.sql.unwrap(), "SELECT id FROM users FETCH FIRST 100 ROWS ONLY;"); } + #[test] + fn uses_skip_first_pagination_for_informix() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT id FROM users WHERE active = 1".to_string(), + database_type: Some(DatabaseType::Informix), + limit: 50, + offset: 100, + }); + + assert_eq!(result.sql.unwrap(), "SELECT SKIP 100 FIRST 50 id FROM users WHERE active = 1;"); + } + + #[test] + fn informix_pagination_keeps_existing_first() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT FIRST 20 id FROM users".to_string(), + database_type: Some(DatabaseType::Informix), + limit: 50, + offset: 0, + }); + + assert_eq!(result.sql.unwrap(), "SELECT FIRST 20 id FROM users;"); + } + #[test] fn uses_mysql_style_alias_for_pagination() { let result = build_paginated_query_sql(PaginatedQuerySqlOptions { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index ca6b99e25d..f9d5497585 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -659,6 +659,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: crate::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/schema/normalization.rs b/crates/dbx-core/src/schema/normalization.rs index cafb91ff3d..c0a9c50251 100644 --- a/crates/dbx-core/src/schema/normalization.rs +++ b/crates/dbx-core/src/schema/normalization.rs @@ -179,6 +179,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 1c0b30301a..a187f70059 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -68,6 +68,11 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String return format!("SELECT TOP {limit} {select_columns} FROM {table_alias}{where_clause}{order}"); } + if database_type == Some(DatabaseType::Informix) { + let row_limit = informix_row_limit_clause(limit, options.offset.unwrap_or(0)); + return format!("SELECT {row_limit} {select_columns} FROM {table_alias}{where_clause}{order}"); + } + if database_type == Some(DatabaseType::Db2) && options.offset.is_some_and(|offset| offset > 0) { return build_db2_table_select_page_sql( &table_alias, @@ -142,6 +147,10 @@ pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String { return format!("SELECT TOP {limit} {select_columns} FROM {table}{order_by}"); } + if database_type == Some(DatabaseType::Informix) { + return format!("SELECT FIRST {limit} {select_columns} FROM {table}{order_by}"); + } + if database_type.is_some_and(uses_fetch_first) { return format!("SELECT {select_columns} FROM {table}{order_by} FETCH FIRST {limit} ROWS ONLY"); } @@ -153,6 +162,14 @@ pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String { format!("SELECT {select_columns} FROM {table}{order_by} LIMIT {limit};") } +fn informix_row_limit_clause(limit: usize, offset: usize) -> String { + if offset > 0 { + format!("SKIP {offset} FIRST {limit}") + } else { + format!("FIRST {limit}") + } +} + pub(super) fn is_oracle_row_id(database_type: Option, name: &str) -> bool { database_type == Some(DatabaseType::Oracle) && name.eq_ignore_ascii_case(DBX_ROWID_COLUMN) } diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 9142a3bd0d..a1f74300da 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -278,6 +278,38 @@ fn builds_table_data_where_and_schema_queries() { ); } +#[test] +fn builds_informix_table_data_with_skip_first_pagination() { + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Informix), + schema: Some("ignored".to_string()), + table_name: "users".to_string(), + primary_keys: vec!["id".to_string()], + columns: vec!["id".to_string(), "name".to_string()], + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(50), + offset: Some(100), + where_input: Some("WHERE active = 1".to_string()), + include_row_id: false, + }), + "SELECT SKIP 100 FIRST 50 * FROM users WHERE (active = 1) ORDER BY id ASC" + ); + + assert_eq!( + build_table_select_sql(TableSelectSqlOptions { + database_type: Some(DatabaseType::Informix), + schema: None, + table_name: "systables", + columns: &["tabname".to_string()], + order_columns: &[], + limit: 1, + }), + "SELECT FIRST 1 tabname FROM systables" + ); +} + #[test] fn explicit_table_data_order_overrides_default_key_order() { assert_eq!( 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 71476e22a9..6ea05228a9 100644 --- a/crates/dbx-core/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-core/src/table_structure_sql/column_alter.rs @@ -1,4 +1,5 @@ use super::column_format::{clickhouse_column_type, column_data_type, column_definition}; +use super::columns::build_drop_column_sql; use super::comments::build_sqlserver_column_comment_sql; use super::dialect::{capabilities_for, database_label, StructureDialect}; use super::types::{EditableStructureColumn, SingleColumnAlterSqlOptions, TableStructureSqlResult}; @@ -29,7 +30,7 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name)); return TableStructureSqlResult { statements, warnings }; } - statements.push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, &original.name))); + statements.push(build_drop_column_sql(dialect, &table, &original.name)); return TableStructureSqlResult { statements, warnings }; } @@ -74,6 +75,7 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta StructureDialect::ClickHouse => { statements.extend(build_clickhouse_existing_column_sql(&table, &options.column, "")) } + StructureDialect::Informix => statements.extend(build_informix_existing_column_sql(&table, &options.column)), StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql( &table, &options.column, @@ -191,6 +193,42 @@ pub(super) fn build_postgres_existing_column_sql(table: &str, column: &EditableS statements } +pub(super) fn build_informix_existing_column_sql(table: &str, column: &EditableStructureColumn) -> Vec { + let Some(original) = &column.original else { + return Vec::new(); + }; + let dialect = StructureDialect::Informix; + let mut statements = Vec::new(); + let mut current_name = original.name.clone(); + if column.name != original.name { + statements.push(format!( + "RENAME COLUMN {table}.{} 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); + if type_changed || nullable_changed || default_changed { + let mut parts = vec![quote_ident(dialect, ¤t_name), column_data_type(dialect, column)]; + if column.is_nullable { + parts.push("NULL".to_string()); + } else { + parts.push("NOT NULL".to_string()); + } + 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 { + parts.push("DEFAULT NULL".to_string()); + } + statements.push(format!("ALTER TABLE {table} MODIFY ({});", parts.join(" "))); + } + statements +} + pub(super) fn build_oracle_like_existing_column_sql( dialect: StructureDialect, table: &str, diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index a4431e88b6..7fbf34b425 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -1,7 +1,8 @@ use super::column_alter::{ - build_clickhouse_existing_column_sql, build_h2_existing_column_sql, build_mysql_existing_column_sql, - build_oracle_like_existing_column_sql, build_postgres_existing_column_sql, build_sqlite_existing_column_sql, - build_sqlserver_existing_column_sql, has_column_extra_change, has_existing_column_attribute_change, + build_clickhouse_existing_column_sql, build_h2_existing_column_sql, build_informix_existing_column_sql, + build_mysql_existing_column_sql, build_oracle_like_existing_column_sql, build_postgres_existing_column_sql, + build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, has_column_extra_change, + has_existing_column_attribute_change, }; use super::column_format::column_definition; use super::comments::build_sqlserver_column_comment_sql; @@ -33,7 +34,7 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name)); continue; } - statements.push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, &original.name))); + statements.push(build_drop_column_sql(dialect, &table, &original.name)); continue; } @@ -106,6 +107,7 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu column, if has_position_change { &position_clause } else { "" }, )), + StructureDialect::Informix => statements.extend(build_informix_existing_column_sql(&table, column)), StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql( &table, column, @@ -185,11 +187,13 @@ pub(super) fn build_add_column_sql( schema: Option<&str>, table_name: &str, ) -> Vec { - let add_keyword = if dialect == StructureDialect::SqlServer { "ADD" } else { "ADD COLUMN" }; let definition = column_definition(dialect, column); let mut statements = if dialect == StructureDialect::Oracle { vec![format!("ALTER TABLE {table} ADD ({definition});")] + } else if dialect == StructureDialect::Informix { + vec![format!("ALTER TABLE {table} ADD ({definition});")] } else { + let add_keyword = if dialect == StructureDialect::SqlServer { "ADD" } else { "ADD COLUMN" }; vec![format!("ALTER TABLE {table} {add_keyword} {definition}{position_clause};")] }; if matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle) && !clean(&column.comment).is_empty() { @@ -212,6 +216,13 @@ pub(super) fn build_add_column_sql( statements } +pub(super) fn build_drop_column_sql(dialect: StructureDialect, table: &str, column_name: &str) -> String { + if dialect == StructureDialect::Informix { + return format!("ALTER TABLE {table} DROP ({});", quote_ident(dialect, column_name)); + } + format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, column_name)) +} + pub(super) fn column_position_clause( dialect: StructureDialect, columns: &[&EditableStructureColumn], diff --git a/crates/dbx-core/src/table_structure_sql/dialect.rs b/crates/dbx-core/src/table_structure_sql/dialect.rs index 41df3b1abc..d347b3c5c5 100644 --- a/crates/dbx-core/src/table_structure_sql/dialect.rs +++ b/crates/dbx-core/src/table_structure_sql/dialect.rs @@ -11,6 +11,7 @@ pub(super) enum StructureDialect { Oracle, H2, ClickHouse, + Informix, Unsupported, } @@ -64,8 +65,7 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru | DatabaseType::StarRocks | DatabaseType::Goldendb | DatabaseType::Sundb - | DatabaseType::Databend - | DatabaseType::Gbase, + | DatabaseType::Databend, ) => TableStructureCapabilities { dialect: StructureDialect::Mysql, add_column: true, @@ -82,6 +82,13 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru alter_primary_key: true, ..base }, + Some(DatabaseType::Gbase) => TableStructureCapabilities { + dialect: StructureDialect::Mysql, + add_column: true, + drop_column: true, + rename_column: true, + ..base + }, Some( DatabaseType::Postgres | DatabaseType::Gaussdb @@ -197,6 +204,17 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru comment: true, ..base }, + Some(DatabaseType::Informix) => TableStructureCapabilities { + dialect: StructureDialect::Informix, + add_column: true, + drop_column: true, + rename_column: true, + alter_existing_column: true, + create_index: true, + drop_index: true, + rebuild_index: true, + ..base + }, _ => base, } } @@ -227,6 +245,7 @@ pub(super) fn dialect_label(dialect: StructureDialect) -> String { StructureDialect::Oracle => "oracle", StructureDialect::H2 => "h2", StructureDialect::ClickHouse => "clickhouse", + StructureDialect::Informix => "informix", StructureDialect::Unsupported => "this database", } .to_string() @@ -243,6 +262,7 @@ pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option Some(DatabaseType::Oracle), StructureDialect::H2 => Some(DatabaseType::H2), StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse), + StructureDialect::Informix => Some(DatabaseType::Informix), StructureDialect::Unsupported => None, } } diff --git a/crates/dbx-core/src/table_structure_sql/indexes.rs b/crates/dbx-core/src/table_structure_sql/indexes.rs index 82d7e33fb2..6d46eee979 100644 --- a/crates/dbx-core/src/table_structure_sql/indexes.rs +++ b/crates/dbx-core/src/table_structure_sql/indexes.rs @@ -112,7 +112,7 @@ pub(super) fn build_drop_index_sql( if matches!(dialect, StructureDialect::Mysql | StructureDialect::SqlServer) { return format!("DROP INDEX {} ON {table};", quote_ident(dialect, index_name)); } - if matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle) + if matches!(dialect, StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Informix) && schema.is_some_and(|schema| !schema.trim().is_empty()) { return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name)); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 7c6dede93d..41fce3bb6b 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -117,6 +117,75 @@ fn builds_mysql_column_and_index_changes() { ); } +#[test] +fn builds_informix_column_and_index_changes() { + let mut renamed = column("display_name"); + renamed.data_type = "varchar(120)".to_string(); + renamed.is_nullable = false; + renamed.default_value = "'guest'".to_string(); + renamed.original = Some(ColumnInfo { + name: "name".to_string(), + data_type: "varchar(80)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: Some(String::new()), + }); + let mut email = column("email"); + email.is_nullable = false; + let mut old_col = column("old_col"); + old_col.marked_for_drop = true; + old_col.original = Some(ColumnInfo { + name: "old_col".to_string(), + data_type: "varchar(20)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + }); + let mut old_index = index("idx_old", &["name"]); + old_index.marked_for_drop = true; + old_index.original = Some(IndexInfo { + name: "idx_old".to_string(), + columns: vec!["name".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + }); + let mut email_index = index("uniq_users_email", &["email"]); + email_index.is_unique = true; + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Informix), + schema: Some("gbasedbt".to_string()), + table_name: "users".to_string(), + columns: vec![renamed, email, old_col], + indexes: vec![old_index, email_index], + 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![ + "RENAME COLUMN gbasedbt.users.name TO display_name;", + "ALTER TABLE gbasedbt.users MODIFY (display_name varchar(120) NOT NULL DEFAULT 'guest');", + "ALTER TABLE gbasedbt.users ADD (email varchar(255) NOT NULL);", + "ALTER TABLE gbasedbt.users DROP (old_col);", + "DROP INDEX gbasedbt.idx_old;", + "CREATE UNIQUE INDEX uniq_users_email ON gbasedbt.users (email);", + ] + ); +} + #[test] fn mysql_create_index_with_comment() { let mut col = column("name"); @@ -146,6 +215,69 @@ fn mysql_create_index_with_comment() { ); } +#[test] +fn gbase8a_uses_limited_mysql_ddl() { + let mut renamed = column("display_email"); + renamed.data_type = "varchar(255)".to_string(); + renamed.original = Some(ColumnInfo { + name: "email".to_string(), + data_type: "varchar(255)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + }); + let new_col = column("nickname"); + let mut old_col = column("old_col"); + old_col.marked_for_drop = true; + old_col.original = Some(ColumnInfo { + name: "old_col".to_string(), + data_type: "varchar(20)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + }); + let mut index = index("idx_users_email", &["display_email"]); + index.original = Some(IndexInfo { + name: "idx_users_email".to_string(), + columns: vec!["email".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::Gbase), + schema: None, + table_name: "users".to_string(), + columns: vec![renamed, new_col, old_col], + indexes: vec![index], + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!( + result.statements, + vec![ + "ALTER TABLE `users` CHANGE COLUMN `email` `display_email` varchar(255);", + "ALTER TABLE `users` ADD COLUMN `nickname` varchar(255);", + "ALTER TABLE `users` DROP COLUMN `old_col`;", + ] + ); + assert_eq!( + result.warnings, + vec!["Editing existing indexes is not supported for gbase from this editor.".to_string()] + ); +} + #[test] fn mysql_create_unique_index_with_comment_and_btree() { let mut idx = index("uniq_users_email", &["email"]); diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index 6148518884..fd5cac7ef1 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -4,7 +4,11 @@ use super::types::EditableStructureColumn; pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, table_name: &str) -> String { if matches!( dialect, - StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::SqlServer | StructureDialect::H2 + StructureDialect::Postgres + | StructureDialect::Oracle + | StructureDialect::SqlServer + | StructureDialect::H2 + | StructureDialect::Informix ) && schema.is_some_and(|schema| !schema.trim().is_empty()) { return format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, table_name)); @@ -16,10 +20,20 @@ pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String { match dialect { StructureDialect::Mysql => format!("`{}`", name.replace('`', "``")), StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")), + StructureDialect::Informix if is_simple_informix_identifier(name) => name.to_string(), _ => format!("\"{}\"", name.replace('"', "\"\"")), } } +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(super) fn quote_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } @@ -78,6 +92,7 @@ pub(super) fn is_temporal_type_for_default(dialect: StructureDialect, base_type: StructureDialect::Sqlite => { matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time") } + StructureDialect::Informix => matches!(normalized.as_str(), "date" | "datetime"), _ => false, } } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 2ee02553fe..b47afde944 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -3330,6 +3330,7 @@ mod tests { redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 0dfd4208d9..a53a8d2a20 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -44,6 +44,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig { redis_cluster_nodes: String::new(), redis_key_separator: dbx_core::models::connection::default_redis_key_separator(), etcd_endpoints: String::new(), + gbase_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), diff --git a/packages/app-tests/databaseCapabilities.test.ts b/packages/app-tests/databaseCapabilities.test.ts index 9b6f71612f..15b0be3f22 100644 --- a/packages/app-tests/databaseCapabilities.test.ts +++ b/packages/app-tests/databaseCapabilities.test.ts @@ -223,6 +223,8 @@ test("describes feature support through capability helpers", () => { assert.equal(supportsTableStructureEditing("opengauss"), true); assert.equal(supportsTableStructureEditing("redshift"), true); assert.equal(supportsTableStructureEditing("clickhouse"), true); + assert.equal(supportsTableStructureEditing("gbase"), true); + assert.equal(supportsTableStructureEditing("informix"), true); assert.equal(supportsTableStructureEditing("rqlite"), true); assert.equal(supportsTableStructureEditing("mongodb"), false); assert.equal(supportsTableStructureEditing("manticoresearch"), false); diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index 0ffc6b4caa..98a77cecf3 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesSchemaExecutionContext, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "../../apps/desktop/src/lib/jdbcDialect.ts"; +import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesSchemaExecutionContext, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect, tableStructureDatabaseTypeForConnection } from "../../apps/desktop/src/lib/jdbcDialect.ts"; import { supportsTableStructureEditing } from "../../apps/desktop/src/lib/databaseFeatureSupport.ts"; import { qualifiedTableName } from "../../apps/desktop/src/lib/tableSelectSql.ts"; @@ -15,6 +15,10 @@ test("infers JDBC dialect from URL, driver class, and driver jar path", () => { test("effective database type keeps non-JDBC types and enables compatible JDBC structure editing", () => { assert.equal(effectiveDatabaseTypeForConnection({ db_type: "postgres" }), "postgres"); assert.equal(effectiveDatabaseTypeForConnection({ db_type: "jdbc" }), "jdbc"); + assert.equal(effectiveDatabaseTypeForConnection({ db_type: "gbase", driver_profile: "gbase8s" }), "informix"); + assert.equal(effectiveDatabaseTypeForConnection({ db_type: "gbase", driver_profile: "gbase8a" }), "mysql"); + assert.equal(tableStructureDatabaseTypeForConnection({ db_type: "gbase", driver_profile: "gbase8a" }), "gbase"); + assert.equal(tableStructureDatabaseTypeForConnection({ db_type: "gbase", driver_profile: "gbase8s" }), "informix"); assert.equal( effectiveDatabaseTypeForConnection({ db_type: "jdbc", @@ -56,6 +60,20 @@ test("JDBC tree shape follows the inferred driver dialect", () => { ); }); +test("GBase profiles use their compatible object tree shapes", () => { + const gbase8a = { db_type: "gbase" as const, driver_profile: "gbase8a" }; + const gbase8s = { db_type: "gbase" as const, driver_profile: "gbase8s" }; + + assert.equal(connectionUsesDatabaseObjectTreeMode(gbase8a), false); + assert.equal(connectionObjectTreeQuerySchema(gbase8a, "testdb", undefined), ""); + assert.equal(connectionObjectTreeNodeSchema(gbase8a, "testdb", undefined), undefined); + assert.equal(connectionUsesDatabaseObjectTreeMode(gbase8s), false); + assert.equal(connectionObjectTreeQuerySchema(gbase8s, "testdb", "gbasedbt"), "gbasedbt"); + assert.equal(connectionObjectTreeNodeSchema(gbase8s, "testdb", "gbasedbt"), "gbasedbt"); + assert.equal(connectionObjectTreeQuerySchema(gbase8s, "testdb", undefined), "testdb"); + assert.equal(connectionObjectTreeNodeSchema(gbase8s, "testdb", undefined), "testdb"); +}); + test("Databend JDBC keeps database as schema context for table data", () => { const databend = { db_type: "jdbc" as const, connection_string: "jdbc:databend://db.example.com:8000/dbx_test" }; diff --git a/packages/app-tests/tableStructureCapabilities.test.ts b/packages/app-tests/tableStructureCapabilities.test.ts index 275abf7825..ba2ffc7f24 100644 --- a/packages/app-tests/tableStructureCapabilities.test.ts +++ b/packages/app-tests/tableStructureCapabilities.test.ts @@ -41,6 +41,21 @@ test("mysql-like databases expose index rebuild and type capabilities", () => { } }); +test("gbase 8a exposes limited mysql-compatible structure editing capabilities", () => { + const caps = getTableStructureCapabilities("gbase"); + assert.equal(caps.dialect, "mysql"); + assert.equal(caps.createTable, true); + assert.equal(caps.addColumn, true); + assert.equal(caps.dropColumn, true); + assert.equal(caps.renameColumn, true); + assert.equal(caps.alterExistingColumn, false); + assert.equal(caps.createIndex, false); + assert.equal(caps.dropIndex, false); + assert.equal(caps.rebuildIndex, false); + assert.equal(caps.comment, false); + assert.equal(canEditTableStructure("gbase"), true); +}); + test("redshift reuses postgres column DDL but keeps indexes disabled", () => { const caps = getTableStructureCapabilities("redshift"); assert.equal(caps.dialect, "postgres"); @@ -93,6 +108,23 @@ test("limited analytic engines can open the editor for supported operations only assert.equal(canEditTableStructure("clickhouse"), true); }); +test("informix exposes conservative structure editing capabilities", () => { + const caps = getTableStructureCapabilities("informix"); + assert.equal(caps.dialect, "informix"); + assert.equal(caps.createTable, true); + assert.equal(caps.addColumn, true); + assert.equal(caps.dropColumn, true); + assert.equal(caps.renameColumn, true); + assert.equal(caps.alterExistingColumn, true); + assert.equal(caps.comment, false); + assert.equal(caps.createIndex, true); + assert.equal(caps.dropIndex, true); + assert.equal(caps.rebuildIndex, true); + assert.equal(caps.indexType, false); + assert.equal(caps.alterPrimaryKey, false); + assert.equal(canEditTableStructure("informix"), true); +}); + test("unsupported non-relational databases do not open the structure editor", () => { for (const dbType of ["redis", "mongodb", "elasticsearch", "manticoresearch", "neo4j", undefined] as const) { const caps = getTableStructureCapabilities(dbType);