From e16391618bcdfb747cec448a2cd99424ada78ad3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 15:15:30 +0000 Subject: [PATCH 01/15] feat(plugin-mysql): add OceanBase MySQL-mode connection type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- CHANGELOG.md | 1 + .../TableProCoreTypes/DatabaseType.swift | 6 +- .../DatabaseTypeTests.swift | 5 +- Plugins/MySQLDriverPlugin/Info.plist | 1 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 6 +- .../MySQLPluginDriver+Flavor.swift | 26 +++- .../MySQLPluginDriver+OceanBase.swift | 86 +++++++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 22 ++- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 44 +++++- .../MySQLServerVersion.swift | 6 + .../OceanBaseHiddenPrimaryKey.swift | 33 ++++ Plugins/MySQLDriverPlugin/OceanBaseSQL.swift | 141 ++++++++++++++++++ Plugins/TableProPluginKit/SqlDialect.swift | 2 +- .../oceanbase-icon.imageset/Contents.json | 16 ++ .../oceanbase-icon.imageset/oceanbase.svg | 1 + TablePro/Core/CrossEngine/SQLTypeFamily.swift | 1 + TablePro/Core/Plugins/ImportTypeMapper.swift | 2 +- ...uginMetadataRegistry+CuratedDefaults.swift | 2 +- ...etadataRegistry+MySQLVariantDefaults.swift | 68 +++++++++ ...ginMetadataRegistry+SnapshotAdoption.swift | 2 +- .../Core/Plugins/PluginMetadataRegistry.swift | 4 +- .../DockerComposeExtractor.swift | 7 + TablePro/Info.plist | 1 + TablePro/Models/Connection/DatabaseType.swift | 1 + .../Schema/ColumnDefaultVocabulary.swift | 2 +- .../Models/Schema/ForeignKeyDialect.swift | 2 +- .../oceanbase-icon.imageset/Contents.json | 16 ++ .../oceanbase-icon.imageset/oceanbase.svg | 1 + .../Coordinators/ConnectionCoordinator.swift | 2 +- .../Helpers/DatabaseType+Mobile.swift | 3 + .../Intents/IntentDatabaseSession.swift | 2 +- .../Platform/IOSDriverFactory.swift | 4 +- .../Views/Components/DatabaseIconView.swift | 1 + .../SQLBuilderDefaultValuesTests.swift | 3 + .../MySQLVariantSupportTests.swift | 13 ++ .../SQLDialectParityTests.swift | 1 + .../oceanbase-icon.imageset/Contents.json | 16 ++ .../oceanbase-icon.imageset/oceanbase.svg | 1 + .../Helpers/DatabaseTypeStyle.swift | 2 + .../CloudSQL/CloudSQLProxyModelTests.swift | 1 + ...ementGeneratorOceanBaseHiddenPKTests.swift | 61 ++++++++ .../Core/Compare/CompareSQLLiteralTests.swift | 2 +- .../SchemaSyncScriptBuilderTests.swift | 2 + .../Plugins/MySQLVariantSupportTests.swift | 42 +++++- .../PluginManagerVariantAccessorTests.swift | 1 + ...PluginMetadataRegistryTypeCountTests.swift | 11 +- .../PluginMetadataRegistryVariantTests.swift | 3 +- .../Plugins/SocketPathPlaceholderTests.swift | 1 + .../ProjectYamlExtractorTests.swift | 16 ++ .../ConnectionURLParserOceanBaseTests.swift | 44 ++++++ .../Utilities/DatabaseURLSchemeTests.swift | 21 +++ .../Models/DatabaseTypeOceanBaseTests.swift | 50 +++++++ TableProTests/Models/DatabaseTypeTests.swift | 10 +- .../Plugins/MySQLServerFlavorTests.swift | 51 ++++++- .../OceanBaseHiddenPrimaryKeyTests.swift | 53 +++++++ TableProTests/Plugins/OceanBaseSQLTests.swift | 94 ++++++++++++ ...tructureColumnFieldRegistrationTests.swift | 4 +- docs/connections/connection-form.mdx | 1 + docs/connections/urls.mdx | 1 + docs/databases/index.mdx | 9 +- docs/databases/mysql.mdx | 2 +- docs/databases/oceanbase.mdx | 59 ++++++++ docs/docs.json | 1 + docs/index.mdx | 2 +- docs/snippets/driver-counts.mdx | 2 +- project.yml | 2 + 66 files changed, 1057 insertions(+), 42 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift create mode 100644 Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift create mode 100644 Plugins/MySQLDriverPlugin/OceanBaseSQL.swift create mode 100644 TablePro/Assets.xcassets/oceanbase-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift create mode 100644 TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift create mode 100644 TableProTests/Models/DatabaseTypeOceanBaseTests.swift create mode 100644 TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift create mode 100644 TableProTests/Plugins/OceanBaseSQLTests.swift create mode 100644 docs/databases/oceanbase.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2447a90ef..f561ecf84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- OceanBase MySQL-mode connection type, including grid saves on servers opened as MySQL. (#1748) - Google Cloud Spanner as a registry plugin over the REST API. (#1226, #2480) - TiDB and Databend connection types on the MySQL driver. (#1066, #2514) - Empty state in the inspector and the assistant for a connection that is not up. diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 211eda5ad..de3feef5c 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -13,6 +13,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let mariadb = DatabaseType(rawValue: "MariaDB") public static let tidb = DatabaseType(rawValue: "TiDB") public static let databend = DatabaseType(rawValue: "Databend") + public static let oceanbase = DatabaseType(rawValue: "OceanBase") public static let postgresql = DatabaseType(rawValue: "PostgreSQL") public static let sqlite = DatabaseType(rawValue: "SQLite") public static let redis = DatabaseType(rawValue: "Redis") @@ -42,7 +43,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL") public static let allKnownTypes: [DatabaseType] = [ - .mysql, .mariadb, .tidb, .databend, .postgresql, .sqlite, .redis, .mongodb, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .dameng, .duckdb, .cassandra, .redshift, .etcd, .cloudflareD1, .dynamodb, .bigquery, .spanner, .snowflake, .libsql, .beancount, .surrealdb, .teradata, .trino, .kafka, .cloudflareR2SQL @@ -55,6 +56,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .mariadb: return "mariadb-icon" case .tidb: return "tidb-icon" case .databend: return "databend-icon" + case .oceanbase: return "oceanbase-icon" case .postgresql: return "postgresql-icon" case .redshift: return "redshift-icon" case .sqlite: return "sqlite-icon" @@ -85,7 +87,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public var pluginTypeId: String { switch self { - case .mariadb, .tidb, .databend: return DatabaseType.mysql.rawValue + case .mariadb, .tidb, .databend, .oceanbase: return DatabaseType.mysql.rawValue case .redshift: return DatabaseType.postgresql.rawValue default: return rawValue } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index f0d7b2e80..dc7522fc1 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -10,6 +10,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mariadb.rawValue == "MariaDB") #expect(DatabaseType.tidb.rawValue == "TiDB") #expect(DatabaseType.databend.rawValue == "Databend") + #expect(DatabaseType.oceanbase.rawValue == "OceanBase") #expect(DatabaseType.postgresql.rawValue == "PostgreSQL") #expect(DatabaseType.sqlite.rawValue == "SQLite") #expect(DatabaseType.redis.rawValue == "Redis") @@ -29,6 +30,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mariadb.pluginTypeId == "MySQL") #expect(DatabaseType.tidb.pluginTypeId == "MySQL") #expect(DatabaseType.databend.pluginTypeId == "MySQL") + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") #expect(DatabaseType.postgresql.pluginTypeId == "PostgreSQL") #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") #expect(DatabaseType.sqlite.pluginTypeId == "SQLite") @@ -59,10 +61,11 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 28) + #expect(DatabaseType.allKnownTypes.count == 29) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) + #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.spanner)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 7f89d5129..7e6f0a44f 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -10,6 +10,7 @@ MariaDB TiDB Databend + OceanBase diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index bdba8d1fc..aab80c5f1 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -15,7 +15,7 @@ import TableProPluginKit final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MySQL Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "MySQL, MariaDB, TiDB, and Databend support via libmariadb" + static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, and OceanBase support via libmariadb" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MySQL" @@ -33,7 +33,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ), MySQLConnectionEncoding.connectionField ] - static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend"] + static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase"] // MARK: - UI/Capability Metadata @@ -123,7 +123,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static func driverVariant(for databaseTypeId: String) -> String? { switch databaseTypeId { - case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant: + case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant: return databaseTypeId default: return nil diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index 02d63b71c..1d177e64e 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -28,7 +28,14 @@ extension MySQLFlavorMismatchError: PluginDriverError { extension MySQLPluginDriver { static func initialFlavor(for config: DriverConnectionConfig) -> MySQLServerFlavor { - config.additionalFields["driverVariant"] == MySQLServerFlavor.databendVariant ? .databend : .mysql + switch config.additionalFields["driverVariant"] { + case MySQLServerFlavor.databendVariant: + return .databend + case MySQLServerFlavor.oceanbaseVariant: + return .oceanbase(version: nil) + default: + return .mysql + } } func resolveFlavor(on connection: MariaDBPluginConnection, variant: String?) async throws -> MySQLServerFlavor { @@ -48,6 +55,21 @@ extension MySQLPluginDriver { throw MySQLFlavorMismatchError(kind: .databendNeedsItsOwnType) } + if variant == MySQLServerFlavor.oceanbaseVariant { + if MySQLFlavorResolution.needsOceanBaseProbe(banner: banner, variant: variant) { + if let comment = await firstValue(of: MySQLFlavorResolution.oceanbaseProbe, on: connection), + let version = MySQLServerFlavor.oceanbaseVersion(fromBanner: comment) { + return .oceanbase(version: version) + } + return .oceanbase(version: nil) + } + return bannerFlavor.isOceanBase ? bannerFlavor : .oceanbase(version: nil) + } + + if bannerFlavor.isOceanBase { + return bannerFlavor + } + guard MySQLFlavorResolution.needsTiDBVersionProbe(banner: banner, variant: variant) else { return bannerFlavor } @@ -59,7 +81,7 @@ extension MySQLPluginDriver { } func killTarget(for flavor: MySQLServerFlavor, on connection: MariaDBPluginConnection) async -> MySQLKillTarget { - guard flavor.isTiDB || flavor.isDatabend else { return .threadId } + guard flavor.isTiDB || flavor.isDatabend || flavor.isOceanBase else { return .threadId } let identifier = await firstValue(of: MySQLFlavorResolution.connectionIdentifierProbe, on: connection) return flavor.killTarget(connectionIdentifier: identifier) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift new file mode 100644 index 000000000..c7db8b9e5 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift @@ -0,0 +1,86 @@ +import Foundation +import TableProPluginKit + +extension MySQLPluginDriver { + func rewriteOceanBaseQueryIfNeeded(_ query: String) async -> String { + guard flavor.isOceanBase else { return query } + if let table = OceanBaseSQL.simpleSelectStarTable(from: query) { + let hidden = await oceanbaseHiddenPrimaryKeyColumns(for: table) + return OceanBaseSQL.projectingHiddenPrimaryKeys( + sql: query, + hiddenColumns: hidden, + quote: quoteIdentifier + ) + } + return OceanBaseSQL.withHiddenColumnVisibilityHint(query) + } + + func attachingOceanBaseHiddenPrimaryKeys( + to columns: [PluginColumnInfo], + table: String + ) async throws -> [PluginColumnInfo] { + let indexes = try await fetchIndexes(table: table, schema: nil) + let primaryColumns = indexes.first(where: \.isPrimary)?.columns ?? [] + var attached = OceanBaseHiddenPrimaryKey.attaching( + to: columns, + primaryIndexColumns: primaryColumns + ) + let visibleNames = Set(columns.map(\.name)) + if attached.count == columns.count, !columns.contains(where: \.isPrimaryKey) { + for name in OceanBaseSQL.documentedHiddenNames where !visibleNames.contains(name) { + if await oceanbaseColumnExists(name, table: table) { + attached.append(OceanBaseHiddenPrimaryKey.synthesizedColumn(named: name)) + } + } + } + let hiddenNames = attached.compactMap { column in + visibleNames.contains(column.name) ? nil : column.name + } + rememberOceanBaseHiddenPrimaryKeys(hiddenNames, for: table) + return attached + } + + func attachingOceanBaseHiddenPrimaryKeys( + to allColumns: [String: [PluginColumnInfo]], + primaryColumnsByTable: [String: [String]] + ) -> [String: [PluginColumnInfo]] { + var merged = allColumns + for (table, columns) in allColumns { + let attached = OceanBaseHiddenPrimaryKey.attaching( + to: columns, + primaryIndexColumns: primaryColumnsByTable[table] ?? [] + ) + merged[table] = attached + let hiddenNames = attached.compactMap { column in + columns.contains(where: { $0.name == column.name }) ? nil : column.name + } + rememberOceanBaseHiddenPrimaryKeys(hiddenNames, for: table) + } + return merged + } + + private func oceanbaseHiddenPrimaryKeyColumns(for table: String) async -> [String] { + if let cached = cachedOceanBaseHiddenPrimaryKeys(for: table) { + return cached + } + do { + _ = try await fetchColumns(table: table, schema: nil) + } catch { + return [] + } + return cachedOceanBaseHiddenPrimaryKeys(for: table) ?? [] + } + + private func oceanbaseColumnExists(_ column: String, table: String) async -> Bool { + do { + _ = try await execute(query: OceanBaseSQL.documentedHiddenColumnProbe( + table: table, + column: column, + quote: quoteIdentifier + )) + return true + } catch { + return false + } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 2d15ac140..ba47dc2e9 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -60,6 +60,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// connection between `requireConnection` returning and the query reaching the server. private var activeOperations = 0 + private var oceanbaseHiddenPrimaryKeys: [String: [String]] = [:] + internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") var currentSchema: String? { nil } @@ -216,6 +218,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let parameters else { return try await executeWithReconnect(query: query, isRetry: false, rowCap: cap) } + let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } noteActivity(query) @@ -242,6 +245,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } noteActivity(query) @@ -279,6 +283,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { countsAsActivity: Bool = true ) async throws -> PluginQueryResult { let startTime = Date() + let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } @@ -507,7 +512,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW FULL COLUMNS FROM \(quoteIdentifier(table))") let generationExpressions = try await fetchGenerationExpressions(table: table) - return result.rows.compactMap { row in + let columns = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = row[safe: 0]?.asText, let dataType = row[safe: 1]?.asText else { return nil } @@ -549,6 +554,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { generationKind: mysqlGenerationKind(extra: extra) ) } + guard flavor.isOceanBase else { return columns } + return try await attachingOceanBaseHiddenPrimaryKeys(to: columns, table: table) } private func fetchGenerationExpressions(table: String) async throws -> [String: String] { @@ -685,7 +692,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { allColumns[tableName, default: []].append(column) } - return allColumns + guard flavor.isOceanBase else { return allColumns } + let indexes = try await fetchAllIndexes(schema: schema) + let primaryByTable = indexes.mapValues { $0.first(where: \.isPrimary)?.columns ?? [] } + return attachingOceanBaseHiddenPrimaryKeys(to: allColumns, primaryColumnsByTable: primaryByTable) } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { @@ -1250,6 +1260,14 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Private Helpers + func cachedOceanBaseHiddenPrimaryKeys(for table: String) -> [String]? { + sessionLock.withLock { oceanbaseHiddenPrimaryKeys[table] } + } + + func rememberOceanBaseHiddenPrimaryKeys(_ columns: [String], for table: String) { + sessionLock.withLock { oceanbaseHiddenPrimaryKeys[table] = columns } + } + private func extractTableName(from query: String) -> String? { guard let regex = Self.tableNameRegex, let match = regex.firstMatch(in: query, range: NSRange(query.startIndex..., in: query)), diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index de8c018da..a67a5ce0a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -33,15 +33,20 @@ internal enum MySQLServerFlavor: Equatable, Sendable { case mariadb case tidb(version: MySQLEngineVersion?) case databend + case oceanbase(version: MySQLEngineVersion?) static let tidbVariant = "TiDB" static let databendVariant = "Databend" + static let oceanbaseVariant = "OceanBase" static func fromBanner(_ banner: String?) -> MySQLServerFlavor { guard let banner else { return .mysql } if let version = tidbVersion(fromBanner: banner) { return .tidb(version: version) } + if banner.range(of: "oceanbase", options: .caseInsensitive) != nil { + return .oceanbase(version: oceanbaseVersion(fromBanner: banner)) + } if isDatabendBanner(banner) { return .databend } @@ -63,6 +68,18 @@ internal enum MySQLServerFlavor: Equatable, Sendable { banner.range(of: #"^\d+\.\d+\.\d+-v\d+\.\d+\.\d+-"#, options: .regularExpression) != nil } + static func oceanbaseVersion(fromBanner banner: String) -> MySQLEngineVersion? { + guard banner.range(of: "oceanbase", options: .caseInsensitive) != nil else { return nil } + if let marker = banner.range(of: "OceanBase_CE-v", options: .caseInsensitive) + ?? banner.range(of: "OceanBase-v", options: .caseInsensitive) { + return MySQLEngineVersion(parsing: banner[marker.upperBound...]) + } + guard let name = banner.range(of: "OceanBase", options: .caseInsensitive) else { return nil } + var rest = banner[name.upperBound...] + rest = rest.drop(while: { $0.isLetter || $0 == "_" || $0 == "-" || $0.isWhitespace }) + return MySQLEngineVersion(parsing: rest) + } + var isMariaDB: Bool { self == .mariadb } var isTiDB: Bool { @@ -72,11 +89,21 @@ internal enum MySQLServerFlavor: Equatable, Sendable { var isDatabend: Bool { self == .databend } + var isOceanBase: Bool { + guard case .oceanbase = self else { return false } + return true + } + var tidbVersion: MySQLEngineVersion? { guard case .tidb(let version) = self else { return nil } return version } + var oceanbaseVersion: MySQLEngineVersion? { + guard case .oceanbase(let version) = self else { return nil } + return version + } + var systemDatabaseNames: [String] { switch self { case .mysql, .mariadb: @@ -85,6 +112,8 @@ internal enum MySQLServerFlavor: Equatable, Sendable { return ["INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys"] case .databend: return ["information_schema", "system"] + case .oceanbase: + return ["information_schema", "mysql", "oceanbase"] } } @@ -92,7 +121,7 @@ internal enum MySQLServerFlavor: Equatable, Sendable { switch self { case .mysql, .mariadb: return ["OPTIMIZE TABLE", "ANALYZE TABLE", "CHECK TABLE", "REPAIR TABLE"] - case .tidb, .databend: + case .tidb, .databend, .oceanbase: return ["ANALYZE TABLE"] } } @@ -114,7 +143,7 @@ internal enum MySQLServerFlavor: Equatable, Sendable { return "SET SESSION max_statement_time = \(seconds)" case .databend: return "SET max_execute_time_in_seconds = \(seconds)" - case .mysql, .tidb: + case .mysql, .tidb, .oceanbase: return "SET SESSION max_execution_time = \(seconds * 1_000)" } } @@ -146,6 +175,9 @@ internal enum MySQLServerFlavor: Equatable, Sendable { case .databend: guard let session = connectionIdentifier, !session.isEmpty else { return .threadId } return .databendSession(session) + case .oceanbase: + guard let id = connectionIdentifier.flatMap(UInt64.init) else { return .threadId } + return .oceanbaseConnection(id) case .mysql, .mariadb: return .threadId } @@ -156,6 +188,7 @@ internal enum MySQLKillTarget: Equatable, Sendable { case threadId case tidbConnection(UInt64) case databendSession(String) + case oceanbaseConnection(UInt64) func statement(threadId: UInt) -> String? { switch self { @@ -165,6 +198,8 @@ internal enum MySQLKillTarget: Equatable, Sendable { return "KILL TIDB QUERY \(id)" case .databendSession(let session): return "KILL QUERY '\(mysqlEscapeStringLiteral(session))'" + case .oceanbaseConnection(let id): + return "KILL QUERY \(id)" } } } @@ -178,7 +213,12 @@ internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } + static func needsOceanBaseProbe(banner: String?, variant: String?) -> Bool { + variant == MySQLServerFlavor.oceanbaseVariant && !MySQLServerFlavor.fromBanner(banner).isOceanBase + } + static let tidbVersionProbe = "SELECT tidb_version()" static let databendProbe = "SELECT value FROM system.settings WHERE name = 'max_result_rows'" + static let oceanbaseProbe = "SELECT @@version_comment" static let connectionIdentifierProbe = "SELECT CONNECTION_ID()" } diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index 514dd1f9f..e65763fc5 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -38,6 +38,9 @@ enum MySQLServerVersion { case .tidb(let version): guard let version else { return false } return version >= MySQLEngineVersion(major: 7, minor: 2, patch: 0) + case .oceanbase(let version): + guard let version else { return false } + return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) case .databend: return false } @@ -53,6 +56,9 @@ enum MySQLServerVersion { return isAtLeast((10, 2, 0), banner: banner) case .tidb: return true + case .oceanbase(let version): + guard let version else { return false } + return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) case .databend: return false } diff --git a/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift b/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift new file mode 100644 index 000000000..49b3d6ce7 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift @@ -0,0 +1,33 @@ +import Foundation +import TableProPluginKit + +internal enum OceanBaseHiddenPrimaryKey { + static func synthesizedColumn(named name: String) -> PluginColumnInfo { + PluginColumnInfo( + name: name, + dataType: "BIGINT", + isNullable: false, + isPrimaryKey: true, + defaultValue: nil, + extra: "auto_increment", + identityKind: .always, + isGenerated: false, + allowedValues: nil, + generationExpression: nil, + generationKind: nil + ) + } + + static func attaching( + to columns: [PluginColumnInfo], + primaryIndexColumns: [String] + ) -> [PluginColumnInfo] { + let visible = Set(columns.map(\.name)) + let hidden = OceanBaseSQL.hiddenPrimaryKeyColumns( + primaryIndexColumns: primaryIndexColumns, + visibleColumnNames: visible + ) + guard !hidden.isEmpty else { return columns } + return columns + hidden.map(synthesizedColumn(named:)) + } +} diff --git a/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift b/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift new file mode 100644 index 000000000..321dd6531 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift @@ -0,0 +1,141 @@ +import Foundation + +internal enum OceanBaseSQL { + static let visibilityHint = "/*+ opt_param('hidden_column_visible','true') */" + static let incrementColumn = "__pk_increment" + static let clusterColumn = "__pk_cluster_column" + static let documentedHiddenNames = [incrementColumn, clusterColumn] + + private static let hintedVerbs: Set = [ + "SELECT", "UPDATE", "DELETE", "INSERT", "REPLACE" + ] + + static func isDocumentedHiddenName(_ name: String) -> Bool { + documentedHiddenNames.contains(name) + } + + static func hiddenPrimaryKeyColumns( + primaryIndexColumns: [String], + visibleColumnNames: Set + ) -> [String] { + var seen = Set() + var hidden: [String] = [] + for column in primaryIndexColumns where !visibleColumnNames.contains(column) { + guard seen.insert(column).inserted else { continue } + hidden.append(column) + } + return hidden + } + + static func withHiddenColumnVisibilityHint(_ sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return sql } + if trimmed.contains("hidden_column_visible") { return sql } + guard let verbEnd = firstKeywordEnd(in: trimmed) else { return sql } + let verb = String(trimmed[trimmed.startIndex.. String + ) -> String { + let hinted = withHiddenColumnVisibilityHint(sql) + guard !hiddenColumns.isEmpty else { return hinted } + guard simpleSelectStarTable(from: hinted) != nil else { return hinted } + let extras = hiddenColumns.map(quote).joined(separator: ", ") + guard let regex = try? NSRegularExpression( + pattern: #"(?is)^(SELECT(?:\s+/\*\+[^*]*\*/)?)\s+\*\s+FROM\b"# + ) else { + return hinted + } + let nsSQL = hinted as NSString + guard let match = regex.firstMatch(in: hinted, range: NSRange(location: 0, length: nsSQL.length)), + match.numberOfRanges > 1 + else { + return hinted + } + let prefix = nsSQL.substring(with: match.range(at: 1)) + let afterFrom = nsSQL.substring(from: match.range.upperBound) + return "\(prefix) *, \(extras) FROM\(afterFrom)" + } + + static func simpleSelectStarTable(from sql: String) -> String? { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard let regex = try? NSRegularExpression( + pattern: #"(?is)^SELECT(?:\s+/\*\+[^*]*\*/)?\s+\*\s+FROM\s+"# + ) else { + return nil + } + let nsSQL = trimmed as NSString + guard let match = regex.firstMatch(in: trimmed, range: NSRange(location: 0, length: nsSQL.length)) else { + return nil + } + var rest = nsSQL.substring(from: match.range.upperBound) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !rest.hasPrefix("(") else { return nil } + return trailingQualifiedIdentifier(from: &rest) + } + + static func documentedHiddenColumnProbe(table: String, column: String, quote: (String) -> String) -> String { + "SELECT \(visibilityHint) \(quote(column)) FROM \(quote(table)) LIMIT 0" + } + + private static func firstKeywordEnd(in sql: String) -> String.Index? { + var index = sql.startIndex + while index < sql.endIndex, sql[index].isWhitespace { + sql.formIndex(after: &index) + } + guard index < sql.endIndex, sql[index].isLetter else { return nil } + while index < sql.endIndex, sql[index].isLetter { + sql.formIndex(after: &index) + } + return index + } + + private static func trailingQualifiedIdentifier(from rest: inout String) -> String? { + var parts: [String] = [] + while let ident = parseLeadingIdentifier(from: &rest) { + parts.append(ident) + rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) + guard rest.hasPrefix(".") else { break } + rest.removeFirst() + rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) + } + return parts.last + } + + private static func parseLeadingIdentifier(from rest: inout String) -> String? { + rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = rest.first else { return nil } + if first == "`" { + rest.removeFirst() + guard let end = rest.firstIndex(of: "`") else { return nil } + let value = String(rest[.. rest.startIndex else { return nil } + let value = String(rest[.. diff --git a/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/TablePro/Core/CrossEngine/SQLTypeFamily.swift index cae295d6c..d2c9ed8cd 100644 --- a/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -43,6 +43,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "MySQL": .mysql, "MariaDB": .mysql, "TiDB": .mysql, + "OceanBase": .mysql, "PostgreSQL": .postgres, "Redshift": .postgres, "CockroachDB": .postgres, diff --git a/TablePro/Core/Plugins/ImportTypeMapper.swift b/TablePro/Core/Plugins/ImportTypeMapper.swift index 14e5f3455..3de3cfaf0 100644 --- a/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -11,7 +11,7 @@ enum ImportTypeMapper { switch databaseType { case .postgresql, .redshift, .cockroachdb: return postgresType(type) - case .mysql, .mariadb, .tidb: + case .mysql, .mariadb, .tidb, .oceanbase: return mysqlType(type) case .sqlite: return sqliteType(type) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 558d6ea77..db1eddaed 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -11,7 +11,7 @@ import TableProPluginKit /// The primary type ids here are overwritten by `buildMetadataSnapshot` the moment the plugin /// registers, so these are the pre-load answer for those. For a variant id they are the whole /// answer: `registerVariant` keeps the curated entry and ignores the plugin's own statics, which -/// is the only reason MariaDB, TiDB, Databend, Redshift, CockroachDB and PGlite can differ from +/// is the only reason MariaDB, TiDB, Databend, OceanBase, Redshift, CockroachDB and PGlite can differ from /// the plugin that drives them. extension PluginMetadataRegistry { // swiftlint:disable:next function_body_length diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index ac98728d4..0f4d331b9 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -34,6 +34,10 @@ extension PluginMetadataRegistry { mysqlColumnTypes.filter { $0.key != "Spatial" } } + static func oceanbaseColumnTypes(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { + mysqlColumnTypes.filter { $0.key != "Spatial" } + } + static func mysqlVariantDefaults( dialect: SQLDialectDescriptor, mysqlColumnTypes: [String: [String]], @@ -163,6 +167,70 @@ extension PluginMetadataRegistry { category: .analytical, tagline: String(localized: "Cloud data warehouse, built in Rust") ) + )), + ("OceanBase", PluginMetadataSnapshot( + displayName: "OceanBase", iconName: "oceanbase-icon", defaultPort: 2_881, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "oceanbase", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText) + ], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["oceanbase"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#006AFF", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + structureEditing: SchemaEditingSupport(columnReorder: .alter, foreignKeyEdit: .alter), + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: false, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: false, + supportsGeneratedColumns: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred, + supportsPrincipalConnectionLimit: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: ["__pk_increment", "__pk_cluster_column"], + systemDatabaseNames: ["information_schema", "mysql", "oceanbase"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, + .onUpdate, .autoIncrement, .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: oceanbaseColumnTypes(from: mysqlColumnTypes) + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [idleReleaseField], + category: .relational, + tagline: String(localized: "Distributed HTAP, MySQL-compatible") + ) )) ] } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index c3045a6b2..bc2a81bef 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -21,7 +21,7 @@ extension PluginMetadataRegistry { /// /// Two facts qualify: case-insensitive matching, which is why Redshift is spelled /// `postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction)`, and the column type list, - /// which TiDB narrows (no spatial types) and Databend replaces with its own. Each has its own + /// which TiDB and OceanBase narrow (no spatial types) and Databend replaces with its own. Each has its own /// named adoption here rather than a value comparison: `SQLDialectDescriptor` is not /// `Equatable`, and a whole-descriptor diff would report "differs" for Redshift and hand it /// the stub back. diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index d6c246aba..798bc35bd 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -383,6 +383,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["MariaDB"] = "MySQL" reverseTypeIndex["TiDB"] = "MySQL" reverseTypeIndex["Databend"] = "MySQL" + reverseTypeIndex["OceanBase"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" reverseTypeIndex["PGlite"] = "PostgreSQL" @@ -680,7 +681,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { /// Keyed by `databaseTypeId`. Stale plugins from the registry inherit these on registration. static func fallbackCategory(forTypeId typeId: String) -> DatabaseCategory { switch typeId { - case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL": + case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL", "OceanBase": return .relational case "Redshift", "ClickHouse", "DuckDB", "BigQuery": return .analytical @@ -707,6 +708,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { switch typeId { case "MySQL": return String(localized: "Most popular open-source SQL database") case "MariaDB": return String(localized: "Open-source fork of MySQL") + case "OceanBase": return String(localized: "Distributed HTAP, MySQL-compatible") case "PostgreSQL": return String(localized: "Advanced object-relational SQL") case "Redshift": return String(localized: "Amazon's columnar warehouse on Postgres") case "SQLite": return String(localized: "Embedded zero-config SQL database") diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 4e4c56d55..faccd4b18 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -79,6 +79,9 @@ enum DockerComposeExtractor { if databendRepositories.contains(repositoryPath.suffix(2).joined(separator: "/")) { return ServiceDatabase(type: .databend, defaultPort: 3_307) } + if name.contains("oceanbase") { + return ServiceDatabase(type: .oceanbase, defaultPort: 2_881) + } if name.contains("postgres"), !name.contains("postgrest") { return ServiceDatabase(type: .postgresql, defaultPort: 5_432) } @@ -179,6 +182,10 @@ enum DockerComposeExtractor { fields.username = variables["QUERY_DEFAULT_USER"] ?? "root" fields.password = variables["QUERY_DEFAULT_PASSWORD"] ?? "" fields.database = "default" + case .oceanbase: + fields.username = "root@sys" + fields.password = "" + fields.database = "" case .mariadb, .mysql: let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil ? "MARIADB" diff --git a/TablePro/Info.plist b/TablePro/Info.plist index cf67a7aaf..40bd760bd 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -415,6 +415,7 @@ mysql mariadb tidb + oceanbase sqlite mongodb redis diff --git a/TablePro/Models/Connection/DatabaseType.swift b/TablePro/Models/Connection/DatabaseType.swift index db70ba176..571114d0b 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -18,6 +18,7 @@ extension DatabaseType { static let mariadb = DatabaseType(rawValue: "MariaDB") static let tidb = DatabaseType(rawValue: "TiDB") static let databend = DatabaseType(rawValue: "Databend") + static let oceanbase = DatabaseType(rawValue: "OceanBase") static let postgresql = DatabaseType(rawValue: "PostgreSQL") static let sqlite = DatabaseType(rawValue: "SQLite") static let redshift = DatabaseType(rawValue: "Redshift") diff --git a/TablePro/Models/Schema/ColumnDefaultVocabulary.swift b/TablePro/Models/Schema/ColumnDefaultVocabulary.swift index 4583449a6..5266829b6 100644 --- a/TablePro/Models/Schema/ColumnDefaultVocabulary.swift +++ b/TablePro/Models/Schema/ColumnDefaultVocabulary.swift @@ -54,7 +54,7 @@ internal enum ColumnDefaultVocabulary { private static func expressionSQL(for databaseType: DatabaseType) -> [String] { switch databaseType { - case .mysql, .tidb: + case .mysql, .tidb, .oceanbase: return ["CURRENT_TIMESTAMP", "(UUID())", "(CURRENT_DATE)"] case .mariadb: return ["CURRENT_TIMESTAMP", "uuid()", "curdate()"] diff --git a/TablePro/Models/Schema/ForeignKeyDialect.swift b/TablePro/Models/Schema/ForeignKeyDialect.swift index 7253c6a0d..fe1f2d3fc 100644 --- a/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -44,7 +44,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: false, allowsOmittedReferencedColumns: true ) - case .mysql, .mariadb, .tidb: + case .mysql, .mariadb, .tidb, .oceanbase: return ForeignKeyDialect( deleteActions: [.noAction, .restrict, .cascade, .setNull], updateActions: [.noAction, .restrict, .cascade, .setNull], diff --git a/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json new file mode 100644 index 000000000..eb0b7fe6e --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oceanbase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg new file mode 100644 index 000000000..a423b01f5 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg @@ -0,0 +1 @@ + diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index a52072da9..14cffc8d2 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -51,7 +51,7 @@ final class ConnectionCoordinator { var supportsDatabaseSwitching: Bool { connection.type == .mysql || connection.type == .mariadb || - connection.type == .tidb || + connection.type == .tidb || connection.type == .oceanbase || connection.type == .postgresql || connection.type == .redshift || connection.type == .mssql } diff --git a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index 3b1badeeb..bfce9ab52 100644 --- a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -7,6 +7,7 @@ extension DatabaseType { case .mysql, .mariadb: return "3306" case .tidb: return "4000" case .databend: return "3307" + case .oceanbase: return "2881" case .postgresql: return "5432" case .redshift: return "5439" case .redis: return "6379" @@ -23,6 +24,7 @@ extension DatabaseType { case .mariadb: "MariaDB" case .tidb: "TiDB" case .databend: "Databend" + case .oceanbase: "OceanBase" case .postgresql: "PostgreSQL" case .redshift: "Redshift" case .sqlite: "SQLite" @@ -38,6 +40,7 @@ extension DatabaseType { .mysql, .mariadb, .tidb, + .oceanbase, .postgresql, .sqlite, .duckdb, diff --git a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift index a2991128a..32cebec07 100644 --- a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift +++ b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -9,7 +9,7 @@ struct IntentDatabaseSession { static func supportsTabularInsert(_ type: DatabaseType) -> Bool { switch type { - case .mysql, .mariadb, .tidb, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle: + case .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .mssql, .sqlite, .duckdb, .oracle: return true default: return false diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 175229073..fa3007c9a 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -33,7 +33,7 @@ nonisolated final class IOSDriverFactory: DriverFactory { ? nil : bookmarkStore.bookmark(for: connection.id) return DuckDBDriver(path: connection.database, bookmark: bookmark) - case .mysql, .mariadb, .tidb: + case .mysql, .mariadb, .tidb, .oceanbase: return MySQLDriver( host: connection.host, port: connection.port, @@ -75,6 +75,6 @@ nonisolated final class IOSDriverFactory: DriverFactory { } func supportedTypes() -> [DatabaseType] { - [.sqlite, .duckdb, .mysql, .mariadb, .tidb, .postgresql, .redshift, .redis, .mssql, .oracle] + [.sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle] } } diff --git a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift index 458e4c3fd..917b33d66 100644 --- a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift +++ b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift @@ -30,6 +30,7 @@ struct DatabaseIconView: View { case .mysql, .mariadb: return .orange case .tidb: return .red case .databend: return .blue + case .oceanbase: return .blue case .postgresql, .redshift: return .blue case .sqlite: return .green case .redis: return .red diff --git a/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift index 22e6543ed..7d72893bf 100644 --- a/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift +++ b/TableProMobile/TableProMobileTests/Helpers/SQLBuilderDefaultValuesTests.swift @@ -18,6 +18,9 @@ struct SQLBuilderDefaultValuesTests { #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .tidb) == "INSERT INTO `t` () VALUES ()") #expect(SQLBuilder.quoteIdentifier("a`b", for: .tidb) == "`a``b`") + #expect(SQLBuilder.buildAllDefaultsInsert(qualifiedTable: "`t`", for: .oceanbase) + == "INSERT INTO `t` () VALUES ()") + #expect(SQLBuilder.speaksMySQLDialect(.oceanbase)) } @Test("Databend is not a MySQL dialect on iOS") diff --git a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift index 390e9c242..7f5caf1e5 100644 --- a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift +++ b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift @@ -9,9 +9,11 @@ struct MySQLVariantSupportTests { @Test("TiDB is offered and supported, Databend is neither") func offeredTypes() { #expect(DatabaseType.mobileSupportedTypes.contains(.tidb)) + #expect(DatabaseType.mobileSupportedTypes.contains(.oceanbase)) #expect(!DatabaseType.mobileSupportedTypes.contains(.databend)) let supported = IOSDriverFactory().supportedTypes() #expect(supported.contains(.tidb)) + #expect(supported.contains(.oceanbase)) #expect(!supported.contains(.databend)) } @@ -21,11 +23,14 @@ struct MySQLVariantSupportTests { #expect(DatabaseType.tidb.mobileDisplayName == "TiDB") #expect(DatabaseType.databend.defaultPort == "3307") #expect(DatabaseType.databend.mobileDisplayName == "Databend") + #expect(DatabaseType.oceanbase.defaultPort == "2881") + #expect(DatabaseType.oceanbase.mobileDisplayName == "OceanBase") } @Test("TiDB takes tabular inserts from Shortcuts, Databend does not") func tabularInsert() { #expect(IntentDatabaseSession.supportsTabularInsert(.tidb)) + #expect(IntentDatabaseSession.supportsTabularInsert(.oceanbase)) #expect(!IntentDatabaseSession.supportsTabularInsert(.databend)) } @@ -36,4 +41,12 @@ struct MySQLVariantSupportTests { let mysql = try #require(driver as? MySQLDriver) #expect(mysql.databaseType == .tidb) } + + @Test("OceanBase routes to the MySQL driver with its own type") + func oceanBaseRoutesToMySQLDriver() throws { + let connection = DatabaseConnection(name: "o", type: .oceanbase, host: "127.0.0.1", port: 2_881) + let driver = try IOSDriverFactory().createDriver(for: connection, password: nil) + let mysql = try #require(driver as? MySQLDriver) + #expect(mysql.databaseType == .oceanbase) + } } diff --git a/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift b/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift index f7d65f1ec..12206f2c1 100644 --- a/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift +++ b/TableProMobile/TableProMobileTests/SQLDialectParityTests.swift @@ -42,6 +42,7 @@ struct SQLDialectParityTests { (.mysql, .collationDefined), (.mariadb, .collationDefined), (.tidb, .collationDefined), + (.oceanbase, .collationDefined), (.mssql, .collationDefined), (.postgresql, .ilikeOperator), (.duckdb, .ilikeOperator), diff --git a/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json new file mode 100644 index 000000000..eb0b7fe6e --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oceanbase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg new file mode 100644 index 000000000..a423b01f5 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/oceanbase-icon.imageset/oceanbase.svg @@ -0,0 +1 @@ + diff --git a/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift index c5e91110f..c54702eff 100644 --- a/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift +++ b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift @@ -7,6 +7,7 @@ enum DatabaseTypeStyle { case "MariaDB": return "mariadb-icon" case "TiDB": return "tidb-icon" case "Databend": return "databend-icon" + case "OceanBase": return "oceanbase-icon" case "PostgreSQL": return "postgresql-icon" case "Redshift": return "redshift-icon" case "SQLite": return "sqlite-icon" @@ -46,6 +47,7 @@ enum DatabaseTypeStyle { case "MySQL", "MariaDB": return .orange case "TiDB": return .red case "Databend": return .blue + case "OceanBase": return .blue case "PostgreSQL", "Redshift": return .blue case "SQLite": return .green case "Redis": return .red diff --git a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift index 68bf26132..9464ec8a9 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift @@ -108,6 +108,7 @@ struct CloudSQLProxyModelTests { #expect(!DatabaseType.mariadb.supportsCloudSQLProxy) #expect(!DatabaseType.tidb.supportsCloudSQLProxy) #expect(!DatabaseType.databend.supportsCloudSQLProxy) + #expect(!DatabaseType.oceanbase.supportsCloudSQLProxy) #expect(!DatabaseType.sqlite.supportsCloudSQLProxy) #expect(!DatabaseType.mongodb.supportsCloudSQLProxy) } diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift new file mode 100644 index 000000000..2f11a7ee9 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift @@ -0,0 +1,61 @@ +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("SQL Statement Generator OceanBase hidden PK") +struct SQLStatementGeneratorOceanBaseHiddenPKTests { + private func makeGenerator( + columns: [String], + primaryKeyColumns: [String], + generatedColumns: Set = [] + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: "orders", + columns: columns, + primaryKeyColumns: primaryKeyColumns, + databaseType: .oceanbase, + generatedColumns: generatedColumns, + dialect: nil + ) + } + + @Test("An UPDATE is skipped when the hidden PK is named but not in the result columns") + func missingHiddenKeySkipsUpdate() throws { + let generator = try makeGenerator( + columns: ["name", "amount"], + primaryKeyColumns: ["__pk_increment"] + ) + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(columnIndex: 0, columnName: "name", oldValue: "old", newValue: "new") + ], + originalRow: ["old", "1"].map(PluginCellValue.fromOptional) + ) + #expect(generator.generateUpdateSQL(for: change) == nil) + } + + @Test("An UPDATE matches on the hidden PK once it is a result column") + func hiddenKeyAnchorsUpdate() throws { + let generator = try makeGenerator( + columns: ["name", "amount", "__pk_increment"], + primaryKeyColumns: ["__pk_increment"], + generatedColumns: ["__pk_increment"] + ) + let change = RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(columnIndex: 0, columnName: "name", oldValue: "old", newValue: "new") + ], + originalRow: ["old", "1", "42"].map(PluginCellValue.fromOptional) + ) + let statement = try #require(generator.generateUpdateSQL(for: change)) + #expect(statement.sql == "UPDATE `orders` SET `name` = ? WHERE `__pk_increment` = ?") + #expect(statement.parameters.count == 2) + #expect(statement.parameters[0] as? String == "new") + #expect(statement.parameters[1] as? String == "42") + #expect(!statement.sql.contains("SET `__pk_increment`")) + } +} diff --git a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 0cb4a9d81..665ce4ad2 100644 --- a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -15,7 +15,7 @@ final class CompareSQLLiteralTests: XCTestCase { private let png = Data([0x89, 0x50, 0x4E, 0x47]) func testBitStringEnginesKeepTheDefaultSpelling() throws { - for type in [DatabaseType.mysql, .mariadb, .tidb, .databend, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1] { + for type in [DatabaseType.mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1] { let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) XCTAssertEqual(literal, "X'89504E47'", "\(type.rawValue) uses a bit-string literal") } diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift index 7e56a6c1a..450a69ccb 100644 --- a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -351,6 +351,8 @@ final class CompareSyncEngineFamilyTests: XCTestCase { XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .tidb)) XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .tidb, to: .mariadb)) XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .databend)) + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .oceanbase)) + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .oceanbase)) } func testUnrelatedEnginesAreRefused() { diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index 1b6e1540f..cf4228ea1 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -29,6 +29,15 @@ struct MySQLVariantSupportTests { #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: DatabaseType(rawValue: typeId)) == .plainText) } + @Test("OceanBase keeps a plain EXPLAIN and does not take FORMAT=JSON") + func oceanbaseExplainStaysPlain() throws { + let registry = PluginMetadataRegistry.shared + registry.registerVariant(pluginSnapshot: try Self.mysqlPluginSnapshot(), forTypeId: "OceanBase", primaryTypeId: "MySQL") + let variants = try #require(registry.snapshot(forRegisteredTypeId: "OceanBase")).explainVariants + #expect(variants.map(\.sqlPrefix) == ["EXPLAIN"]) + #expect(variants.allSatisfy { $0.format == .plainText }) + } + @Test("TiDB keeps its type list without the spatial group once the MySQL plugin registers") func tidbColumnTypesSurviveRegistration() throws { let registry = PluginMetadataRegistry.shared @@ -38,6 +47,15 @@ struct MySQLVariantSupportTests { #expect(types["JSON"] == ["JSON"]) } + @Test("OceanBase keeps its type list without the spatial group once the MySQL plugin registers") + func oceanbaseColumnTypesSurviveRegistration() throws { + let registry = PluginMetadataRegistry.shared + registry.registerVariant(pluginSnapshot: try Self.mysqlPluginSnapshot(), forTypeId: "OceanBase", primaryTypeId: "MySQL") + let types = try #require(registry.snapshot(forRegisteredTypeId: "OceanBase")).editor.columnTypesByCategory + #expect(types["Spatial"] == nil) + #expect(types["JSON"] == ["JSON"]) + } + @Test("Databend keeps its own types and case folding once the MySQL plugin registers") func databendEditorSurvivesRegistration() throws { let registry = PluginMetadataRegistry.shared @@ -66,6 +84,7 @@ struct MySQLVariantSupportTests { @Test("TiDB hides the connection limit it ignores; the others keep it") func principalConnectionLimit() { #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .tidb)) + #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .oceanbase)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mysql)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mariadb)) } @@ -75,6 +94,7 @@ struct MySQLVariantSupportTests { #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .databend).contains("VARIANT")) #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .mysql).isEmpty) #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .tidb).isEmpty) + #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .oceanbase).isEmpty) } @Test("The inspector's function menu offers only what each engine has") @@ -82,6 +102,8 @@ struct MySQLVariantSupportTests { let tidb = SQLFunctionProvider.functions(for: .tidb).map(\.expression) #expect(tidb.contains("CURDATE()")) #expect(tidb.contains("UTC_TIMESTAMP()")) + let oceanbase = SQLFunctionProvider.functions(for: .oceanbase).map(\.expression) + #expect(oceanbase.contains("CURDATE()")) let databend = SQLFunctionProvider.functions(for: .databend).map(\.expression) #expect(databend == ["NOW()", "CURRENT_TIMESTAMP()", "UUID()"]) } @@ -97,6 +119,7 @@ struct MySQLVariantSupportTests { @Test("Databend lexes as the generic dialect, TiDB as MySQL") func lexicalDialects() { #expect(SqlDialect.from(databaseTypeId: "TiDB") == .mysql) + #expect(SqlDialect.from(databaseTypeId: "OceanBase") == .mysql) #expect(SqlDialect.from(databaseTypeId: "Databend") == .generic) } @@ -109,7 +132,9 @@ struct MySQLVariantSupportTests { @Test("TiDB copies as the MySQL type family; Databend does not") func typeFamilies() { #expect(SQLTypeFamily.of(.tidb) == .mysql) + #expect(SQLTypeFamily.of(.oceanbase) == .mysql) #expect(!SQLTypeFamily.needsTranslation(from: .mysql, to: .tidb)) + #expect(!SQLTypeFamily.needsTranslation(from: .mysql, to: .oceanbase)) #expect(SQLTypeFamily.of(.databend) != .mysql) } @@ -118,12 +143,20 @@ struct MySQLVariantSupportTests { #expect(ColumnDefaultVocabulary.options(for: .tidb) == ColumnDefaultVocabulary.options(for: .mysql)) } + @Test("OceanBase treats the hidden primary key columns as immutable") + func oceanbaseImmutableHiddenKeys() { + #expect(PluginManager.shared.immutableColumns(for: .oceanbase) == ["__pk_increment", "__pk_cluster_column"]) + #expect(PluginManager.shared.immutableColumns(for: .mysql).isEmpty) + } + @Test("Neither variant gets a Server Dashboard or a native backup") func dashboardAndBackup() { #expect(ServerDashboardQueryProviderFactory.provider(for: .tidb) == nil) #expect(ServerDashboardQueryProviderFactory.provider(for: .databend) == nil) + #expect(ServerDashboardQueryProviderFactory.provider(for: .oceanbase) == nil) #expect(!NativeDumpRegistry.supports(.tidb)) #expect(!NativeDumpRegistry.supports(.databend)) + #expect(!NativeDumpRegistry.supports(.oceanbase)) } @Test("TiDB compares with TiDB, not with MySQL or MariaDB") @@ -132,14 +165,21 @@ struct MySQLVariantSupportTests { #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .tidb)) #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .tidb, to: .mariadb)) #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .databend)) + #expect(CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .oceanbase)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .oceanbase)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .oceanbase, to: .mariadb)) } - @Test("URLs: tidb:// opens TiDB, databend:// is refused") + @Test("URLs: tidb:// opens TiDB, oceanbase:// opens OceanBase, databend:// is refused") func urlSchemes() { guard case .success(let tidb) = ConnectionURLParser.parse("tidb://root@host:4000/test") else { Issue.record("Expected tidb:// to parse"); return } #expect(tidb.type == .tidb) + guard case .success(let oceanbase) = ConnectionURLParser.parse("oceanbase://root%40sys@host:2881/test") else { + Issue.record("Expected oceanbase:// to parse"); return + } + #expect(oceanbase.type == .oceanbase) guard case .failure = ConnectionURLParser.parse("databend://root:pw@host:8000/default") else { Issue.record("Expected databend:// to be refused"); return } diff --git a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift index 315d0818d..4cce008ed 100644 --- a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift @@ -49,6 +49,7 @@ struct PluginManagerVariantAccessorTests { "INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys" ]) #expect(manager.systemDatabaseNames(for: .databend) == ["information_schema", "system"]) + #expect(manager.systemDatabaseNames(for: .oceanbase) == ["information_schema", "mysql", "oceanbase"]) } /// The reason the editor half of this was reported: Redshift has no non-ASCII ILIKE, so it diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index ef6d5a6e6..5cf47c6b7 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -11,7 +11,7 @@ import Testing /// this registry, `docs/snippets/driver-counts.mdx`, and the marketing site. Nothing at runtime /// reconciles them, and by August 2026 they read 28, 27 and 25 at once. /// -/// The answer is 34, and the reason it once read 28 is worth keeping. Turso is served by +/// The answer is 35, and the reason it once read 28 is worth keeping. Turso is served by /// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its /// own, so it was the only type the picker could not offer before its plugin was installed. /// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated @@ -24,7 +24,7 @@ import Testing /// `docs/scripts/check-docs-against-source.py` reads the registry and holds the docs half. /// /// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. -/// Both answer 34 under XCTest, where no plugin bundle ever loads, but the registry is a +/// Both answer 35 under XCTest, where no plugin bundle ever loads, but the registry is a /// process-global singleton and suites that register a synthetic type run alongside this one. @MainActor @Suite("PluginMetadataRegistry engine count") @@ -34,7 +34,7 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", "Trino", - "Turso", "Typesense" + "Turso", "Typesense", "OceanBase" ] private static func builtInTypeIds() -> Set { @@ -43,10 +43,10 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 34 database types before any plugin loads") + @Test("The app ships 35 database types before any plugin loads") func builtInDefaultsCoverTwentyNineTypes() { let ids = Self.builtInTypeIds() - #expect(ids.count == 34) + #expect(ids.count == 35) #expect(ids == Self.expectedTypeIds) } @@ -64,6 +64,7 @@ struct PluginMetadataRegistryTypeCountTests { "MariaDB": "MySQL", "TiDB": "MySQL", "Databend": "MySQL", + "OceanBase": "MySQL", "Redshift": "PostgreSQL", "CockroachDB": "PostgreSQL", "PGlite": "PostgreSQL", diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift index 38e92eee9..2314d8d2d 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryVariantTests.swift @@ -104,6 +104,7 @@ struct PluginMetadataRegistryVariantTests { #expect(registry.snapshot(for: .cockroachdb)?.defaultPort == 26_257) #expect(registry.snapshot(for: .tidb)?.defaultPort == 4_000) #expect(registry.snapshot(for: .databend)?.defaultPort == 3_307) + #expect(registry.snapshot(for: .oceanbase)?.defaultPort == 2_881) } /// One connection, one dialect. The filter preview reads PluginManager.sqlDialect while the @@ -112,7 +113,7 @@ struct PluginMetadataRegistryVariantTests { @MainActor @Test("every reader of the editor dialect agrees for a variant type") func dialectReadersAgreeForAVariant() throws { - for databaseType in [DatabaseType.redshift, .cockroachdb, .pglite, .mariadb, .tidb, .databend] { + for databaseType in [DatabaseType.redshift, .cockroachdb, .pglite, .mariadb, .tidb, .databend, .oceanbase] { let viaManager = try #require(PluginManager.shared.sqlDialect(for: databaseType)) let viaHelper = try resolveSQLDialect(for: databaseType) #expect(viaManager.caseSensitivityStyle == viaHelper.caseSensitivityStyle) diff --git a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift index f6e774a48..676094c2a 100644 --- a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift +++ b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift @@ -21,6 +21,7 @@ struct SocketPathPlaceholderTests { func mysqlProtocolVariantsHaveNoSocket() { #expect(PluginManager.shared.defaultUnixSocketPath(for: .tidb) == nil) #expect(PluginManager.shared.defaultUnixSocketPath(for: .databend) == nil) + #expect(PluginManager.shared.defaultUnixSocketPath(for: .oceanbase) == nil) } @Test("PostgreSQL uses the PGSQL socket") diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index 61f26be14..8b4817e73 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -262,6 +262,22 @@ struct DockerComposeExtractorTests { #expect(databend?.parsedURL.database == "default") } + @Test("OceanBase images map to OceanBase on 2881 as root@sys") + func testOceanBaseImageAndCredentials() { + let oceanbase = extract(""" + services: + ob: + image: oceanbase/oceanbase-ce:latest + ports: + - "2881:2881" + """).first + #expect(oceanbase?.parsedURL.type == .oceanbase) + #expect(oceanbase?.parsedURL.port == nil) + #expect(oceanbase?.parsedURL.username == "root@sys") + #expect(oceanbase?.parsedURL.password.isEmpty == true) + #expect(oceanbase?.parsedURL.database.isEmpty == true) + } + @Test("Interpolation uses the adjacent dotenv file") func testInterpolationFromDotenv() { let contents = """ diff --git a/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift new file mode 100644 index 000000000..f05642df9 --- /dev/null +++ b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Connection URL Parser - OceanBase") +struct ConnectionURLParserOceanBaseTests { + @Test("Full oceanbase URL with default port") + func testFullURLDefaultPort() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@host:2881/test") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.host == "host") + #expect(parsed.port == nil) + #expect(parsed.database == "test") + #expect(parsed.username == "root@sys") + #expect(parsed.password == "pass") + } + + @Test("Case-insensitive OceanBase scheme") + func testCaseInsensitiveScheme() { + let result = ConnectionURLParser.parse("OceanBase://root%40sys@host/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.host == "host") + #expect(parsed.username == "root@sys") + } + + @Test("OceanBase non-default port preserved") + func testNonDefaultPortPreserved() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@host:2883/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.port == 2_883) + #expect(parsed.host == "host") + #expect(parsed.database == "db") + } +} diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index 6003e96c7..d087d77d2 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -61,6 +61,16 @@ struct DatabaseURLSchemeTests { #expect(parsed.type == .tidb) } + @Test("OceanBase scheme parses successfully") + func oceanbaseScheme() { + let result = ConnectionURLParser.parse("oceanbase://root%40sys:pass@localhost:2881/test") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.username == "root@sys") + } + @Test("SQLite scheme parses successfully") func sqliteScheme() { let result = ConnectionURLParser.parse("sqlite:///path/to/database.db") @@ -196,6 +206,17 @@ struct DatabaseURLSchemeTests { #expect(parsed.sshHost == "sshhost") } + @Test("OceanBase+SSH scheme parses successfully") + func oceanbaseSshScheme() { + let result = ConnectionURLParser.parse("oceanbase+ssh://sshuser@sshhost:22/root%40sys:dbpass@dbhost/dbname") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .oceanbase) + #expect(parsed.sshHost == "sshhost") + #expect(parsed.username == "root@sys") + } + // MARK: - Unsupported Schemes @Test("FTP scheme returns unsupported error") diff --git a/TableProTests/Models/DatabaseTypeOceanBaseTests.swift b/TableProTests/Models/DatabaseTypeOceanBaseTests.swift new file mode 100644 index 000000000..dcee5681e --- /dev/null +++ b/TableProTests/Models/DatabaseTypeOceanBaseTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseType OceanBase") +struct DatabaseTypeOceanBaseTests { + @Test("rawValue is OceanBase") + func rawValue() { + #expect(DatabaseType.oceanbase.rawValue == "OceanBase") + } + + @Test("defaultPort is 2881") + func defaultPort() { + #expect(DatabaseType.oceanbase.defaultPort == 2_881) + } + + @Test("iconName is oceanbase-icon") + func iconName() { + #expect(DatabaseType.oceanbase.iconName == "oceanbase-icon") + } + + @Test("pluginTypeId resolves to MySQL") + func pluginTypeIdResolvesToMySQL() { + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") + } + + @Test("triggers browse without trigger editing") + func triggerFlags() { + #expect(DatabaseType.oceanbase.supportsTriggers == true) + #expect(DatabaseType.oceanbase.supportsTriggerEditing == false) + } + + @Test("routines are offered") + func routines() { + #expect(DatabaseType.oceanbase.supportsRoutines == true) + } + + @Test("allKnownTypes contains oceanbase") + func allKnownTypesContainsOceanBase() { + #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) + } + + @Test("Codable round-trips through rawValue") + func codableRoundTrip() throws { + let encoded = try JSONEncoder().encode(DatabaseType.oceanbase) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) + #expect(decoded == DatabaseType.oceanbase) + } +} diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 1e7a3a1e4..ba749d08d 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -57,6 +57,7 @@ struct DatabaseTypeTests { (DatabaseType.mariadb, "MariaDB"), (DatabaseType.tidb, "TiDB"), (DatabaseType.databend, "Databend"), + (DatabaseType.oceanbase, "OceanBase"), (DatabaseType.postgresql, "PostgreSQL"), (DatabaseType.sqlite, "SQLite"), (DatabaseType.mongodb, "MongoDB"), @@ -119,6 +120,11 @@ struct DatabaseTypeTests { #expect(DatabaseType.databend.pluginTypeId == "MySQL") } + @Test("OceanBase pluginTypeId maps to MySQL plugin") + func testOceanBasePluginTypeId() { + #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") + } + @Test("Redshift pluginTypeId maps to PostgreSQL plugin") func testRedshiftPluginTypeId() { #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") @@ -156,7 +162,8 @@ struct DatabaseTypeTests { DatabaseType.mysql, DatabaseType.mariadb, DatabaseType.tidb, - DatabaseType.databend + DatabaseType.databend, + DatabaseType.oceanbase ]) func testMariaDBClientEnginesDefaultSSLPreferred(type: DatabaseType) { #expect(type.defaultSSLMode == .preferred) @@ -194,6 +201,7 @@ struct DatabaseTypeTests { DatabaseType.mariadb, DatabaseType.tidb, DatabaseType.databend, + DatabaseType.oceanbase, DatabaseType.mssql ]) func testOpportunisticTLSSupported(type: DatabaseType) { diff --git a/TableProTests/Plugins/MySQLServerFlavorTests.swift b/TableProTests/Plugins/MySQLServerFlavorTests.swift index efcb1d0f6..7781acfeb 100644 --- a/TableProTests/Plugins/MySQLServerFlavorTests.swift +++ b/TableProTests/Plugins/MySQLServerFlavorTests.swift @@ -17,6 +17,9 @@ struct MySQLServerFlavorTests { ("11.4.2-MariaDB-log", .mariadb), ("8.0.11-TiDB-v7.5.1", .tidb(version: MySQLEngineVersion(major: 7, minor: 5, patch: 1))), ("8.0.11-TiDB-v8.5.1", .tidb(version: MySQLEngineVersion(major: 8, minor: 5, patch: 1))), + ("5.7.25-OceanBase-v4.2.1", .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1))), + ("5.7.25 OceanBase 3.1.3", .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3))), + ("5.7.25-OceanBase_CE-v4.3.5", .oceanbase(version: MySQLEngineVersion(major: 4, minor: 3, patch: 5))), (databendBanner, .databend) ]) func bannerNamesTheEngine(banner: String, expected: MySQLServerFlavor) { @@ -35,13 +38,24 @@ struct MySQLServerFlavorTests { #expect(MySQLServerFlavor.tidbVersion(fromReleaseInfo: "8.0.36") == nil) } - @Test("A TiDB or Databend connection whose banner does not say so is confirmed with a query") + @Test("A TiDB, Databend or OceanBase connection whose banner does not say so is confirmed with a query") func probesOnlyWhenTheBannerIsSilent() { #expect(MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.35", variant: "TiDB")) #expect(!MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.11-TiDB-v7.5.1", variant: "TiDB")) #expect(!MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.35", variant: nil)) #expect(MySQLFlavorResolution.needsDatabendProbe(banner: "8.0.36", variant: "Databend")) #expect(!MySQLFlavorResolution.needsDatabendProbe(banner: Self.databendBanner, variant: "Databend")) + #expect(MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25", variant: "OceanBase")) + #expect(!MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25-OceanBase-v4.2.1", variant: "OceanBase")) + #expect(!MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25-OceanBase-v4.2.1", variant: nil)) + } + + @Test("A MySQL connection whose banner names OceanBase is OceanBase") + func mysqlOpenedOceanBaseIsOceanBase() { + let flavor = MySQLServerFlavor.fromBanner("5.7.25-OceanBase-v4.2.1") + #expect(flavor.isOceanBase) + #expect(flavor.oceanbaseVersion == MySQLEngineVersion(major: 4, minor: 2, patch: 1)) + #expect(!MySQLServerFlavor.fromBanner("5.7.25").isOceanBase) } @Test("System databases are the exact spellings each engine reports") @@ -51,6 +65,11 @@ struct MySQLServerFlavorTests { "INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys" ]) #expect(MySQLServerFlavor.databend.systemDatabaseNames == ["information_schema", "system"]) + #expect(MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames == [ + "information_schema", "mysql", "oceanbase" + ]) + #expect(!MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames.contains("test")) + #expect(!MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames.contains("performance_schema")) } @Test("TiDB and Databend offer only the maintenance they support") @@ -58,6 +77,7 @@ struct MySQLServerFlavorTests { #expect(MySQLServerFlavor.mysql.maintenanceOperations.count == 4) #expect(MySQLServerFlavor.tidb(version: nil).maintenanceOperations == ["ANALYZE TABLE"]) #expect(MySQLServerFlavor.databend.maintenanceOperations == ["ANALYZE TABLE"]) + #expect(MySQLServerFlavor.oceanbase(version: nil).maintenanceOperations == ["ANALYZE TABLE"]) } @Test("TiDB sequences cannot be browsed, so they are not listed as tables") @@ -72,6 +92,7 @@ struct MySQLServerFlavorTests { #expect(!MySQLServerFlavor.mysql.dropsIdleSessionOnKillQuery) #expect(!MySQLServerFlavor.mariadb.dropsIdleSessionOnKillQuery) #expect(!MySQLServerFlavor.databend.dropsIdleSessionOnKillQuery) + #expect(!MySQLServerFlavor.oceanbase(version: nil).dropsIdleSessionOnKillQuery) } @Test("Only Databend refuses server-side prepare") @@ -79,6 +100,7 @@ struct MySQLServerFlavorTests { #expect(!MySQLServerFlavor.databend.preparesOnServer) #expect(MySQLServerFlavor.tidb(version: nil).preparesOnServer) #expect(MySQLServerFlavor.mysql.preparesOnServer) + #expect(MySQLServerFlavor.oceanbase(version: nil).preparesOnServer) } @Test("A read-write transaction declares the access mode so a read-only session default is overridden") @@ -104,6 +126,7 @@ struct MySQLServerFlavorTests { (.mysql, 0, "SET SESSION max_execution_time = 0"), (.mysql, 30, "SET SESSION max_execution_time = 30000"), (.tidb(version: nil), 30, "SET SESSION max_execution_time = 30000"), + (.oceanbase(version: nil), 30, "SET SESSION max_execution_time = 30000"), (.databend, 30, "SET max_execute_time_in_seconds = 30") ]) func queryTimeout(flavor: MySQLServerFlavor, seconds: Int, expected: String) { @@ -120,11 +143,14 @@ struct MySQLServerFlavorTests { #expect(!MySQLServerFlavor.databend.isInterruptedByKill(errno: 1_105, message: "SyntaxException. Code: 1005")) } - @Test("TiDB kills by its 64-bit connection id and Databend by its session id") + @Test("TiDB kills by its 64-bit connection id, OceanBase by CONNECTION_ID, Databend by its session id") func killStatements() { let tidb = MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: "2199023255571") #expect(tidb.statement(threadId: 19) == "KILL TIDB QUERY 2199023255571") + let oceanbase = MySQLServerFlavor.oceanbase(version: nil).killTarget(connectionIdentifier: "3221490143") + #expect(oceanbase.statement(threadId: 19) == "KILL QUERY 3221490143") + let databend = MySQLServerFlavor.databend.killTarget(connectionIdentifier: "1f7e5199-119f-484a-aaff-e8d5143a7979") #expect(databend.statement(threadId: 89) == "KILL QUERY '1f7e5199-119f-484a-aaff-e8d5143a7979'") @@ -137,6 +163,7 @@ struct MySQLServerFlavorTests { func killFallsBackToThreadId() { #expect(MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: nil) == .threadId) #expect(MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: "not-a-number") == .threadId) + #expect(MySQLServerFlavor.oceanbase(version: nil).killTarget(connectionIdentifier: nil) == .threadId) #expect(MySQLServerFlavor.databend.killTarget(connectionIdentifier: "") == .threadId) } @@ -157,5 +184,25 @@ struct MySQLServerFlavorTests { func databendGenerationExpression() { #expect(!MySQLServerVersion.hasGenerationExpression(banner: Self.databendBanner, flavor: .databend)) #expect(MySQLServerVersion.hasGenerationExpression(banner: "8.0.11-TiDB-v7.5.1", flavor: .tidb(version: nil))) + #expect(MySQLServerVersion.hasGenerationExpression( + banner: "5.7.25-OceanBase-v4.2.1", + flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1)) + )) + #expect(!MySQLServerVersion.hasGenerationExpression( + banner: "5.7.25 OceanBase 3.1.3", + flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) + )) + #expect(!MySQLServerVersion.hasGenerationExpression( + banner: "5.7.25", + flavor: .oceanbase(version: nil) + )) + #expect(MySQLServerVersion.hasCheckConstraints( + banner: "5.7.25-OceanBase-v4.2.1", + flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 0, patch: 0)) + )) + #expect(!MySQLServerVersion.hasCheckConstraints( + banner: "5.7.25 OceanBase 3.1.3", + flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) + )) } } diff --git a/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift b/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift new file mode 100644 index 000000000..5a2c2fb78 --- /dev/null +++ b/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift @@ -0,0 +1,53 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("OceanBase hidden primary key") +struct OceanBaseHiddenPrimaryKeyTests { + @Test("A SHOW INDEX PRIMARY column missing from SHOW COLUMNS is attached as BIGINT PK") + func attachesHiddenPrimary() { + let visible = [ + PluginColumnInfo(name: "name", dataType: "VARCHAR(32)", isNullable: true, isPrimaryKey: false) + ] + let attached = OceanBaseHiddenPrimaryKey.attaching( + to: visible, + primaryIndexColumns: ["__pk_increment"] + ) + #expect(attached.map(\.name) == ["name", "__pk_increment"]) + let hidden = attached[1] + #expect(hidden.isPrimaryKey) + #expect(hidden.dataType == "BIGINT") + #expect(hidden.identityKind == .always) + #expect(!hidden.isNullable) + } + + @Test("A visible primary key is left alone") + func visiblePrimaryIsUnchanged() { + let visible = [ + PluginColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true), + PluginColumnInfo(name: "name", dataType: "VARCHAR(32)", isNullable: true, isPrimaryKey: false) + ] + let attached = OceanBaseHiddenPrimaryKey.attaching( + to: visible, + primaryIndexColumns: ["id"] + ) + #expect(attached.map(\.name) == ["id", "name"]) + } + + @Test("An already-visible hidden name is not duplicated") + func doesNotDuplicate() { + let visible = [ + PluginColumnInfo( + name: "__pk_increment", + dataType: "BIGINT", + isNullable: false, + isPrimaryKey: true + ) + ] + let attached = OceanBaseHiddenPrimaryKey.attaching( + to: visible, + primaryIndexColumns: ["__pk_increment"] + ) + #expect(attached.count == 1) + } +} diff --git a/TableProTests/Plugins/OceanBaseSQLTests.swift b/TableProTests/Plugins/OceanBaseSQLTests.swift new file mode 100644 index 000000000..ce91f19c1 --- /dev/null +++ b/TableProTests/Plugins/OceanBaseSQLTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@Suite("OceanBase SQL rewrite") +struct OceanBaseSQLTests { + private func quote(_ name: String) -> String { + "`\(name.replacingOccurrences(of: "`", with: "``"))`" + } + + @Test("The visibility hint sits after the first verb") + func hintFollowsTheVerb() { + #expect( + OceanBaseSQL.withHiddenColumnVisibilityHint("SELECT * FROM t") + == "SELECT \(OceanBaseSQL.visibilityHint) * FROM t" + ) + #expect( + OceanBaseSQL.withHiddenColumnVisibilityHint("UPDATE t SET a = 1 WHERE b = 2") + == "UPDATE \(OceanBaseSQL.visibilityHint) t SET a = 1 WHERE b = 2" + ) + #expect( + OceanBaseSQL.withHiddenColumnVisibilityHint("DELETE FROM t WHERE id = 1") + == "DELETE \(OceanBaseSQL.visibilityHint) FROM t WHERE id = 1" + ) + } + + @Test("SHOW, SET and KILL are left alone") + func sessionCommandsAreNotHinted() { + for sql in ["SHOW FULL COLUMNS FROM t", "SET SESSION max_execution_time = 1000", "KILL QUERY 42"] { + #expect(OceanBaseSQL.withHiddenColumnVisibilityHint(sql) == sql) + } + } + + @Test("A statement that already carries the hint is not hinted again") + func existingHintIsKept() { + let sql = "SELECT \(OceanBaseSQL.visibilityHint) `__pk_increment` FROM t LIMIT 0" + #expect(OceanBaseSQL.withHiddenColumnVisibilityHint(sql) == sql) + } + + @Test("SELECT * from a table expands with hidden primary key columns") + func selectStarProjectsHiddenKeys() { + let rewritten = OceanBaseSQL.projectingHiddenPrimaryKeys( + sql: "SELECT * FROM orders", + hiddenColumns: ["__pk_increment"], + quote: quote + ) + #expect(rewritten == "SELECT \(OceanBaseSQL.visibilityHint) *, `__pk_increment` FROM orders") + } + + @Test("SELECT * from a subquery is not expanded") + func subquerySelectStarIsNotExpanded() { + let sql = "SELECT * FROM (SELECT 1) AS x" + #expect(OceanBaseSQL.simpleSelectStarTable(from: sql) == nil) + #expect( + OceanBaseSQL.projectingHiddenPrimaryKeys(sql: sql, hiddenColumns: ["__pk_increment"], quote: quote) + == "SELECT \(OceanBaseSQL.visibilityHint) * FROM (SELECT 1) AS x" + ) + } + + @Test("A qualified identifier keeps the table name, not the schema") + func qualifiedTableName() { + #expect(OceanBaseSQL.simpleSelectStarTable(from: "SELECT * FROM `test`.`orders`") == "orders") + #expect(OceanBaseSQL.simpleSelectStarTable(from: "select * from orders WHERE 1") == "orders") + } + + @Test("Hidden primary key names are the ones SHOW INDEX lists that SHOW COLUMNS omits") + func hiddenNamesComeFromThePrimaryIndex() { + #expect( + OceanBaseSQL.hiddenPrimaryKeyColumns( + primaryIndexColumns: ["__pk_increment"], + visibleColumnNames: ["id", "name"] + ) == ["__pk_increment"] + ) + #expect( + OceanBaseSQL.hiddenPrimaryKeyColumns( + primaryIndexColumns: ["id"], + visibleColumnNames: ["id", "name"] + ).isEmpty + ) + #expect( + OceanBaseSQL.hiddenPrimaryKeyColumns( + primaryIndexColumns: ["__pk_increment", "__pk_cluster_column"], + visibleColumnNames: ["name"] + ) == ["__pk_increment", "__pk_cluster_column"] + ) + } + + @Test("The documented probe names a hidden column under the visibility hint") + func documentedProbe() { + #expect( + OceanBaseSQL.documentedHiddenColumnProbe(table: "orders", column: "__pk_increment", quote: quote) + == "SELECT \(OceanBaseSQL.visibilityHint) `__pk_increment` FROM `orders` LIMIT 0" + ) + } +} diff --git a/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift index f530ce4ad..1dbb34629 100644 --- a/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift +++ b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift @@ -20,11 +20,13 @@ struct StructureColumnFieldRegistrationTests { let mysql = PluginManager.shared.structureColumnFields(for: .mysql) let mariadb = PluginManager.shared.structureColumnFields(for: .mariadb) let tidb = PluginManager.shared.structureColumnFields(for: .tidb) + let oceanbase = PluginManager.shared.structureColumnFields(for: .oceanbase) #expect(Set(mysql) == Set(mariadb)) #expect(Set(mysql) == Set(tidb)) + #expect(Set(mysql) == Set(oceanbase)) } - @Test("MySQL-protocol engines with on update offer it", arguments: [DatabaseType.mysql, .mariadb, .tidb]) + @Test("MySQL-protocol engines with on update offer it", arguments: [DatabaseType.mysql, .mariadb, .tidb, .oceanbase]) func onUpdateIsOffered(databaseType: DatabaseType) { #expect(PluginManager.shared.structureColumnFields(for: databaseType).contains(.onUpdate)) } diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index adf4952ce..82deff096 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -111,6 +111,7 @@ Metadata connections, the extra ones TablePro opens to read a database's object | [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | Yes | Yes | | [TiDB](/databases/tidb) | 4000 | Yes | Yes | Yes | No | Yes | Yes | | [Databend](/databases/databend) | 3307 | Yes | Yes | Yes | No | Yes | Yes | +| [OceanBase](/databases/oceanbase) | 2881 | Yes | Yes | Yes | No | Yes | Yes | | [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | Yes | | [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | Yes | | [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | Yes | diff --git a/docs/connections/urls.mdx b/docs/connections/urls.mdx index b2cc1cc16..8ec370e57 100644 --- a/docs/connections/urls.mdx +++ b/docs/connections/urls.mdx @@ -19,6 +19,7 @@ Two readers take these URLs, and not the same set. The **Import from URL…** sh | `mysql://` | MySQL | Yes | | `mariadb://` | MariaDB | Yes | | `tidb://` | TiDB | Yes | +| `oceanbase://` | OceanBase | Yes | | `sqlite://` | SQLite | Yes | | `mongodb://` | MongoDB | Yes | | `mongodb+srv://` | MongoDB (SRV) | Yes | diff --git a/docs/databases/index.mdx b/docs/databases/index.mdx index c0d2e822b..4d8db69d1 100644 --- a/docs/databases/index.mdx +++ b/docs/databases/index.mdx @@ -1,11 +1,11 @@ --- title: Supported Databases -description: All 34 engines TablePro connects to, their default ports, and which ones need a plugin +description: All 35 engines TablePro connects to, their default ports, and which ones need a plugin --- import DriverCounts from "/snippets/driver-counts.mdx"; -Thirty-four engines, and every one of them is free to use. What differs between them is where the +Thirty-five engines, and every one of them is free to use. What differs between them is where the driver comes from, not what the license covers. @@ -34,6 +34,7 @@ driver comes from, not what the license covers. | [Microsoft SQL Server](/databases/mssql) | 1433 | Plugin | | [MongoDB](/databases/mongodb) | 27017 | Plugin | | [MySQL](/databases/mysql) | 3306 | Built-in | +| [OceanBase](/databases/oceanbase) | 2881 | Built-in | | [Oracle Database](/databases/oracle) | 1521 | Plugin | | [PGlite](/databases/pglite) | 5432 | Built-in | | [PostgreSQL](/databases/postgresql) | 5432 | Built-in | @@ -50,8 +51,8 @@ driver comes from, not what the license covers. | [Turso](/databases/libsql) | API-based | Plugin | Rows sharing a page share a driver. MariaDB reads as MySQL, ScyllaDB as Cassandra, Turso as libSQL, -and Redshift, CockroachDB and PGlite all speak the PostgreSQL wire protocol. TiDB and Databend -also run on the bundled MySQL driver. +and Redshift, CockroachDB and PGlite all speak the PostgreSQL wire protocol. TiDB, Databend and +OceanBase also run on the bundled MySQL driver. ## Built-in against plugin diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index a930f51ce..78842a350 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -3,7 +3,7 @@ title: MySQL description: Connect to MySQL 5.7 and later with the bundled MariaDB Connector/C driver --- -MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no auth plugin to switch over. The same bundled driver covers 5.7 and later, plus [MariaDB](/databases/mariadb), [TiDB](/databases/tidb), and [Databend](/databases/databend). +MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no auth plugin to switch over. The same bundled driver covers 5.7 and later, plus [MariaDB](/databases/mariadb), [TiDB](/databases/tidb), [Databend](/databases/databend), and [OceanBase](/databases/oceanbase). ## Quick setup diff --git a/docs/databases/oceanbase.mdx b/docs/databases/oceanbase.mdx new file mode 100644 index 000000000..be783601e --- /dev/null +++ b/docs/databases/oceanbase.mdx @@ -0,0 +1,59 @@ +--- +title: OceanBase +description: Connect to OceanBase MySQL mode through the bundled MySQL driver on port 2881 +--- + +Sign in as `user@tenant`. The sys tenant's root is `root@sys`. Oracle compatibility mode is a different protocol and does not connect. + +No minimum OceanBase version is enforced. Host, password, SSH tunnels, SSL/TLS and `Cmd+K` database switching work as on [MySQL](/databases/mysql). + +## Connection settings + +| Field | Default | Notes | +|-------|---------|-------| +| **Port** | `2881` | The SQL port for MySQL mode. Do not point at an OBProxy extras port unless that is the SQL listener | +| **Database** | empty | Optional. Leave it empty to browse every database in the tenant | +| **SSL Mode** | Preferred | TLS first, plain text if the server refuses | +| **Username** | empty | `user@tenant`. In a URL, write the `@` as `%40`: `root%40sys` | + +**Release the Server Connection After** is available under Advanced. AWS IAM, Cloud SQL Auth Proxy and Unix socket are not offered; reach a private cluster through an [SSH tunnel](/connections/ssh-tunneling). + +## Connection URL + +```text +oceanbase://root%40sys:password@host:2881/database +``` + +`oceanbase+ssh://` opens it through an SSH tunnel. See [Connection URL Reference](/connections/urls). + +## Hidden primary keys + +A table with no explicit primary key still has one: `__pk_increment`, and on some clusters `__pk_cluster_column`. `SHOW FULL COLUMNS` and a plain `SELECT *` omit those names, so a grid save that matches on visible columns cannot find the row. + +Opening the table projects the hidden key into the result. Stop and parameterized `UPDATE` / `DELETE` then match on it. The column is not editable. + +A connection saved as **MySQL** that reaches an OceanBase server takes the same path. The sidebar, type picker and EXPLAIN follow the connection type, so choose **OceanBase** for those. + +## What the MySQL driver does not copy + +- The sidebar hides `information_schema`, `mysql` and `oceanbase`. User databases such as `test` stay listed. +- **Stop** sends `KILL QUERY` for `CONNECTION_ID()`, which is wider than the 32-bit handshake thread id. +- `EXPLAIN` is plain text. `EXPLAIN FORMAT=JSON` is not offered. +- The type picker has MySQL's types without the Spatial group. +- [Users & Roles](/features/users-roles) has no connection limit field. +- Table Maintenance offers `ANALYZE TABLE` only. +- Foreign keys can be created. `SET FOREIGN_KEY_CHECKS` is not sent. Triggers appear in the Structure tab; adding one from there is not offered. + +## Limitations + +- Oracle compatibility mode does not connect. Use a MySQL-mode tenant and the MySQL protocol port. +- No Server Dashboard, and **File > Backup Dump…** stays dimmed. +- [Compare & Sync](/features/compare-sync) writes no structure script between OceanBase and MySQL or MariaDB. OceanBase against OceanBase works. +- Cluster tenant administration is out of scope. + +## Related + +- [MySQL](/databases/mysql), for connection fields, SSL/TLS, and troubleshooting +- [MariaDB](/databases/mariadb) +- [TiDB](/databases/tidb) +- [SSH Tunneling](/connections/ssh-tunneling) diff --git a/docs/docs.json b/docs/docs.json index 43abd690e..a10fea0e0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -164,6 +164,7 @@ "databases/mssql", "databases/mongodb", "databases/mysql", + "databases/oceanbase", "databases/oracle", "databases/pglite", "databases/postgresql", diff --git a/docs/index.mdx b/docs/index.mdx index 4f67682ea..062214abf 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 29 more +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 30 more --- import DriverCounts from "/snippets/driver-counts.mdx"; diff --git a/docs/snippets/driver-counts.mdx b/docs/snippets/driver-counts.mdx index 36442e09d..a6c7ae26f 100644 --- a/docs/snippets/driver-counts.mdx +++ b/docs/snippets/driver-counts.mdx @@ -1,3 +1,3 @@ -Five drivers ship inside the app and cover eleven databases. Twenty-one registry plugins cover the +Five drivers ship inside the app and cover twelve databases. Twenty-one registry plugins cover the other twenty-three and install on the first connection that needs one. See [Plugins & Themes](/features/plugins). diff --git a/project.yml b/project.yml index e08d09fab..35c34f4d5 100644 --- a/project.yml +++ b/project.yml @@ -498,6 +498,8 @@ targets: - Plugins/MySQLDriverPlugin/DatabendLiteral.swift - Plugins/MySQLDriverPlugin/DatabendResultShape.swift - Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift + - Plugins/MySQLDriverPlugin/OceanBaseSQL.swift + - Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift - Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift - Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift - Plugins/SQLiteDriverPlugin/SQLiteDefaultValue.swift From 5327a068c90c0983c7a655754129b80b86cd2ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 11 Sep 2026 16:48:58 +0000 Subject: [PATCH 02/15] fix(plugin-mysql): spell OceanBase binary literals as MySQL bit-strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- TablePro/Core/Compare/CompareSQLLiteral.swift | 4 ++-- .../Services/ProjectImport/ProjectYamlExtractorTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Compare/CompareSQLLiteral.swift b/TablePro/Core/Compare/CompareSQLLiteral.swift index 5d5118e68..e6b8d7d6e 100644 --- a/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -56,8 +56,8 @@ internal enum CompareSQLLiteral { /// name falls back to the driver's own spelling rather than guessing at one. internal static func binaryStyle(for databaseType: DatabaseType) -> BinaryStyle { switch databaseType { - case .mysql, .mariadb, .tidb, .databend, .sqlite, .clickhouse, .duckdb, .libsql, .turso, - .cloudflareD1: + case .mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .duckdb, .libsql, + .turso, .cloudflareD1: return .bitString case .postgresql, .cockroachdb, .redshift, .pglite: return .postgresBytea diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index 8b4817e73..c3bbc9742 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -272,7 +272,7 @@ struct DockerComposeExtractorTests { - "2881:2881" """).first #expect(oceanbase?.parsedURL.type == .oceanbase) - #expect(oceanbase?.parsedURL.port == nil) + #expect(oceanbase?.parsedURL.port == 2_881) #expect(oceanbase?.parsedURL.username == "root@sys") #expect(oceanbase?.parsedURL.password.isEmpty == true) #expect(oceanbase?.parsedURL.database.isEmpty == true) From af1c60ec16163fadbda563b8f11a0751f8006d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 11 Sep 2026 17:03:45 +0000 Subject: [PATCH 03/15] ci(plugin-mysql): retrigger macOS tests after XcodeGen checksum flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long From c0fe860c3776b4d7778ebbba6bf06eb2237a69fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 12 Sep 2026 07:54:04 +0700 Subject: [PATCH 04/15] refactor(plugin-mysql): identify OceanBase by its version comment and edit keyless tables as MySQL does --- CHANGELOG.md | 2 +- .../MySQLDriverPlugin/MySQLKillTarget.swift | 8 +- .../MySQLPluginDriver+Flavor.swift | 20 +-- .../MySQLPluginDriver+OceanBase.swift | 86 ----------- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 22 +-- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 35 +++-- .../OceanBaseHiddenPrimaryKey.swift | 33 ---- Plugins/MySQLDriverPlugin/OceanBaseSQL.swift | 141 ------------------ ...etadataRegistry+MySQLVariantDefaults.swift | 61 +++++--- .../DockerComposeExtractor.swift | 23 ++- ...ementGeneratorOceanBaseHiddenPKTests.swift | 61 -------- .../Plugins/MySQLVariantSupportTests.swift | 7 +- .../ProjectYamlExtractorTests.swift | 43 ++++++ .../Plugins/MySQLServerFlavorTests.swift | 61 +++++--- .../OceanBaseHiddenPrimaryKeyTests.swift | 53 ------- TableProTests/Plugins/OceanBaseSQLTests.swift | 94 ------------ docs/databases/oceanbase.mdx | 10 +- docs/external-api/ios-shortcuts.mdx | 6 +- docs/ios/index.mdx | 5 +- project.yml | 2 - 20 files changed, 187 insertions(+), 586 deletions(-) delete mode 100644 Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift delete mode 100644 Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift delete mode 100644 Plugins/MySQLDriverPlugin/OceanBaseSQL.swift delete mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift delete mode 100644 TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift delete mode 100644 TableProTests/Plugins/OceanBaseSQLTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b014ed2..fa16fa6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- OceanBase MySQL-mode connection type, including grid saves on servers opened as MySQL. (#1748) +- OceanBase MySQL-mode connection type. (#1748) - Google Cloud Spanner as a registry plugin over the REST API. (#1226, #2480) - TiDB and Databend connection types on the MySQL driver. (#1066, #2514) - Empty state in the inspector and the assistant for a connection that is not up. diff --git a/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift b/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift index 19b9362d6..2eaa49764 100644 --- a/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift +++ b/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift @@ -4,7 +4,6 @@ internal enum MySQLKillTarget: Equatable, Sendable { case threadId case tidbConnection(UInt64) case databendSession(String) - case oceanbaseConnection(UInt64) func statement(threadId: UInt) -> String? { switch self { @@ -14,8 +13,6 @@ internal enum MySQLKillTarget: Equatable, Sendable { return "KILL TIDB QUERY \(id)" case .databendSession(let session): return "KILL QUERY '\(mysqlEscapeStringLiteral(session))'" - case .oceanbaseConnection(let id): - return "KILL QUERY \(id)" } } } @@ -29,10 +26,7 @@ internal extension MySQLServerFlavor { case .databend: guard let session = connectionIdentifier, !session.isEmpty else { return .threadId } return .databendSession(session) - case .oceanbase: - guard let id = connectionIdentifier.flatMap(UInt64.init) else { return .threadId } - return .oceanbaseConnection(id) - case .mysql, .mariadb: + case .mysql, .mariadb, .oceanbase: return .threadId } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index 1d177e64e..aca207660 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -10,6 +10,7 @@ internal struct MySQLFlavorMismatchError: Error, Equatable { enum Kind: Equatable { case databendNeedsItsOwnType case notDatabend + case notOceanBase } let kind: Kind @@ -22,6 +23,8 @@ extension MySQLFlavorMismatchError: PluginDriverError { return String(localized: "This server is Databend. Edit the connection and choose Databend as its type.") case .notDatabend: return String(localized: "This server did not identify as Databend. Check the host and port of its MySQL handler.") + case .notOceanBase: + return String(localized: "This server did not identify as OceanBase. Check the host and port of its MySQL mode tenant.") } } } @@ -56,18 +59,11 @@ extension MySQLPluginDriver { } if variant == MySQLServerFlavor.oceanbaseVariant { - if MySQLFlavorResolution.needsOceanBaseProbe(banner: banner, variant: variant) { - if let comment = await firstValue(of: MySQLFlavorResolution.oceanbaseProbe, on: connection), - let version = MySQLServerFlavor.oceanbaseVersion(fromBanner: comment) { - return .oceanbase(version: version) - } - return .oceanbase(version: nil) + guard let comment = await firstValue(of: MySQLFlavorResolution.oceanbaseProbe, on: connection), + MySQLServerFlavor.namesOceanBase(comment) else { + throw MySQLFlavorMismatchError(kind: .notOceanBase) } - return bannerFlavor.isOceanBase ? bannerFlavor : .oceanbase(version: nil) - } - - if bannerFlavor.isOceanBase { - return bannerFlavor + return .oceanbase(version: MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment)) } guard MySQLFlavorResolution.needsTiDBVersionProbe(banner: banner, variant: variant) else { @@ -81,7 +77,7 @@ extension MySQLPluginDriver { } func killTarget(for flavor: MySQLServerFlavor, on connection: MariaDBPluginConnection) async -> MySQLKillTarget { - guard flavor.isTiDB || flavor.isDatabend || flavor.isOceanBase else { return .threadId } + guard flavor.isTiDB || flavor.isDatabend else { return .threadId } let identifier = await firstValue(of: MySQLFlavorResolution.connectionIdentifierProbe, on: connection) return flavor.killTarget(connectionIdentifier: identifier) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift deleted file mode 100644 index c7db8b9e5..000000000 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBase.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import TableProPluginKit - -extension MySQLPluginDriver { - func rewriteOceanBaseQueryIfNeeded(_ query: String) async -> String { - guard flavor.isOceanBase else { return query } - if let table = OceanBaseSQL.simpleSelectStarTable(from: query) { - let hidden = await oceanbaseHiddenPrimaryKeyColumns(for: table) - return OceanBaseSQL.projectingHiddenPrimaryKeys( - sql: query, - hiddenColumns: hidden, - quote: quoteIdentifier - ) - } - return OceanBaseSQL.withHiddenColumnVisibilityHint(query) - } - - func attachingOceanBaseHiddenPrimaryKeys( - to columns: [PluginColumnInfo], - table: String - ) async throws -> [PluginColumnInfo] { - let indexes = try await fetchIndexes(table: table, schema: nil) - let primaryColumns = indexes.first(where: \.isPrimary)?.columns ?? [] - var attached = OceanBaseHiddenPrimaryKey.attaching( - to: columns, - primaryIndexColumns: primaryColumns - ) - let visibleNames = Set(columns.map(\.name)) - if attached.count == columns.count, !columns.contains(where: \.isPrimaryKey) { - for name in OceanBaseSQL.documentedHiddenNames where !visibleNames.contains(name) { - if await oceanbaseColumnExists(name, table: table) { - attached.append(OceanBaseHiddenPrimaryKey.synthesizedColumn(named: name)) - } - } - } - let hiddenNames = attached.compactMap { column in - visibleNames.contains(column.name) ? nil : column.name - } - rememberOceanBaseHiddenPrimaryKeys(hiddenNames, for: table) - return attached - } - - func attachingOceanBaseHiddenPrimaryKeys( - to allColumns: [String: [PluginColumnInfo]], - primaryColumnsByTable: [String: [String]] - ) -> [String: [PluginColumnInfo]] { - var merged = allColumns - for (table, columns) in allColumns { - let attached = OceanBaseHiddenPrimaryKey.attaching( - to: columns, - primaryIndexColumns: primaryColumnsByTable[table] ?? [] - ) - merged[table] = attached - let hiddenNames = attached.compactMap { column in - columns.contains(where: { $0.name == column.name }) ? nil : column.name - } - rememberOceanBaseHiddenPrimaryKeys(hiddenNames, for: table) - } - return merged - } - - private func oceanbaseHiddenPrimaryKeyColumns(for table: String) async -> [String] { - if let cached = cachedOceanBaseHiddenPrimaryKeys(for: table) { - return cached - } - do { - _ = try await fetchColumns(table: table, schema: nil) - } catch { - return [] - } - return cachedOceanBaseHiddenPrimaryKeys(for: table) ?? [] - } - - private func oceanbaseColumnExists(_ column: String, table: String) async -> Bool { - do { - _ = try await execute(query: OceanBaseSQL.documentedHiddenColumnProbe( - table: table, - column: column, - quote: quoteIdentifier - )) - return true - } catch { - return false - } - } -} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 91c53e139..5e07d7746 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -60,8 +60,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// connection between `requireConnection` returning and the query reaching the server. private var activeOperations = 0 - private var oceanbaseHiddenPrimaryKeys: [String: [String]] = [:] - internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") var currentSchema: String? { nil } @@ -216,7 +214,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let parameters else { return try await executeWithReconnect(query: query, isRetry: false, rowCap: cap) } - let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } noteActivity(query) @@ -243,7 +240,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { - let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } noteActivity(query) @@ -280,7 +276,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { countsAsActivity: Bool = true ) async throws -> PluginQueryResult { let startTime = Date() - let query = await rewriteOceanBaseQueryIfNeeded(query) let conn = try await requireConnection() defer { endOperation() } @@ -508,7 +503,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: "SHOW FULL COLUMNS FROM \(quoteIdentifier(table))") let generationExpressions = try await fetchGenerationExpressions(table: table) - let columns = result.rows.compactMap { row -> PluginColumnInfo? in + return result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, let dataType = row[safe: 1]?.asText else { return nil } @@ -550,8 +545,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { generationKind: mysqlGenerationKind(extra: extra) ) } - guard flavor.isOceanBase else { return columns } - return try await attachingOceanBaseHiddenPrimaryKeys(to: columns, table: table) } private func fetchGenerationExpressions(table: String) async throws -> [String: String] { @@ -688,10 +681,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { allColumns[tableName, default: []].append(column) } - guard flavor.isOceanBase else { return allColumns } - let indexes = try await fetchAllIndexes(schema: schema) - let primaryByTable = indexes.mapValues { $0.first(where: \.isPrimary)?.columns ?? [] } - return attachingOceanBaseHiddenPrimaryKeys(to: allColumns, primaryColumnsByTable: primaryByTable) + return allColumns } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { @@ -1265,14 +1255,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Private Helpers - func cachedOceanBaseHiddenPrimaryKeys(for table: String) -> [String]? { - sessionLock.withLock { oceanbaseHiddenPrimaryKeys[table] } - } - - func rememberOceanBaseHiddenPrimaryKeys(_ columns: [String], for table: String) { - sessionLock.withLock { oceanbaseHiddenPrimaryKeys[table] = columns } - } - private func extractTableName(from query: String) -> String? { guard let regex = Self.tableNameRegex, let match = regex.firstMatch(in: query, range: NSRange(query.startIndex..., in: query)), diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index cf728545a..98c5b13f6 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -44,9 +44,6 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { if let version = tidbVersion(fromBanner: banner) { return .tidb(version: version) } - if banner.range(of: "oceanbase", options: .caseInsensitive) != nil { - return .oceanbase(version: oceanbaseVersion(fromBanner: banner)) - } if isDatabendBanner(banner) { return .databend } @@ -68,15 +65,16 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { banner.range(of: #"^\d+\.\d+\.\d+-v\d+\.\d+\.\d+-"#, options: .regularExpression) != nil } - static func oceanbaseVersion(fromBanner banner: String) -> MySQLEngineVersion? { - guard banner.range(of: "oceanbase", options: .caseInsensitive) != nil else { return nil } - if let marker = banner.range(of: "OceanBase_CE-v", options: .caseInsensitive) - ?? banner.range(of: "OceanBase-v", options: .caseInsensitive) { - return MySQLEngineVersion(parsing: banner[marker.upperBound...]) - } - guard let name = banner.range(of: "OceanBase", options: .caseInsensitive) else { return nil } - var rest = banner[name.upperBound...] - rest = rest.drop(while: { $0.isLetter || $0 == "_" || $0 == "-" || $0.isWhitespace }) + /// The MySQL handshake banner is OceanBase's `_display_mysql_version`, which is `5.7.25` on a + /// direct connection and `5.6.25` through OBProxy: it never names the engine. `@@version_comment` + /// is what does, as `OceanBase_CE 4.4.2.1 (r...)` or `OceanBase 3.1.3 (r...)`. + static func namesOceanBase(_ versionComment: String) -> Bool { + versionComment.range(of: "oceanbase", options: .caseInsensitive) != nil + } + + static func oceanbaseVersion(fromVersionComment comment: String) -> MySQLEngineVersion? { + guard let name = comment.range(of: "OceanBase", options: .caseInsensitive) else { return nil } + let rest = comment[name.upperBound...].drop { $0.isLetter || $0 == "_" || $0 == "-" || $0.isWhitespace } return MySQLEngineVersion(parsing: rest) } @@ -137,12 +135,21 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { return mode == .readWrite ? "START TRANSACTION READ WRITE" : "START TRANSACTION" } + /// OceanBase enforces `ob_query_timeout` of its own, 10 seconds by default, and + /// `max_execution_time` only outranks it while it is above zero. Zero is the setting's "no + /// limit", which an export relies on, so it has to lift OceanBase's own limit as well. The + /// server clamps the value it accepts there and warns; this is what it clamps to. + static let oceanbaseUnlimitedQueryTimeoutMicroseconds = 3_216_672_000_000_000 + func queryTimeoutStatement(seconds: Int) -> String { switch self { case .mariadb: return "SET SESSION max_statement_time = \(seconds)" case .databend: return "SET max_execute_time_in_seconds = \(seconds)" + case .oceanbase where seconds <= 0: + return "SET SESSION max_execution_time = 0, ob_query_timeout = " + + "\(Self.oceanbaseUnlimitedQueryTimeoutMicroseconds)" case .mysql, .tidb, .oceanbase: return "SET SESSION max_execution_time = \(seconds * 1_000)" } @@ -177,10 +184,6 @@ nonisolated internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } - static func needsOceanBaseProbe(banner: String?, variant: String?) -> Bool { - variant == MySQLServerFlavor.oceanbaseVariant && !MySQLServerFlavor.fromBanner(banner).isOceanBase - } - static let tidbVersionProbe = "SELECT tidb_version()" static let databendProbe = "SELECT value FROM system.settings WHERE name = 'max_result_rows'" static let oceanbaseProbe = "SELECT @@version_comment" diff --git a/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift b/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift deleted file mode 100644 index 49b3d6ce7..000000000 --- a/Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import TableProPluginKit - -internal enum OceanBaseHiddenPrimaryKey { - static func synthesizedColumn(named name: String) -> PluginColumnInfo { - PluginColumnInfo( - name: name, - dataType: "BIGINT", - isNullable: false, - isPrimaryKey: true, - defaultValue: nil, - extra: "auto_increment", - identityKind: .always, - isGenerated: false, - allowedValues: nil, - generationExpression: nil, - generationKind: nil - ) - } - - static func attaching( - to columns: [PluginColumnInfo], - primaryIndexColumns: [String] - ) -> [PluginColumnInfo] { - let visible = Set(columns.map(\.name)) - let hidden = OceanBaseSQL.hiddenPrimaryKeyColumns( - primaryIndexColumns: primaryIndexColumns, - visibleColumnNames: visible - ) - guard !hidden.isEmpty else { return columns } - return columns + hidden.map(synthesizedColumn(named:)) - } -} diff --git a/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift b/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift deleted file mode 100644 index 321dd6531..000000000 --- a/Plugins/MySQLDriverPlugin/OceanBaseSQL.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation - -internal enum OceanBaseSQL { - static let visibilityHint = "/*+ opt_param('hidden_column_visible','true') */" - static let incrementColumn = "__pk_increment" - static let clusterColumn = "__pk_cluster_column" - static let documentedHiddenNames = [incrementColumn, clusterColumn] - - private static let hintedVerbs: Set = [ - "SELECT", "UPDATE", "DELETE", "INSERT", "REPLACE" - ] - - static func isDocumentedHiddenName(_ name: String) -> Bool { - documentedHiddenNames.contains(name) - } - - static func hiddenPrimaryKeyColumns( - primaryIndexColumns: [String], - visibleColumnNames: Set - ) -> [String] { - var seen = Set() - var hidden: [String] = [] - for column in primaryIndexColumns where !visibleColumnNames.contains(column) { - guard seen.insert(column).inserted else { continue } - hidden.append(column) - } - return hidden - } - - static func withHiddenColumnVisibilityHint(_ sql: String) -> String { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return sql } - if trimmed.contains("hidden_column_visible") { return sql } - guard let verbEnd = firstKeywordEnd(in: trimmed) else { return sql } - let verb = String(trimmed[trimmed.startIndex.. String - ) -> String { - let hinted = withHiddenColumnVisibilityHint(sql) - guard !hiddenColumns.isEmpty else { return hinted } - guard simpleSelectStarTable(from: hinted) != nil else { return hinted } - let extras = hiddenColumns.map(quote).joined(separator: ", ") - guard let regex = try? NSRegularExpression( - pattern: #"(?is)^(SELECT(?:\s+/\*\+[^*]*\*/)?)\s+\*\s+FROM\b"# - ) else { - return hinted - } - let nsSQL = hinted as NSString - guard let match = regex.firstMatch(in: hinted, range: NSRange(location: 0, length: nsSQL.length)), - match.numberOfRanges > 1 - else { - return hinted - } - let prefix = nsSQL.substring(with: match.range(at: 1)) - let afterFrom = nsSQL.substring(from: match.range.upperBound) - return "\(prefix) *, \(extras) FROM\(afterFrom)" - } - - static func simpleSelectStarTable(from sql: String) -> String? { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - guard let regex = try? NSRegularExpression( - pattern: #"(?is)^SELECT(?:\s+/\*\+[^*]*\*/)?\s+\*\s+FROM\s+"# - ) else { - return nil - } - let nsSQL = trimmed as NSString - guard let match = regex.firstMatch(in: trimmed, range: NSRange(location: 0, length: nsSQL.length)) else { - return nil - } - var rest = nsSQL.substring(from: match.range.upperBound) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !rest.hasPrefix("(") else { return nil } - return trailingQualifiedIdentifier(from: &rest) - } - - static func documentedHiddenColumnProbe(table: String, column: String, quote: (String) -> String) -> String { - "SELECT \(visibilityHint) \(quote(column)) FROM \(quote(table)) LIMIT 0" - } - - private static func firstKeywordEnd(in sql: String) -> String.Index? { - var index = sql.startIndex - while index < sql.endIndex, sql[index].isWhitespace { - sql.formIndex(after: &index) - } - guard index < sql.endIndex, sql[index].isLetter else { return nil } - while index < sql.endIndex, sql[index].isLetter { - sql.formIndex(after: &index) - } - return index - } - - private static func trailingQualifiedIdentifier(from rest: inout String) -> String? { - var parts: [String] = [] - while let ident = parseLeadingIdentifier(from: &rest) { - parts.append(ident) - rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) - guard rest.hasPrefix(".") else { break } - rest.removeFirst() - rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) - } - return parts.last - } - - private static func parseLeadingIdentifier(from rest: inout String) -> String? { - rest = rest.trimmingCharacters(in: .whitespacesAndNewlines) - guard let first = rest.first else { return nil } - if first == "`" { - rest.removeFirst() - guard let end = rest.firstIndex(of: "`") else { return nil } - let value = String(rest[.. rest.startIndex else { return nil } - let value = String(rest[.. [String: [String]] { - mysqlColumnTypes.filter { $0.key != "Spatial" } - } - - static func oceanbaseColumnTypes(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { + static func mysqlColumnTypesWithoutSpatial(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { mysqlColumnTypes.filter { $0.key != "Spatial" } } @@ -44,7 +44,18 @@ extension PluginMetadataRegistry { idleReleaseField: ConnectionField ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { [ - ("TiDB", PluginMetadataSnapshot( + tidbVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), + databendVariant(dialect: dialect, idleReleaseField: idleReleaseField), + oceanbaseVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), + ] + } + + private static func tidbVariant( + dialect: SQLDialectDescriptor, + mysqlColumnTypes: [String: [String]], + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("TiDB", PluginMetadataSnapshot( displayName: "TiDB", iconName: "tidb-icon", defaultPort: 4_000, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "tidb", parameterStyle: .questionMark, @@ -98,15 +109,21 @@ extension PluginMetadataRegistry { editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: dialect, statementCompletions: [], - columnTypesByCategory: tidbColumnTypes(from: mysqlColumnTypes) + columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [idleReleaseField], category: .relational, tagline: String(localized: "Distributed SQL, MySQL-compatible") - ) - )), - ("Databend", PluginMetadataSnapshot( + ) + )) + } + + private static func databendVariant( + dialect: SQLDialectDescriptor, + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("Databend", PluginMetadataSnapshot( displayName: "Databend", iconName: "databend-icon", defaultPort: 3_307, requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "", parameterStyle: .questionMark, @@ -166,15 +183,20 @@ extension PluginMetadataRegistry { additionalConnectionFields: [idleReleaseField], category: .analytical, tagline: String(localized: "Cloud data warehouse, built in Rust") - ) - )), - ("OceanBase", PluginMetadataSnapshot( + ) + )) + } + + private static func oceanbaseVariant( + dialect: SQLDialectDescriptor, + mysqlColumnTypes: [String: [String]], + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("OceanBase", PluginMetadataSnapshot( displayName: "OceanBase", iconName: "oceanbase-icon", defaultPort: 2_881, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: false, primaryUrlScheme: "oceanbase", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText) - ], pathFieldRole: .database, + navigationModel: .standard, explainVariants: oceanbaseExplainVariants, pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["oceanbase"], postConnectActions: [.selectDatabaseFromLastSession], brandColorHex: "#006AFF", queryLanguageName: "SQL", editorLanguage: .sql, @@ -211,7 +233,7 @@ extension PluginMetadataRegistry { tableEntityName: "Tables", containerEntityName: "Database", defaultPrimaryKeyColumn: nil, - immutableColumns: ["__pk_increment", "__pk_cluster_column"], + immutableColumns: [], systemDatabaseNames: ["information_schema", "mysql", "oceanbase"], systemSchemaNames: [], fileExtensions: [], @@ -224,14 +246,13 @@ extension PluginMetadataRegistry { editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: dialect, statementCompletions: [], - columnTypesByCategory: oceanbaseColumnTypes(from: mysqlColumnTypes) + columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [idleReleaseField], category: .relational, tagline: String(localized: "Distributed HTAP, MySQL-compatible") - ) + ) )) - ] } } diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index faccd4b18..6e7ab04f7 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -70,6 +70,16 @@ enum DockerComposeExtractor { "datafuselabs/databend-query", "databendlabs/databend-query", ] + /// The `oceanbase` organization also publishes OCP, obagent, the config server and miniob, none of + /// which speak the MySQL protocol, so the repository is matched rather than the whole image name. + /// OBProxy serves SQL on 2883, the observer on 2881. + private static let oceanbaseRepositories: [String: Int] = [ + "oceanbase/oceanbase-ce": 2_881, + "oceanbase/oceanbase": 2_881, + "oceanbase/obproxy-ce": 2_883, + "oceanbase/obproxy": 2_883, + ] + static func databaseKind(for image: String) -> ServiceDatabase? { let name = image.lowercased() let repositoryPath = repositoryComponents(of: name) @@ -79,8 +89,8 @@ enum DockerComposeExtractor { if databendRepositories.contains(repositoryPath.suffix(2).joined(separator: "/")) { return ServiceDatabase(type: .databend, defaultPort: 3_307) } - if name.contains("oceanbase") { - return ServiceDatabase(type: .oceanbase, defaultPort: 2_881) + if let port = oceanbaseRepositories[repositoryPath.suffix(2).joined(separator: "/")] { + return ServiceDatabase(type: .oceanbase, defaultPort: port) } if name.contains("postgres"), !name.contains("postgrest") { return ServiceDatabase(type: .postgresql, defaultPort: 5_432) @@ -183,9 +193,12 @@ enum DockerComposeExtractor { fields.password = variables["QUERY_DEFAULT_PASSWORD"] ?? "" fields.database = "default" case .oceanbase: - fields.username = "root@sys" - fields.password = "" - fields.database = "" + let tenant = variables["OB_TENANT_NAME"] ?? (variables["OB_TENANT_PASSWORD"] != nil ? "test" : "sys") + fields.username = "root@\(tenant)" + fields.password = tenant == "sys" + ? variables["OB_SYS_PASSWORD"] ?? "" + : variables["OB_TENANT_PASSWORD"] ?? "" + fields.database = variables["OB_DATABASE"] ?? "" case .mariadb, .mysql: let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil ? "MARIADB" diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift deleted file mode 100644 index 2f11a7ee9..000000000 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorOceanBaseHiddenPKTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("SQL Statement Generator OceanBase hidden PK") -struct SQLStatementGeneratorOceanBaseHiddenPKTests { - private func makeGenerator( - columns: [String], - primaryKeyColumns: [String], - generatedColumns: Set = [] - ) throws -> SQLStatementGenerator { - try SQLStatementGenerator( - tableName: "orders", - columns: columns, - primaryKeyColumns: primaryKeyColumns, - databaseType: .oceanbase, - generatedColumns: generatedColumns, - dialect: nil - ) - } - - @Test("An UPDATE is skipped when the hidden PK is named but not in the result columns") - func missingHiddenKeySkipsUpdate() throws { - let generator = try makeGenerator( - columns: ["name", "amount"], - primaryKeyColumns: ["__pk_increment"] - ) - let change = RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(columnIndex: 0, columnName: "name", oldValue: "old", newValue: "new") - ], - originalRow: ["old", "1"].map(PluginCellValue.fromOptional) - ) - #expect(generator.generateUpdateSQL(for: change) == nil) - } - - @Test("An UPDATE matches on the hidden PK once it is a result column") - func hiddenKeyAnchorsUpdate() throws { - let generator = try makeGenerator( - columns: ["name", "amount", "__pk_increment"], - primaryKeyColumns: ["__pk_increment"], - generatedColumns: ["__pk_increment"] - ) - let change = RowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - CellChange(columnIndex: 0, columnName: "name", oldValue: "old", newValue: "new") - ], - originalRow: ["old", "1", "42"].map(PluginCellValue.fromOptional) - ) - let statement = try #require(generator.generateUpdateSQL(for: change)) - #expect(statement.sql == "UPDATE `orders` SET `name` = ? WHERE `__pk_increment` = ?") - #expect(statement.parameters.count == 2) - #expect(statement.parameters[0] as? String == "new") - #expect(statement.parameters[1] as? String == "42") - #expect(!statement.sql.contains("SET `__pk_increment`")) - } -} diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index cf4228ea1..202b3bbb1 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -143,9 +143,10 @@ struct MySQLVariantSupportTests { #expect(ColumnDefaultVocabulary.options(for: .tidb) == ColumnDefaultVocabulary.options(for: .mysql)) } - @Test("OceanBase treats the hidden primary key columns as immutable") - func oceanbaseImmutableHiddenKeys() { - #expect(PluginManager.shared.immutableColumns(for: .oceanbase) == ["__pk_increment", "__pk_cluster_column"]) + @Test("No MySQL variant marks a column immutable") + func variantsHaveNoImmutableColumns() { + #expect(PluginManager.shared.immutableColumns(for: .oceanbase).isEmpty) + #expect(PluginManager.shared.immutableColumns(for: .tidb).isEmpty) #expect(PluginManager.shared.immutableColumns(for: .mysql).isEmpty) } diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index c3bbc9742..774ba1e4a 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -278,6 +278,49 @@ struct DockerComposeExtractorTests { #expect(oceanbase?.parsedURL.database.isEmpty == true) } + @Test("An OceanBase tenant password names that tenant, and OBProxy is imported on its own port") + func testOceanBaseTenantAndProxy() { + let candidates = extract(""" + services: + ob: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + OB_TENANT_NAME: app + OB_TENANT_PASSWORD: tenantpw + OB_SYS_PASSWORD: syspw + OB_DATABASE: shop + ports: + - "2881:2881" + proxy: + image: oceanbase/obproxy-ce:latest + ports: + - "2883:2883" + """) + let observer = candidates.first { $0.sourceKey == "services.ob" } + #expect(observer?.parsedURL.username == "root@app") + #expect(observer?.parsedURL.password == "tenantpw") + #expect(observer?.parsedURL.database == "shop") + let proxy = candidates.first { $0.sourceKey == "services.proxy" } + #expect(proxy?.parsedURL.type == .oceanbase) + #expect(proxy?.parsedURL.port == 2_883) + } + + @Test("OceanBase images that do not serve SQL are not imported") + func testOceanBaseNonDatabaseImages() { + let candidates = extract(""" + services: + ocp: + image: oceanbase/ocp-ce:latest + ports: + - "8080:8080" + agent: + image: oceanbase/obagent:latest + miniob: + image: oceanbase/miniob:latest + """) + #expect(candidates.isEmpty) + } + @Test("Interpolation uses the adjacent dotenv file") func testInterpolationFromDotenv() { let contents = """ diff --git a/TableProTests/Plugins/MySQLServerFlavorTests.swift b/TableProTests/Plugins/MySQLServerFlavorTests.swift index 7781acfeb..c2c27cd4c 100644 --- a/TableProTests/Plugins/MySQLServerFlavorTests.swift +++ b/TableProTests/Plugins/MySQLServerFlavorTests.swift @@ -17,9 +17,6 @@ struct MySQLServerFlavorTests { ("11.4.2-MariaDB-log", .mariadb), ("8.0.11-TiDB-v7.5.1", .tidb(version: MySQLEngineVersion(major: 7, minor: 5, patch: 1))), ("8.0.11-TiDB-v8.5.1", .tidb(version: MySQLEngineVersion(major: 8, minor: 5, patch: 1))), - ("5.7.25-OceanBase-v4.2.1", .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1))), - ("5.7.25 OceanBase 3.1.3", .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3))), - ("5.7.25-OceanBase_CE-v4.3.5", .oceanbase(version: MySQLEngineVersion(major: 4, minor: 3, patch: 5))), (databendBanner, .databend) ]) func bannerNamesTheEngine(banner: String, expected: MySQLServerFlavor) { @@ -38,24 +35,39 @@ struct MySQLServerFlavorTests { #expect(MySQLServerFlavor.tidbVersion(fromReleaseInfo: "8.0.36") == nil) } - @Test("A TiDB, Databend or OceanBase connection whose banner does not say so is confirmed with a query") + @Test("A TiDB or Databend connection whose banner does not say so is confirmed with a query") func probesOnlyWhenTheBannerIsSilent() { #expect(MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.35", variant: "TiDB")) #expect(!MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.11-TiDB-v7.5.1", variant: "TiDB")) #expect(!MySQLFlavorResolution.needsTiDBVersionProbe(banner: "8.0.35", variant: nil)) #expect(MySQLFlavorResolution.needsDatabendProbe(banner: "8.0.36", variant: "Databend")) #expect(!MySQLFlavorResolution.needsDatabendProbe(banner: Self.databendBanner, variant: "Databend")) - #expect(MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25", variant: "OceanBase")) - #expect(!MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25-OceanBase-v4.2.1", variant: "OceanBase")) - #expect(!MySQLFlavorResolution.needsOceanBaseProbe(banner: "5.7.25-OceanBase-v4.2.1", variant: nil)) } - @Test("A MySQL connection whose banner names OceanBase is OceanBase") - func mysqlOpenedOceanBaseIsOceanBase() { - let flavor = MySQLServerFlavor.fromBanner("5.7.25-OceanBase-v4.2.1") - #expect(flavor.isOceanBase) - #expect(flavor.oceanbaseVersion == MySQLEngineVersion(major: 4, minor: 2, patch: 1)) - #expect(!MySQLServerFlavor.fromBanner("5.7.25").isOceanBase) + @Test("OceanBase's handshake banner is a plain MySQL version, so no banner reads as OceanBase") + func oceanbaseBannerIsPlainMySQL() { + #expect(MySQLServerFlavor.fromBanner("5.7.25") == .mysql) + #expect(MySQLServerFlavor.fromBanner("5.6.25") == .mysql) + } + + @Test("The version comment names OceanBase and carries its version", arguments: [ + ("OceanBase_CE 4.4.2.1 (r101000022026050611-8cf64ed5) (Built May 6 2026 12:29:53)", + MySQLEngineVersion(major: 4, minor: 4, patch: 2)), + ("OceanBase 4.2.1.2 (r102000042023121309-6b1e1a4b)", MySQLEngineVersion(major: 4, minor: 2, patch: 1)), + ("OceanBase 3.1.3 (r10200392021123009-9a4f2a41)", MySQLEngineVersion(major: 3, minor: 1, patch: 3)), + ("OceanBase_CE-v4.3.5", MySQLEngineVersion(major: 4, minor: 3, patch: 5)) + ]) + func oceanbaseVersionComment(comment: String, expected: MySQLEngineVersion) { + #expect(MySQLServerFlavor.namesOceanBase(comment)) + #expect(MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment) == expected) + } + + @Test("A version comment from another engine does not name OceanBase") + func otherEnginesDoNotNameOceanBase() { + #expect(!MySQLServerFlavor.namesOceanBase("MySQL Community Server - GPL")) + #expect(!MySQLServerFlavor.namesOceanBase("mariadb.org binary distribution")) + #expect(!MySQLServerFlavor.namesOceanBase("")) + #expect(MySQLServerFlavor.oceanbaseVersion(fromVersionComment: "MySQL Community Server - GPL") == nil) } @Test("System databases are the exact spellings each engine reports") @@ -127,6 +139,7 @@ struct MySQLServerFlavorTests { (.mysql, 30, "SET SESSION max_execution_time = 30000"), (.tidb(version: nil), 30, "SET SESSION max_execution_time = 30000"), (.oceanbase(version: nil), 30, "SET SESSION max_execution_time = 30000"), + (.oceanbase(version: nil), 0, "SET SESSION max_execution_time = 0, ob_query_timeout = 3216672000000000"), (.databend, 30, "SET max_execute_time_in_seconds = 30") ]) func queryTimeout(flavor: MySQLServerFlavor, seconds: Int, expected: String) { @@ -143,14 +156,11 @@ struct MySQLServerFlavorTests { #expect(!MySQLServerFlavor.databend.isInterruptedByKill(errno: 1_105, message: "SyntaxException. Code: 1005")) } - @Test("TiDB kills by its 64-bit connection id, OceanBase by CONNECTION_ID, Databend by its session id") + @Test("TiDB kills by its 64-bit connection id and Databend by its session id") func killStatements() { let tidb = MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: "2199023255571") #expect(tidb.statement(threadId: 19) == "KILL TIDB QUERY 2199023255571") - let oceanbase = MySQLServerFlavor.oceanbase(version: nil).killTarget(connectionIdentifier: "3221490143") - #expect(oceanbase.statement(threadId: 19) == "KILL QUERY 3221490143") - let databend = MySQLServerFlavor.databend.killTarget(connectionIdentifier: "1f7e5199-119f-484a-aaff-e8d5143a7979") #expect(databend.statement(threadId: 89) == "KILL QUERY '1f7e5199-119f-484a-aaff-e8d5143a7979'") @@ -159,11 +169,18 @@ struct MySQLServerFlavorTests { #expect(mysql.statement(threadId: 0) == nil) } + @Test("OceanBase kills by the handshake thread id, which is its own session id") + func oceanbaseKillsByThreadId() { + let flavor = MySQLServerFlavor.oceanbase(version: nil) + #expect(flavor.killTarget(connectionIdentifier: "3221490143") == .threadId) + #expect(flavor.killTarget(connectionIdentifier: nil) == .threadId) + #expect(flavor.killTarget(connectionIdentifier: nil).statement(threadId: 3_221_490_143) == "KILL QUERY 3221490143") + } + @Test("A connection id that cannot be read falls back to the handshake thread id") func killFallsBackToThreadId() { #expect(MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: nil) == .threadId) #expect(MySQLServerFlavor.tidb(version: nil).killTarget(connectionIdentifier: "not-a-number") == .threadId) - #expect(MySQLServerFlavor.oceanbase(version: nil).killTarget(connectionIdentifier: nil) == .threadId) #expect(MySQLServerFlavor.databend.killTarget(connectionIdentifier: "") == .threadId) } @@ -185,11 +202,11 @@ struct MySQLServerFlavorTests { #expect(!MySQLServerVersion.hasGenerationExpression(banner: Self.databendBanner, flavor: .databend)) #expect(MySQLServerVersion.hasGenerationExpression(banner: "8.0.11-TiDB-v7.5.1", flavor: .tidb(version: nil))) #expect(MySQLServerVersion.hasGenerationExpression( - banner: "5.7.25-OceanBase-v4.2.1", + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1)) )) #expect(!MySQLServerVersion.hasGenerationExpression( - banner: "5.7.25 OceanBase 3.1.3", + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) )) #expect(!MySQLServerVersion.hasGenerationExpression( @@ -197,11 +214,11 @@ struct MySQLServerFlavorTests { flavor: .oceanbase(version: nil) )) #expect(MySQLServerVersion.hasCheckConstraints( - banner: "5.7.25-OceanBase-v4.2.1", + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 0, patch: 0)) )) #expect(!MySQLServerVersion.hasCheckConstraints( - banner: "5.7.25 OceanBase 3.1.3", + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) )) } diff --git a/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift b/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift deleted file mode 100644 index 5a2c2fb78..000000000 --- a/TableProTests/Plugins/OceanBaseHiddenPrimaryKeyTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@Suite("OceanBase hidden primary key") -struct OceanBaseHiddenPrimaryKeyTests { - @Test("A SHOW INDEX PRIMARY column missing from SHOW COLUMNS is attached as BIGINT PK") - func attachesHiddenPrimary() { - let visible = [ - PluginColumnInfo(name: "name", dataType: "VARCHAR(32)", isNullable: true, isPrimaryKey: false) - ] - let attached = OceanBaseHiddenPrimaryKey.attaching( - to: visible, - primaryIndexColumns: ["__pk_increment"] - ) - #expect(attached.map(\.name) == ["name", "__pk_increment"]) - let hidden = attached[1] - #expect(hidden.isPrimaryKey) - #expect(hidden.dataType == "BIGINT") - #expect(hidden.identityKind == .always) - #expect(!hidden.isNullable) - } - - @Test("A visible primary key is left alone") - func visiblePrimaryIsUnchanged() { - let visible = [ - PluginColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true), - PluginColumnInfo(name: "name", dataType: "VARCHAR(32)", isNullable: true, isPrimaryKey: false) - ] - let attached = OceanBaseHiddenPrimaryKey.attaching( - to: visible, - primaryIndexColumns: ["id"] - ) - #expect(attached.map(\.name) == ["id", "name"]) - } - - @Test("An already-visible hidden name is not duplicated") - func doesNotDuplicate() { - let visible = [ - PluginColumnInfo( - name: "__pk_increment", - dataType: "BIGINT", - isNullable: false, - isPrimaryKey: true - ) - ] - let attached = OceanBaseHiddenPrimaryKey.attaching( - to: visible, - primaryIndexColumns: ["__pk_increment"] - ) - #expect(attached.count == 1) - } -} diff --git a/TableProTests/Plugins/OceanBaseSQLTests.swift b/TableProTests/Plugins/OceanBaseSQLTests.swift deleted file mode 100644 index ce91f19c1..000000000 --- a/TableProTests/Plugins/OceanBaseSQLTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import Testing - -@Suite("OceanBase SQL rewrite") -struct OceanBaseSQLTests { - private func quote(_ name: String) -> String { - "`\(name.replacingOccurrences(of: "`", with: "``"))`" - } - - @Test("The visibility hint sits after the first verb") - func hintFollowsTheVerb() { - #expect( - OceanBaseSQL.withHiddenColumnVisibilityHint("SELECT * FROM t") - == "SELECT \(OceanBaseSQL.visibilityHint) * FROM t" - ) - #expect( - OceanBaseSQL.withHiddenColumnVisibilityHint("UPDATE t SET a = 1 WHERE b = 2") - == "UPDATE \(OceanBaseSQL.visibilityHint) t SET a = 1 WHERE b = 2" - ) - #expect( - OceanBaseSQL.withHiddenColumnVisibilityHint("DELETE FROM t WHERE id = 1") - == "DELETE \(OceanBaseSQL.visibilityHint) FROM t WHERE id = 1" - ) - } - - @Test("SHOW, SET and KILL are left alone") - func sessionCommandsAreNotHinted() { - for sql in ["SHOW FULL COLUMNS FROM t", "SET SESSION max_execution_time = 1000", "KILL QUERY 42"] { - #expect(OceanBaseSQL.withHiddenColumnVisibilityHint(sql) == sql) - } - } - - @Test("A statement that already carries the hint is not hinted again") - func existingHintIsKept() { - let sql = "SELECT \(OceanBaseSQL.visibilityHint) `__pk_increment` FROM t LIMIT 0" - #expect(OceanBaseSQL.withHiddenColumnVisibilityHint(sql) == sql) - } - - @Test("SELECT * from a table expands with hidden primary key columns") - func selectStarProjectsHiddenKeys() { - let rewritten = OceanBaseSQL.projectingHiddenPrimaryKeys( - sql: "SELECT * FROM orders", - hiddenColumns: ["__pk_increment"], - quote: quote - ) - #expect(rewritten == "SELECT \(OceanBaseSQL.visibilityHint) *, `__pk_increment` FROM orders") - } - - @Test("SELECT * from a subquery is not expanded") - func subquerySelectStarIsNotExpanded() { - let sql = "SELECT * FROM (SELECT 1) AS x" - #expect(OceanBaseSQL.simpleSelectStarTable(from: sql) == nil) - #expect( - OceanBaseSQL.projectingHiddenPrimaryKeys(sql: sql, hiddenColumns: ["__pk_increment"], quote: quote) - == "SELECT \(OceanBaseSQL.visibilityHint) * FROM (SELECT 1) AS x" - ) - } - - @Test("A qualified identifier keeps the table name, not the schema") - func qualifiedTableName() { - #expect(OceanBaseSQL.simpleSelectStarTable(from: "SELECT * FROM `test`.`orders`") == "orders") - #expect(OceanBaseSQL.simpleSelectStarTable(from: "select * from orders WHERE 1") == "orders") - } - - @Test("Hidden primary key names are the ones SHOW INDEX lists that SHOW COLUMNS omits") - func hiddenNamesComeFromThePrimaryIndex() { - #expect( - OceanBaseSQL.hiddenPrimaryKeyColumns( - primaryIndexColumns: ["__pk_increment"], - visibleColumnNames: ["id", "name"] - ) == ["__pk_increment"] - ) - #expect( - OceanBaseSQL.hiddenPrimaryKeyColumns( - primaryIndexColumns: ["id"], - visibleColumnNames: ["id", "name"] - ).isEmpty - ) - #expect( - OceanBaseSQL.hiddenPrimaryKeyColumns( - primaryIndexColumns: ["__pk_increment", "__pk_cluster_column"], - visibleColumnNames: ["name"] - ) == ["__pk_increment", "__pk_cluster_column"] - ) - } - - @Test("The documented probe names a hidden column under the visibility hint") - func documentedProbe() { - #expect( - OceanBaseSQL.documentedHiddenColumnProbe(table: "orders", column: "__pk_increment", quote: quote) - == "SELECT \(OceanBaseSQL.visibilityHint) `__pk_increment` FROM `orders` LIMIT 0" - ) - } -} diff --git a/docs/databases/oceanbase.mdx b/docs/databases/oceanbase.mdx index be783601e..5537d8f62 100644 --- a/docs/databases/oceanbase.mdx +++ b/docs/databases/oceanbase.mdx @@ -26,18 +26,18 @@ oceanbase://root%40sys:password@host:2881/database `oceanbase+ssh://` opens it through an SSH tunnel. See [Connection URL Reference](/connections/urls). -## Hidden primary keys +## Tables without a primary key -A table with no explicit primary key still has one: `__pk_increment`, and on some clusters `__pk_cluster_column`. `SHOW FULL COLUMNS` and a plain `SELECT *` omit those names, so a grid save that matches on visible columns cannot find the row. +OceanBase keeps a hidden `__pk_increment` column on a table declared without a primary key, but the catalog never reports it and a plain `SELECT *` never returns it, so TablePro edits such a table the way it edits one on [MySQL](/databases/mysql): the `UPDATE` or `DELETE` matches every column of the row, and a save that would touch more than one row is rolled back with a message that identical rows cannot be told apart. -Opening the table projects the hidden key into the result. Stop and parameterized `UPDATE` / `DELETE` then match on it. The column is not editable. +The connection type decides the sidebar, the type picker, EXPLAIN and Stop. A server reached through a connection saved as **MySQL** is treated as MySQL, because the MySQL handshake reports `5.7.25` and names no engine. Choose **OceanBase** for what this page describes. -A connection saved as **MySQL** that reaches an OceanBase server takes the same path. The sidebar, type picker and EXPLAIN follow the connection type, so choose **OceanBase** for those. +Opening an OceanBase connection reads `@@version_comment`. A server that does not name OceanBase there is refused, so a mistyped host or port fails at connect instead of part way through a session. ## What the MySQL driver does not copy - The sidebar hides `information_schema`, `mysql` and `oceanbase`. User databases such as `test` stay listed. -- **Stop** sends `KILL QUERY` for `CONNECTION_ID()`, which is wider than the 32-bit handshake thread id. +- **Stop** sends `KILL QUERY` for the session's own id, as on MySQL. - `EXPLAIN` is plain text. `EXPLAIN FORMAT=JSON` is not offered. - The type picker has MySQL's types without the Spatial group. - [Users & Roles](/features/users-roles) has no connection limit field. diff --git a/docs/external-api/ios-shortcuts.mdx b/docs/external-api/ios-shortcuts.mdx index cd39ca0a0..a96411b0a 100644 --- a/docs/external-api/ios-shortcuts.mdx +++ b/docs/external-api/ios-shortcuts.mdx @@ -44,9 +44,9 @@ To browse everything the app offers, open Shortcuts, tap the action list, and go -Inserts work on MySQL, MariaDB, TiDB, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and Oracle. Any -other type, Redis for example, fails with *"Redis connections do not support adding rows from -Shortcuts."* +Inserts work on MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and +Oracle. Any other type, Redis for example, fails with *"Redis connections do not support adding rows +from Shortcuts."* Siri takes "Open [connection] in TablePro", "Connect to [connection] in TablePro", "Add a row in TablePro" and "Add rows in TablePro", with a connection name in place of the brackets. diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 042e8a94b..8bd71a27c 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -1,6 +1,6 @@ --- title: iPhone and iPad -description: Browse tables, run queries, and edit rows on nine database engines from a synced connection list +description: Browse tables, run queries, and edit rows on ten database engines from a synced connection list --- You need iOS 18 or later. Builds ship on their own schedule, so a feature in a Mac release note may not be on your phone yet. Your version and build are under **Settings > About**. Connections saved on a Mac come over iCloud; the rest of your Mac setup stays on the Mac. @@ -12,12 +12,13 @@ You need iOS 18 or later. Builds ship on their own schedule, so a feature in a M ## Supported databases -Every driver is compiled in, so there is no plugin system and nothing to install. The type picker offers these nine: +Every driver is compiled in, so there is no plugin system and nothing to install. The type picker offers these ten: | Type | Notes | | --- | --- | | **MySQL** / **MariaDB** | Port 3306 by default | | **TiDB** | Port 4000 by default. Sequences are not listed as tables | +| **OceanBase** | Port 2881 by default. Sign in as `user@tenant` | | **PostgreSQL** | Port 5432 by default | | **SQL Server** | TDS, including Microsoft Entra ID sign-in on a connection synced from the Mac | | **Oracle** | Service name or SID | diff --git a/project.yml b/project.yml index adbffc33b..69d741437 100644 --- a/project.yml +++ b/project.yml @@ -499,8 +499,6 @@ targets: - Plugins/MySQLDriverPlugin/DatabendLiteral.swift - Plugins/MySQLDriverPlugin/DatabendResultShape.swift - Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift - - Plugins/MySQLDriverPlugin/OceanBaseSQL.swift - - Plugins/MySQLDriverPlugin/OceanBaseHiddenPrimaryKey.swift - Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift - Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift - Plugins/SQLiteDriverPlugin/SQLiteDefaultValue.swift From 5c175dd81c62ace1bf5990b5b824bb5249e50e68 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 12 Sep 2026 09:28:29 +0700 Subject: [PATCH 05/15] fix(plugin-mysql): apply the query timeout to OceanBase statements that are not read-only --- .../MySQLPluginDriver+Flavor.swift | 26 ++++++++++++--- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 32 +++++++++---------- .../DockerComposeExtractor.swift | 20 ++++++++---- .../ProjectYamlExtractorTests.swift | 21 ++++++++++++ .../Plugins/MySQLServerFlavorTests.swift | 2 +- 5 files changed, 72 insertions(+), 29 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index aca207660..a4179cd25 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -59,11 +59,7 @@ extension MySQLPluginDriver { } if variant == MySQLServerFlavor.oceanbaseVariant { - guard let comment = await firstValue(of: MySQLFlavorResolution.oceanbaseProbe, on: connection), - MySQLServerFlavor.namesOceanBase(comment) else { - throw MySQLFlavorMismatchError(kind: .notOceanBase) - } - return .oceanbase(version: MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment)) + return try await oceanbaseFlavor(on: connection) } guard MySQLFlavorResolution.needsTiDBVersionProbe(banner: banner, variant: variant) else { @@ -76,6 +72,26 @@ extension MySQLPluginDriver { return .tidb(version: version) } + /// The handshake banner is a plain MySQL version on OceanBase, so `@@version_comment` is the only + /// thing that names the engine and the connection cannot be confirmed without it. A server that + /// answers with another engine's comment is refused; a probe that does not answer at all is not + /// evidence of anything, and failing there would turn one unlucky reconnect into a connection the + /// user cannot reopen. + private func oceanbaseFlavor(on connection: MariaDBPluginConnection) async throws -> MySQLServerFlavor { + let comment: String + do { + comment = try await connection.executeQuery(MySQLFlavorResolution.oceanbaseProbe) + .rows.first?.first?.asText ?? "" + } catch { + Self.logger.debug("OceanBase probe failed: \(error.localizedDescription, privacy: .public)") + return .oceanbase(version: nil) + } + guard MySQLServerFlavor.namesOceanBase(comment) else { + throw MySQLFlavorMismatchError(kind: .notOceanBase) + } + return .oceanbase(version: MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment)) + } + func killTarget(for flavor: MySQLServerFlavor, on connection: MariaDBPluginConnection) async -> MySQLKillTarget { guard flavor.isTiDB || flavor.isDatabend else { return .threadId } let identifier = await firstValue(of: MySQLFlavorResolution.connectionIdentifierProbe, on: connection) diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index 98c5b13f6..cb8435c2f 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -87,21 +87,11 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var isDatabend: Bool { self == .databend } - var isOceanBase: Bool { - guard case .oceanbase = self else { return false } - return true - } - var tidbVersion: MySQLEngineVersion? { guard case .tidb(let version) = self else { return nil } return version } - var oceanbaseVersion: MySQLEngineVersion? { - guard case .oceanbase(let version) = self else { return nil } - return version - } - var systemDatabaseNames: [String] { switch self { case .mysql, .mariadb: @@ -136,21 +126,29 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { } /// OceanBase enforces `ob_query_timeout` of its own, 10 seconds by default, and - /// `max_execution_time` only outranks it while it is above zero. Zero is the setting's "no - /// limit", which an export relies on, so it has to lift OceanBase's own limit as well. The - /// server clamps the value it accepts there and warns; this is what it clamps to. + /// `max_execution_time` governs read-only statements alone: measured on 4.4.2.1, an `UPDATE` + /// under a 30 second `max_execution_time` still failed at 10 seconds with error 4012. So the + /// setting has to move OceanBase's own limit, in microseconds, whatever its value. Zero is the + /// setting's "no limit", which an export relies on, and the server clamps what it accepts + /// there; this is what it clamps to. static let oceanbaseUnlimitedQueryTimeoutMicroseconds = 3_216_672_000_000_000 + static func oceanbaseQueryTimeoutStatement(seconds: Int) -> String { + let microseconds = seconds > 0 + ? seconds * 1_000_000 + : oceanbaseUnlimitedQueryTimeoutMicroseconds + return "SET SESSION max_execution_time = \(max(seconds, 0) * 1_000), ob_query_timeout = \(microseconds)" + } + func queryTimeoutStatement(seconds: Int) -> String { switch self { case .mariadb: return "SET SESSION max_statement_time = \(seconds)" case .databend: return "SET max_execute_time_in_seconds = \(seconds)" - case .oceanbase where seconds <= 0: - return "SET SESSION max_execution_time = 0, ob_query_timeout = " - + "\(Self.oceanbaseUnlimitedQueryTimeoutMicroseconds)" - case .mysql, .tidb, .oceanbase: + case .oceanbase: + return Self.oceanbaseQueryTimeoutStatement(seconds: seconds) + case .mysql, .tidb: return "SET SESSION max_execution_time = \(seconds * 1_000)" } } diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 6e7ab04f7..925489ae7 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum DockerComposeExtractor { struct ServiceDatabase { @@ -116,6 +117,18 @@ enum DockerComposeExtractor { return nil } + /// OBProxy routes by a cluster the observer's own port does not need, and `root@sys` reaches it + /// only when the proxy was given a default cluster, so the name goes into the username whenever + /// the compose file states it. + private static func applyOceanBaseCredentials(_ fields: inout ScannedConnectionFields, variables: [String: String]) { + let tenantPassword = variables["OB_TENANT_PASSWORD"]?.nilIfEmpty + let tenant = variables["OB_TENANT_NAME"]?.nilIfEmpty ?? (tenantPassword != nil ? "test" : "sys") + let cluster = variables["OB_CLUSTER_NAME"]?.nilIfEmpty + fields.username = "root@\(tenant)" + (cluster.map { "#\($0)" } ?? "") + fields.password = tenant == "sys" ? variables["OB_SYS_PASSWORD"] ?? "" : tenantPassword ?? "" + fields.database = variables["OB_DATABASE"] ?? "" + } + static func repositoryComponents(of image: String) -> [String] { let withoutDigest = image.split(separator: "@", maxSplits: 1).first.map(String.init) ?? image var components = withoutDigest.split(separator: "/").map(String.init) @@ -193,12 +206,7 @@ enum DockerComposeExtractor { fields.password = variables["QUERY_DEFAULT_PASSWORD"] ?? "" fields.database = "default" case .oceanbase: - let tenant = variables["OB_TENANT_NAME"] ?? (variables["OB_TENANT_PASSWORD"] != nil ? "test" : "sys") - fields.username = "root@\(tenant)" - fields.password = tenant == "sys" - ? variables["OB_SYS_PASSWORD"] ?? "" - : variables["OB_TENANT_PASSWORD"] ?? "" - fields.database = variables["OB_DATABASE"] ?? "" + applyOceanBaseCredentials(&fields, variables: variables) case .mariadb, .mysql: let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil ? "MARIADB" diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index 774ba1e4a..317749d3a 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -305,6 +305,27 @@ struct DockerComposeExtractorTests { #expect(proxy?.parsedURL.port == 2_883) } + @Test("An OBProxy cluster name joins the username, and an empty tenant name keeps the default") + func testOceanBaseClusterAndEmptyTenant() { + let candidates = extract(""" + services: + proxy: + image: oceanbase/obproxy-ce:latest + environment: + OB_CLUSTER_NAME: obcluster + ports: + - "2883:2883" + ob: + image: oceanbase/oceanbase-ce:latest + environment: + OB_TENANT_NAME: "" + ports: + - "2881:2881" + """) + #expect(candidates.first { $0.sourceKey == "services.proxy" }?.parsedURL.username == "root@sys#obcluster") + #expect(candidates.first { $0.sourceKey == "services.ob" }?.parsedURL.username == "root@sys") + } + @Test("OceanBase images that do not serve SQL are not imported") func testOceanBaseNonDatabaseImages() { let candidates = extract(""" diff --git a/TableProTests/Plugins/MySQLServerFlavorTests.swift b/TableProTests/Plugins/MySQLServerFlavorTests.swift index c2c27cd4c..3035869ca 100644 --- a/TableProTests/Plugins/MySQLServerFlavorTests.swift +++ b/TableProTests/Plugins/MySQLServerFlavorTests.swift @@ -138,7 +138,7 @@ struct MySQLServerFlavorTests { (.mysql, 0, "SET SESSION max_execution_time = 0"), (.mysql, 30, "SET SESSION max_execution_time = 30000"), (.tidb(version: nil), 30, "SET SESSION max_execution_time = 30000"), - (.oceanbase(version: nil), 30, "SET SESSION max_execution_time = 30000"), + (.oceanbase(version: nil), 30, "SET SESSION max_execution_time = 30000, ob_query_timeout = 30000000"), (.oceanbase(version: nil), 0, "SET SESSION max_execution_time = 0, ob_query_timeout = 3216672000000000"), (.databend, 30, "SET max_execute_time_in_seconds = 30") ]) From 3837a73575b9e29273e847a315417f9e07d0a87c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 12 Sep 2026 09:53:56 +0700 Subject: [PATCH 06/15] test(plugins): count OceanBase in the built-in database types --- .../Core/Plugins/PluginMetadataRegistryTypeCountTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index 3b42aeab0..22290af1e 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -43,10 +43,10 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 35 database types before any plugin loads") + @Test("The app ships 36 database types before any plugin loads") func builtInDefaultsCoverTwentyNineTypes() { let ids = Self.builtInTypeIds() - #expect(ids.count == 35) + #expect(ids.count == 36) #expect(ids == Self.expectedTypeIds) } From d4273f6b3ca9c1be141684efc22a178d3d084bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Sat, 12 Sep 2026 03:27:32 +0000 Subject: [PATCH 07/15] fix(ios): share PostgreSQL catalog quoting with the iOS driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- CHANGELOG.md | 1 + .../Tests/TableProModelsTests/DatabaseTypeTests.swift | 2 +- .../TableProMobile/Drivers/PostgreSQLDriver.swift | 8 ++------ .../Drivers/PostgreSQLForeignKeyQueryTests.swift | 7 +++++++ TableProMobile/project.yml | 2 ++ 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0619f5ff1..322be8079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- iOS PostgreSQL foreign-key listing after catalog quoting moved to the shared helper. (#2726) - Empty Columns tab and no autocomplete for PostgreSQL materialized views. (#2726) - Discard restoring a different row than the one edited under a column value filter. - Add Row under a column value filter selecting and opening the wrong row for editing. diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index 445023bb8..26245ef7e 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -61,7 +61,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 29) + #expect(DatabaseType.allKnownTypes.count == 30) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 58945a861..e11f97681 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -312,18 +312,14 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { static func foreignKeysQuery(schema: String, table: String, serverVersionNumber: Int32) -> String { PostgreSQLCatalogForeignKeys.query( - schemaLiteral: literal(schema), - tableLiteral: literal(table), + schema: schema, + table: table, excludesPartitionClones: PostgreSQLCatalogForeignKeys.excludesPartitionClones( serverVersionNumber: serverVersionNumber ) ) } - private static func literal(_ value: String) -> String { - "'\(value.replacingOccurrences(of: "'", with: "''"))'" - } - func fetchDatabases() async throws -> [String] { let raw = try await actor.execute( "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift index 240af440e..73a17cd9c 100644 --- a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLForeignKeyQueryTests.swift @@ -49,6 +49,13 @@ struct PostgreSQLForeignKeyQueryTests { #expect(query.contains("cl.relname = 'it''s'")) } + @Test("a backslash in the schema name is quoted as an E-string") + func escapesBackslash() { + let query = PostgreSQLDriver.foreignKeysQuery(schema: #"a\b"#, table: "t", serverVersionNumber: 170_011) + + #expect(query.contains(#"ns.nspname = E'a\\b'"#)) + } + @Test("a Redshift server never receives the PostgreSQL 11 clone filter") func redshiftKeepsPortableQuery() { let redshift = PostgreSQLDriver.foreignKeysQuery(schema: "public", table: "t", serverVersionNumber: 80_002) diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index 5958c8994..132ef2284 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -64,7 +64,9 @@ targets: - ../Plugins/MySQLDriverPlugin/MySQLLatin1.swift - ../Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift # Foreign key catalog read the iOS PostgreSQL driver shares with the macOS plugin. + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift # libpq COPY handling the iOS PostgreSQL driver shares with the macOS plugin. - ../Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift - ../Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift From 697150a2768b76e1233b4ee16f9a027b47d8559d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Sat, 12 Sep 2026 04:26:47 +0000 Subject: [PATCH 08/15] test(plugins): pin Weaviate PluginKit to current ABI and theme-colored chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- .../Plugins/WeaviateConnectionFieldsTests.swift | 5 +++-- .../Editor/QueryDiagnosticsRefreshTests.swift | 2 +- .../Views/Results/DataGridBodyChromeTests.swift | 17 ++++++++--------- .../DataGridPendingChangeMarkTests.swift | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift b/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift index 3046f3bd0..9aeebb21c 100644 --- a/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift +++ b/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift @@ -127,7 +127,7 @@ struct WeaviateFieldParityTests { @Suite("Weaviate plugin manifest") struct WeaviatePluginManifestTests { - @Test("Info.plist declares PluginKit 25 and the Weaviate type id") + @Test("The bundle pins the PluginKit ABI and the Weaviate type id") func plistDeclaresType() throws { let url = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -135,7 +135,8 @@ struct WeaviatePluginManifestTests { .deletingLastPathComponent() .appendingPathComponent("Plugins/WeaviateDriverPlugin/Info.plist") let plist = try #require(NSDictionary(contentsOf: url) as? [String: Any]) - #expect(plist["TableProPluginKitVersion"] as? Int == 25) + #expect(plist["TableProPluginKitVersion"] as? Int == PluginManager.currentPluginKitVersion) #expect(plist["TableProProvidesDatabaseTypeIds"] as? [String] == ["Weaviate"]) + #expect(plist["TableProMinAppVersion"] as? String == "0.73.0") } } diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index dd2e4f15c..37d0c2e8e 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -193,7 +193,7 @@ struct QueryDiagnosticMessageTests { let emphases = manager.getEmphases(for: QueryDiagnosticsController.emphasisGroup) #expect(emphases.map(\.range) == [range]) - #expect(emphases.first?.style == .underline(color: .systemOrange)) + #expect(emphases.first?.style == .underline(color: ThemeEngine.shared.palette[.statusWarning])) #expect( manager.toolTip(at: try center(of: range, in: controller)) == "Full-width semicolon (U+FF1B). SQL reads only ; as a statement separator." diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index 7a3fff2e9..2d657dd71 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -168,14 +168,12 @@ struct DataGridBodyChromeTests { #expect(!grid.coordinator.presentsColumn(atTableColumnIndex: rowNumber)) } - /// The colour has to come from `tableView.gridColor`, which is the dynamic catalog colour AppKit - /// was filling with, so an appearance change carries the separator with it and there is no - /// second spelling to keep in sync. A hardcoded colour would pass a geometry test and be wrong - /// in dark mode. - @Test("The separator is drawn in the table view's own grid colour") - func separatorUsesTheTableViewGridColor() throws { + /// The colour has to come from the theme's grid line slot, which is what the rest of the grid + /// paints with. `tableView.gridColor` is not consulted: AppKit's grid lines are off, and a + /// hardcoded grey would pass a geometry test and be wrong in dark mode. + @Test("The separator is drawn in the theme's grid line colour") + func separatorUsesTheThemeGridLineColor() throws { let grid = makeGrid(columns: ["id", "name"]) - grid.tableView.gridColor = .systemRed let rowView = try #require(grid.tableView.rowView(atRow: 0, makeIfNecessary: true) as? DataGridRowView) rowView.layoutSubtreeIfNeeded() @@ -189,13 +187,14 @@ struct DataGridBodyChromeTests { x: Int((boundary - 0.5) * scale), y: Int(rowView.bounds.height * scale / 2) )?.usingColorSpace(.deviceRGB) - let expected = NSColor.systemRed.usingColorSpace(.deviceRGB) + let expected = ThemeEngine.shared.palette[.gridLine].usingColorSpace(.deviceRGB) let sampledRed = try #require(sampled?.redComponent) let sampledGreen = try #require(sampled?.greenComponent) let expectedRed = try #require(expected?.redComponent) + let expectedGreen = try #require(expected?.greenComponent) #expect(abs(sampledRed - expectedRed) < 0.15) - #expect(sampledRed > sampledGreen + 0.3, "the separator has to carry the grid colour, not a fixed grey") + #expect(abs(sampledGreen - expectedGreen) < 0.15) } /// `NSTableView` continues the alternation past the last row one row height at a time, numbered diff --git a/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift b/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift index d867d39fa..51bc39df4 100644 --- a/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift +++ b/TableProTests/Views/Results/DataGridPendingChangeMarkTests.swift @@ -190,7 +190,7 @@ struct DataGridPendingChangeMarkTests { ) #expect(struck.gain > textWidth / 2) - #expect(selected.gain > textWidth / 2) + #expect(selected.reach > textWidth / 2) } /// The two lines have to land in different places, or they are the same cue twice. From fe1b97f92b2b79e2e1d04919e718e401f4188501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Sat, 12 Sep 2026 05:31:53 +0000 Subject: [PATCH 09/15] fix(ios): keep shared catalog quoting off the iOS main actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- Plugins/MySQLDriverPlugin/DatabendCatalog.swift | 2 +- Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift | 4 ++-- Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift | 2 +- Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/DatabendCatalog.swift b/Plugins/MySQLDriverPlugin/DatabendCatalog.swift index ae0d45615..46bb96d82 100644 --- a/Plugins/MySQLDriverPlugin/DatabendCatalog.swift +++ b/Plugins/MySQLDriverPlugin/DatabendCatalog.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -internal enum DatabendCatalog { +nonisolated internal enum DatabendCatalog { static func quoteIdentifier(_ name: String) -> String { guard name.contains("`") else { return "`\(name)`" } let escaped = name diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index ed0adf664..46e2a5b6c 100644 --- a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -6,12 +6,12 @@ import Foundation import TableProPluginKit -internal func mysqlQuoteIdentifier(_ name: String) -> String { +nonisolated internal func mysqlQuoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "`", with: "``") return "`\(escaped)`" } -internal func mysqlEscapeStringLiteral(_ value: String) -> String { +nonisolated internal func mysqlEscapeStringLiteral(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") result = result.replacingOccurrences(of: "'", with: "''") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index c69aefabd..898ccb856 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -5,7 +5,7 @@ import Foundation -struct PostgreSQLCapabilities: Sendable, Equatable { +nonisolated struct PostgreSQLCapabilities: Sendable, Equatable { let serverVersion: Int32 /// libpq answers 0 for a handle it has not connected. A catalog query built for that has to diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift index 28b7c64a1..725bb1340 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -9,7 +9,7 @@ import Foundation import TableProPluginKit -public enum PostgreSQLObjectQueries { +nonisolated public enum PostgreSQLObjectQueries { /// The single owner of literal quoting for every statement this plugin builds, which is why it /// returns the quotes too: the `E` prefix sits outside them, so a helper that returns inner text /// for a call site to wrap can never be setting-independent. Measured on PostgreSQL 17.11 with From 81597db0059bff6ee918e9d59088e1491446f1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Sat, 12 Sep 2026 05:31:53 +0000 Subject: [PATCH 10/15] test(settings): scroll the Editor pane to the Vim mode toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nguyễn Nam Long --- TableProUITests/Support/UITestCase.swift | 17 +++++++++++++++++ TableProUITests/VimNormalModeChordUITests.swift | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index fb9f4db6a..9781c6b5f 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -182,6 +182,23 @@ internal class UITestCase: XCTestCase { waitForPredicate(timeout: timeout) { element.exists && element.isHittable } } + /// The settings window is 720x500, and the Editor pane is taller than that once the font + /// pickers sit above the SQL toggles. XCUITest reports the Vim switch as existing and not + /// hittable, because it is below the fold; a swipe is what brings it into the window. + internal func waitUntilHittableByScrolling( + _ element: XCUIElement, + in container: XCUIElement, + timeout: TimeInterval + ) -> Bool { + if waitUntilHittable(element, timeout: 1) { return true } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + container.scrollViews.firstMatch.swipeUp() + if waitUntilHittable(element, timeout: 0.8) { return true } + } + return element.exists && element.isHittable + } + /// Switches the result to its Structure editor, through **View > Result View > Structure** /// rather than the `Structure` segment of the results status bar. /// diff --git a/TableProUITests/VimNormalModeChordUITests.swift b/TableProUITests/VimNormalModeChordUITests.swift index 542160c71..891843477 100644 --- a/TableProUITests/VimNormalModeChordUITests.swift +++ b/TableProUITests/VimNormalModeChordUITests.swift @@ -65,7 +65,10 @@ final class VimNormalModeChordUITests: UITestCase { editorPane.click() let vimToggle = settings.descendants(matching: .any).matching(identifier: "vim-mode-toggle").firstMatch - XCTAssertTrue(waitUntilHittable(vimToggle, timeout: 10), "The Editor pane must offer Vim mode") + XCTAssertTrue( + waitUntilHittableByScrolling(vimToggle, in: settings, timeout: 10), + "The Editor pane must offer Vim mode" + ) if isOn(vimToggle) != enabled { vimToggle.click() } From 97a50ee72c9634c97e50f70e983e181a9a5bb4d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 14 Sep 2026 00:09:41 +0700 Subject: [PATCH 11/15] fix(plugin-mysql): verify OceanBase identity, lift its query timeout and gate ANALYZE at 4.2.2 Claude-Session: https://claude.ai/code/session_01F31dVJERHgtY1vdBVPZiPy --- .../MySQLDriverPlugin/MySQLMaintenance.swift | 7 +- .../MySQLPluginDriver+Flavor.swift | 29 +--- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 17 +- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 62 +++---- .../MySQLServerVersion.swift | 5 +- .../TableProMobile/Drivers/MySQLDriver.swift | 18 ++ .../MySQLVariantSupportTests.swift | 10 ++ .../MaintenanceOperationDescriptorTests.swift | 18 ++ .../Plugins/MySQLServerFlavorTests.swift | 160 ++++++++++++------ 9 files changed, 210 insertions(+), 116 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift b/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift index 4e4379345..262443824 100644 --- a/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift +++ b/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift @@ -100,11 +100,16 @@ nonisolated internal enum MySQLMaintenance { /// identity it carries, and the app runs no maintenance at all. Naming `MySQLMaintenance` from there /// made the flavor unbuildable without this file and the two identifier-quoting files behind it. internal extension MySQLServerFlavor { + static let oceanbaseBareAnalyzeFloor = MySQLEngineVersion(major: 4, minor: 2, patch: 2) + var maintenanceOperations: [PluginMaintenanceOperation] { switch self { case .mysql, .mariadb: return MySQLMaintenance.operations - case .tidb, .databend, .oceanbase: + case .tidb, .databend: + return [MySQLMaintenance.analyzeOperation] + case .oceanbase(let version): + guard let version, version >= Self.oceanbaseBareAnalyzeFloor else { return [] } return [MySQLMaintenance.analyzeOperation] } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index cf17cdf50..4d4cf0711 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -59,7 +59,14 @@ extension MySQLPluginDriver { } if variant == MySQLServerFlavor.oceanbaseVariant { - return try await oceanbaseFlavor(on: connection) + let identity = try await connection.executeQuery(MySQLFlavorResolution.oceanbaseProbe).rows.first + guard let flavor = MySQLFlavorResolution.oceanbaseFlavor( + versionComment: identity?[safe: 0]?.asText, + serverVersion: identity?[safe: 1]?.asText + ) else { + throw MySQLFlavorMismatchError(kind: .notOceanBase) + } + return flavor } guard MySQLFlavorResolution.needsTiDBVersionProbe(banner: banner, variant: variant) else { @@ -72,26 +79,6 @@ extension MySQLPluginDriver { return .tidb(version: version) } - /// The handshake banner is a plain MySQL version on OceanBase, so `@@version_comment` is the only - /// thing that names the engine and the connection cannot be confirmed without it. A server that - /// answers with another engine's comment is refused; a probe that does not answer at all is not - /// evidence of anything, and failing there would turn one unlucky reconnect into a connection the - /// user cannot reopen. - private func oceanbaseFlavor(on connection: MariaDBPluginConnection) async throws -> MySQLServerFlavor { - let comment: String - do { - comment = try await connection.executeQuery(MySQLFlavorResolution.oceanbaseProbe) - .rows.first?.first?.asText ?? "" - } catch { - Self.logger.debug("OceanBase probe failed: \(error.localizedDescription, privacy: .public)") - return .oceanbase(version: nil) - } - guard MySQLServerFlavor.namesOceanBase(comment) else { - throw MySQLFlavorMismatchError(kind: .notOceanBase) - } - return .oceanbase(version: MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment)) - } - func killTarget(for flavor: MySQLServerFlavor, on connection: MariaDBPluginConnection) async -> MySQLKillTarget { guard flavor.isTiDB || flavor.isDatabend else { return .threadId } let identifier = await firstValue(of: MySQLFlavorResolution.connectionIdentifierProbe, on: connection) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index bc63604c1..39499017a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -867,14 +867,15 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// back, so it is not the session's to lose. func applyQueryTimeout(_ seconds: Int) async throws { sessionLock.withLock { appliedQueryTimeoutSeconds = seconds } - do { - _ = try await executeWithReconnect( - query: flavor.queryTimeoutStatement(seconds: seconds), - isRetry: false, - countsAsActivity: false - ) - } catch { - Self.logger.warning("Failed to set query timeout: \(error.localizedDescription)") + for statement in flavor.queryTimeoutStatements(seconds: seconds) { + do { + _ = try await executeWithReconnect(query: statement, isRetry: false, countsAsActivity: false) + } catch { + Self.logger.warning( + "Failed to set query timeout with \(statement, privacy: .public): \(error.localizedDescription)" + ) + return + } } } diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index de52921d8..b421a4e84 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -39,6 +39,8 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { static let databendVariant = "Databend" static let oceanbaseVariant = "OceanBase" + static let oceanbaseUnlimitedQueryTimeoutMicroseconds = 3_216_672_000_000_000 + static func fromBanner(_ banner: String?) -> MySQLServerFlavor { guard let banner else { return .mysql } if let version = tidbVersion(fromBanner: banner) { @@ -65,17 +67,18 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { banner.range(of: #"^\d+\.\d+\.\d+-v\d+\.\d+\.\d+-"#, options: .regularExpression) != nil } - /// The MySQL handshake banner is OceanBase's `_display_mysql_version`, which is `5.7.25` on a - /// direct connection and `5.6.25` through OBProxy: it never names the engine. `@@version_comment` - /// is what does, as `OceanBase_CE 4.4.2.1 (r...)` or `OceanBase 3.1.3 (r...)`. - static func namesOceanBase(_ versionComment: String) -> Bool { - versionComment.range(of: "oceanbase", options: .caseInsensitive) != nil + static func oceanbaseVersion(fromVersionComment comment: String) -> MySQLEngineVersion? { + guard let name = comment.range( + of: #"^OceanBase(_CE)? +(?=\d)"#, options: [.regularExpression, .caseInsensitive] + ) else { return nil } + return MySQLEngineVersion(parsing: comment[name.upperBound...]) } - static func oceanbaseVersion(fromVersionComment comment: String) -> MySQLEngineVersion? { - guard let name = comment.range(of: "OceanBase", options: .caseInsensitive) else { return nil } - let rest = comment[name.upperBound...].drop { $0.isLetter || $0 == "_" || $0 == "-" || $0.isWhitespace } - return MySQLEngineVersion(parsing: rest) + static func oceanbaseVersion(fromServerVersion version: String) -> MySQLEngineVersion? { + guard let marker = version.range( + of: #"-OceanBase(_CE)?-v(?=\d)"#, options: [.regularExpression, .caseInsensitive] + ) else { return nil } + return MySQLEngineVersion(parsing: version[marker.upperBound...]) } var isMariaDB: Bool { self == .mariadb } @@ -87,6 +90,11 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var isDatabend: Bool { self == .databend } + var isOceanBase: Bool { + guard case .oceanbase = self else { return false } + return true + } + var tidbVersion: MySQLEngineVersion? { guard case .tidb(let version) = self else { return nil } return version @@ -101,7 +109,7 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { case .databend: return ["information_schema", "system"] case .oceanbase: - return ["information_schema", "mysql", "oceanbase"] + return ["information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR"] } } @@ -116,31 +124,17 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { return mode == .readWrite ? "START TRANSACTION READ WRITE" : "START TRANSACTION" } - /// OceanBase enforces `ob_query_timeout` of its own, 10 seconds by default, and - /// `max_execution_time` governs read-only statements alone: measured on 4.4.2.1, an `UPDATE` - /// under a 30 second `max_execution_time` still failed at 10 seconds with error 4012. So the - /// setting has to move OceanBase's own limit, in microseconds, whatever its value. Zero is the - /// setting's "no limit", which an export relies on, and the server clamps what it accepts - /// there; this is what it clamps to. - static let oceanbaseUnlimitedQueryTimeoutMicroseconds = 3_216_672_000_000_000 - - static func oceanbaseQueryTimeoutStatement(seconds: Int) -> String { - let microseconds = seconds > 0 - ? seconds * 1_000_000 - : oceanbaseUnlimitedQueryTimeoutMicroseconds - return "SET SESSION max_execution_time = \(max(seconds, 0) * 1_000), ob_query_timeout = \(microseconds)" - } - - func queryTimeoutStatement(seconds: Int) -> String { + func queryTimeoutStatements(seconds: Int) -> [String] { switch self { case .mariadb: - return "SET SESSION max_statement_time = \(seconds)" + return ["SET SESSION max_statement_time = \(seconds)"] case .databend: - return "SET max_execute_time_in_seconds = \(seconds)" + return ["SET max_execute_time_in_seconds = \(seconds)"] case .oceanbase: - return Self.oceanbaseQueryTimeoutStatement(seconds: seconds) + let microseconds = seconds > 0 ? seconds * 1_000_000 : Self.oceanbaseUnlimitedQueryTimeoutMicroseconds + return ["SET SESSION ob_query_timeout = \(microseconds)", "SET SESSION max_execution_time = 0"] case .mysql, .tidb: - return "SET SESSION max_execution_time = \(seconds * 1_000)" + return ["SET SESSION max_execution_time = \(seconds * 1_000)"] } } @@ -173,8 +167,14 @@ nonisolated internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } + static func oceanbaseFlavor(versionComment: String?, serverVersion: String?) -> MySQLServerFlavor? { + let version = versionComment.flatMap(MySQLServerFlavor.oceanbaseVersion(fromVersionComment:)) + ?? serverVersion.flatMap(MySQLServerFlavor.oceanbaseVersion(fromServerVersion:)) + return version.map { .oceanbase(version: $0) } + } + static let tidbVersionProbe = "SELECT tidb_version()" static let databendProbe = "SELECT value FROM system.settings WHERE name = 'max_result_rows'" - static let oceanbaseProbe = "SELECT @@version_comment" + static let oceanbaseProbe = "SELECT @@version_comment, @@version" static let connectionIdentifierProbe = "SELECT CONNECTION_ID()" } diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index e65763fc5..a43b6cc74 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -54,11 +54,8 @@ enum MySQLServerVersion { return isAtLeast((5, 7, 6), banner: banner) case .mariadb: return isAtLeast((10, 2, 0), banner: banner) - case .tidb: + case .tidb, .oceanbase: return true - case .oceanbase(let version): - guard let version else { return false } - return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) case .databend: return false } diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index a53b40ac8..02e923911 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -1,5 +1,6 @@ import CMariaDB import Foundation +import os import TableProDatabase import TableProModels import TableProMSSQLCore @@ -49,6 +50,13 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Connection + private static let logger = Logger(subsystem: "com.TablePro", category: "MySQLDriver") + + static func sessionSetupStatements(for databaseType: DatabaseType) -> [String] { + guard databaseType == .oceanbase else { return [] } + return MySQLServerFlavor.oceanbase(version: nil).queryTimeoutStatements(seconds: 0) + } + func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect( @@ -56,6 +64,16 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { ssl: ssl, encoding: connectionEncoding ) serverVersion = await actor.serverVersion() + for statement in Self.sessionSetupStatements(for: databaseType) { + do { + _ = try await actor.execute(statement) + } catch { + Self.logger.warning( + "Session setup failed with \(statement, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + break + } + } } func disconnect() async throws { diff --git a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift index 7f5caf1e5..80e674682 100644 --- a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift +++ b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift @@ -49,4 +49,14 @@ struct MySQLVariantSupportTests { let mysql = try #require(driver as? MySQLDriver) #expect(mysql.databaseType == .oceanbase) } + + @Test("An OceanBase session lifts the server's own statement limit; other MySQL engines set nothing") + func oceanBaseSessionSetup() { + #expect(MySQLDriver.sessionSetupStatements(for: .oceanbase) == [ + "SET SESSION ob_query_timeout = 3216672000000000", "SET SESSION max_execution_time = 0" + ]) + #expect(MySQLDriver.sessionSetupStatements(for: .mysql).isEmpty) + #expect(MySQLDriver.sessionSetupStatements(for: .tidb).isEmpty) + #expect(MySQLDriver.sessionSetupStatements(for: .mariadb).isEmpty) + } } diff --git a/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift b/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift index 671a1267b..0202fd635 100644 --- a/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift +++ b/TableProTests/Plugins/MaintenanceOperationDescriptorTests.swift @@ -268,6 +268,24 @@ struct MaintenanceOperationDescriptorTests { } } + @Test("OceanBase offers ANALYZE TABLE from 4.2.2, the first release that parses the bare form") + func oceanbaseAnalyzeFloor() { + let before = MySQLServerFlavor.oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1)) + let from = MySQLServerFlavor.oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 2)) + #expect(MySQLMaintenance.statements( + operation: "ANALYZE TABLE", table: "orders", schema: nil, options: [:], flavor: before + ) == nil) + #expect(MySQLMaintenance.statements( + operation: "ANALYZE TABLE", table: "orders", schema: nil, options: [:], flavor: .oceanbase(version: nil) + ) == nil) + #expect(MySQLMaintenance.statements( + operation: "ANALYZE TABLE", table: "orders", schema: "shop", options: [:], flavor: from + ) == ["ANALYZE TABLE `shop`.`orders`"]) + #expect(MySQLMaintenance.statements( + operation: "OPTIMIZE TABLE", table: "orders", schema: nil, options: [:], flavor: from + ) == nil) + } + // MARK: - Every declared option is read /// An option the descriptor declares that the statement builder never reads is the hardcoding bug diff --git a/TableProTests/Plugins/MySQLServerFlavorTests.swift b/TableProTests/Plugins/MySQLServerFlavorTests.swift index 8cd7fad2f..90313b3e2 100644 --- a/TableProTests/Plugins/MySQLServerFlavorTests.swift +++ b/TableProTests/Plugins/MySQLServerFlavorTests.swift @@ -44,30 +44,71 @@ struct MySQLServerFlavorTests { #expect(!MySQLFlavorResolution.needsDatabendProbe(banner: Self.databendBanner, variant: "Databend")) } - @Test("OceanBase's handshake banner is a plain MySQL version, so no banner reads as OceanBase") + @Test("OceanBase's handshake banner is a plain MySQL version, direct or through OBProxy") func oceanbaseBannerIsPlainMySQL() { #expect(MySQLServerFlavor.fromBanner("5.7.25") == .mysql) #expect(MySQLServerFlavor.fromBanner("5.6.25") == .mysql) } - @Test("The version comment names OceanBase and carries its version", arguments: [ - ("OceanBase_CE 4.4.2.1 (r101000022026050611-8cf64ed5) (Built May 6 2026 12:29:53)", - MySQLEngineVersion(major: 4, minor: 4, patch: 2)), - ("OceanBase 4.2.1.2 (r102000042023121309-6b1e1a4b)", MySQLEngineVersion(major: 4, minor: 2, patch: 1)), - ("OceanBase 3.1.3 (r10200392021123009-9a4f2a41)", MySQLEngineVersion(major: 3, minor: 1, patch: 3)), - ("OceanBase_CE-v4.3.5", MySQLEngineVersion(major: 4, minor: 3, patch: 5)) + @Test("The version comment opens with OceanBase and its version", arguments: [ + ( + "OceanBase_CE 4.4.2.1 (r101000022026050611-8cf64ed50606966fd5c29f47265cf557d97ea776) (Built May 6 2026 12:29:53)", + MySQLEngineVersion(major: 4, minor: 4, patch: 2) + ), + ( + "OceanBase_CE 4.0.0.0 (r100000272022110114-6af7f9ae79cd0ecbafd4b1b88e2886ccdba0c3be) (Built Nov 1 2022 14:53:48)", + MySQLEngineVersion(major: 4, minor: 0, patch: 0) + ), + ("OceanBase 4.2.5.4 (r1-abc) (Built Jul 15 2025 15:31:08)", MySQLEngineVersion(major: 4, minor: 2, patch: 5)), + ("OceanBase 3.1.4 (r1-abc) (Built Jul 15 2022 11:45:14)", MySQLEngineVersion(major: 3, minor: 1, patch: 4)) ]) func oceanbaseVersionComment(comment: String, expected: MySQLEngineVersion) { - #expect(MySQLServerFlavor.namesOceanBase(comment)) - #expect(MySQLServerFlavor.oceanbaseVersion(fromVersionComment: comment) == expected) + #expect( + MySQLFlavorResolution.oceanbaseFlavor(versionComment: comment, serverVersion: nil) + == .oceanbase(version: expected) + ) } - @Test("A version comment from another engine does not name OceanBase") - func otherEnginesDoNotNameOceanBase() { - #expect(!MySQLServerFlavor.namesOceanBase("MySQL Community Server - GPL")) - #expect(!MySQLServerFlavor.namesOceanBase("mariadb.org binary distribution")) - #expect(!MySQLServerFlavor.namesOceanBase("")) - #expect(MySQLServerFlavor.oceanbaseVersion(fromVersionComment: "MySQL Community Server - GPL") == nil) + @Test("The edition name is matched without regard to case") + func versionCommentIgnoresCase() { + #expect( + MySQLFlavorResolution.oceanbaseFlavor(versionComment: "oceanbase_ce 4.4.2.1 (r1-abc)", serverVersion: nil) + == .oceanbase(version: MySQLEngineVersion(major: 4, minor: 4, patch: 2)) + ) + } + + @Test("A comment in an unknown format falls back to the OceanBase suffix of @@version", arguments: [ + ("5.7.25-OceanBase_CE-v4.4.2.1", MySQLEngineVersion(major: 4, minor: 4, patch: 2)), + ("5.7.25-OceanBase-v4.2.5.4", MySQLEngineVersion(major: 4, minor: 2, patch: 5)) + ]) + func serverVersionFallback(serverVersion: String, expected: MySQLEngineVersion) { + #expect( + MySQLFlavorResolution.oceanbaseFlavor(versionComment: "OceanBase Cloud build", serverVersion: serverVersion) + == .oceanbase(version: expected) + ) + #expect( + MySQLFlavorResolution.oceanbaseFlavor(versionComment: nil, serverVersion: serverVersion) + == .oceanbase(version: expected) + ) + } + + @Test("A comment that only mentions OceanBase, or names no version, is not OceanBase", arguments: [ + "MySQL Community Server - GPL", + "mariadb.org binary distribution", + "", + "Percona Server, compatible with OceanBase 4.2.1", + "OceanBase", + "OceanBaseX 4.2.1", + "5.7.25-OceanBase_CE-v4.4.2.1" + ]) + func notOceanBase(comment: String) { + #expect(MySQLFlavorResolution.oceanbaseFlavor(versionComment: comment, serverVersion: "8.4.11") == nil) + } + + @Test("A probe that returned nothing is not OceanBase") + func missingIdentityIsNotOceanBase() { + #expect(MySQLFlavorResolution.oceanbaseFlavor(versionComment: nil, serverVersion: nil) == nil) + #expect(MySQLFlavorResolution.oceanbaseFlavor(versionComment: nil, serverVersion: "5.7.25") == nil) } @Test("System databases are the exact spellings each engine reports") @@ -78,10 +119,10 @@ struct MySQLServerFlavorTests { ]) #expect(MySQLServerFlavor.databend.systemDatabaseNames == ["information_schema", "system"]) #expect(MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames == [ - "information_schema", "mysql", "oceanbase" + "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR" ]) #expect(!MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames.contains("test")) - #expect(!MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames.contains("performance_schema")) + #expect(!MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames.contains("ocs")) } @Test("TiDB and Databend offer only the maintenance they support") @@ -89,7 +130,17 @@ struct MySQLServerFlavorTests { #expect(MySQLServerFlavor.mysql.maintenanceOperations.count == 4) #expect(MySQLServerFlavor.tidb(version: nil).maintenanceOperations.map(\.name) == ["ANALYZE TABLE"]) #expect(MySQLServerFlavor.databend.maintenanceOperations.map(\.name) == ["ANALYZE TABLE"]) - #expect(MySQLServerFlavor.oceanbase(version: nil).maintenanceOperations.map(\.name) == ["ANALYZE TABLE"]) + } + + @Test("OceanBase offers ANALYZE TABLE only where its grammar takes the bare form", arguments: [ + (MySQLEngineVersion?.none, [String]()), + (MySQLEngineVersion(major: 4, minor: 0, patch: 0), []), + (MySQLEngineVersion(major: 4, minor: 2, patch: 1), []), + (MySQLEngineVersion(major: 4, minor: 2, patch: 2), ["ANALYZE TABLE"]), + (MySQLEngineVersion(major: 4, minor: 4, patch: 2), ["ANALYZE TABLE"]) + ]) + func oceanbaseMaintenance(version: MySQLEngineVersion?, expected: [String]) { + #expect(MySQLServerFlavor.oceanbase(version: version).maintenanceOperations.map(\.name) == expected) } @Test("TiDB sequences cannot be browsed, so they are not listed as tables") @@ -133,22 +184,36 @@ struct MySQLServerFlavorTests { } @Test("Each engine names its own statement timeout", arguments: [ - (MySQLServerFlavor.mariadb, 0, "SET SESSION max_statement_time = 0"), - (.mariadb, 30, "SET SESSION max_statement_time = 30"), - (.mysql, 0, "SET SESSION max_execution_time = 0"), - (.mysql, 30, "SET SESSION max_execution_time = 30000"), - (.tidb(version: nil), 30, "SET SESSION max_execution_time = 30000"), - (.oceanbase(version: nil), 30, "SET SESSION max_execution_time = 30000, ob_query_timeout = 30000000"), - (.oceanbase(version: nil), 0, "SET SESSION max_execution_time = 0, ob_query_timeout = 3216672000000000"), - (.databend, 30, "SET max_execute_time_in_seconds = 30") + (MySQLServerFlavor.mariadb, 0, ["SET SESSION max_statement_time = 0"]), + (.mariadb, 30, ["SET SESSION max_statement_time = 30"]), + (.mysql, 0, ["SET SESSION max_execution_time = 0"]), + (.mysql, 30, ["SET SESSION max_execution_time = 30000"]), + (.tidb(version: nil), 30, ["SET SESSION max_execution_time = 30000"]), + (.databend, 30, ["SET max_execute_time_in_seconds = 30"]) ]) - func queryTimeout(flavor: MySQLServerFlavor, seconds: Int, expected: String) { - #expect(flavor.queryTimeoutStatement(seconds: seconds) == expected) + func queryTimeout(flavor: MySQLServerFlavor, seconds: Int, expected: [String]) { + #expect(flavor.queryTimeoutStatements(seconds: seconds) == expected) + } + + @Test("OceanBase moves its own query timeout and clears a global max_execution_time, one statement each") + func oceanbaseQueryTimeout() { + let flavor = MySQLServerFlavor.oceanbase(version: MySQLEngineVersion(major: 4, minor: 4, patch: 2)) + #expect(flavor.queryTimeoutStatements(seconds: 30) == [ + "SET SESSION ob_query_timeout = 30000000", "SET SESSION max_execution_time = 0" + ]) + #expect(flavor.queryTimeoutStatements(seconds: 0) == [ + "SET SESSION ob_query_timeout = 3216672000000000", "SET SESSION max_execution_time = 0" + ]) + #expect(MySQLServerFlavor.oceanbase(version: nil).queryTimeoutStatements(seconds: 0) + == flavor.queryTimeoutStatements(seconds: 0)) } @Test("A killed statement reads as the interruption each engine reports") func killInterruption() { #expect(MySQLServerFlavor.mysql.isInterruptedByKill(errno: 1_317, message: "Query execution was interrupted")) + #expect(MySQLServerFlavor.oceanbase(version: nil).isInterruptedByKill( + errno: 1_317, message: "Query execution was interrupted" + )) #expect(!MySQLServerFlavor.mysql.isInterruptedByKill(errno: 1_105, message: "AbortedQuery")) #expect(MySQLServerFlavor.databend.isInterruptedByKill( errno: 1_105, message: "AbortedQuery. Code: 1043, Text = Aborted query, because the server is shutting down or the query was killed.." @@ -169,12 +234,12 @@ struct MySQLServerFlavorTests { #expect(mysql.statement(threadId: 0) == nil) } - @Test("OceanBase kills by the handshake thread id, which is its own session id") + @Test("OceanBase kills by the handshake thread id, which OBProxy maps to the server session") func oceanbaseKillsByThreadId() { let flavor = MySQLServerFlavor.oceanbase(version: nil) - #expect(flavor.killTarget(connectionIdentifier: "3221490143") == .threadId) + #expect(flavor.killTarget(connectionIdentifier: "3221613678") == .threadId) #expect(flavor.killTarget(connectionIdentifier: nil) == .threadId) - #expect(flavor.killTarget(connectionIdentifier: nil).statement(threadId: 3_221_490_143) == "KILL QUERY 3221490143") + #expect(flavor.killTarget(connectionIdentifier: nil).statement(threadId: 3_221_613_678) == "KILL QUERY 3221613678") } @Test("A connection id that cannot be read falls back to the handshake thread id") @@ -201,25 +266,18 @@ struct MySQLServerFlavorTests { func databendGenerationExpression() { #expect(!MySQLServerVersion.hasGenerationExpression(banner: Self.databendBanner, flavor: .databend)) #expect(MySQLServerVersion.hasGenerationExpression(banner: "8.0.11-TiDB-v7.5.1", flavor: .tidb(version: nil))) - #expect(MySQLServerVersion.hasGenerationExpression( - banner: "5.7.25", - flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1)) - )) - #expect(!MySQLServerVersion.hasGenerationExpression( - banner: "5.7.25", - flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) - )) - #expect(!MySQLServerVersion.hasGenerationExpression( - banner: "5.7.25", - flavor: .oceanbase(version: nil) - )) - #expect(MySQLServerVersion.hasCheckConstraints( - banner: "5.7.25", - flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 0, patch: 0)) - )) - #expect(!MySQLServerVersion.hasCheckConstraints( - banner: "5.7.25", - flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 3)) - )) + } + + @Test("OceanBase reads CHECK constraints from 4.0 and generation expressions on every version", arguments: [ + (MySQLEngineVersion?.none, false), + (MySQLEngineVersion(major: 3, minor: 1, patch: 4), false), + (MySQLEngineVersion(major: 4, minor: 0, patch: 0), true), + (MySQLEngineVersion(major: 4, minor: 4, patch: 2), true) + ]) + func oceanbaseCatalogGates(version: MySQLEngineVersion?, readsCheckConstraints: Bool) { + let flavor = MySQLServerFlavor.oceanbase(version: version) + #expect(MySQLServerVersion.hasCheckConstraints(banner: "5.7.25", flavor: flavor) == readsCheckConstraints) + #expect(MySQLServerVersion.hasGenerationExpression(banner: "5.7.25", flavor: flavor)) + #expect(!MySQLServerVersion.quotesColumnDefault(banner: "5.7.25", flavor: flavor)) } } From 7c1fa9d9b7974c06ad6f76b92675165f126029a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 14 Sep 2026 00:09:41 +0700 Subject: [PATCH 12/15] fix(plugin-mysql): read OceanBase expression defaults from SHOW CREATE TABLE Claude-Session: https://claude.ai/code/session_01F31dVJERHgtY1vdBVPZiPy --- .../MySQLCreateTableScanner.swift | 166 ++++++++++ .../MySQLPluginDriver+OceanBaseDefaults.swift | 83 +++++ .../MySQLPluginDriver+Schema.swift | 25 +- .../OceanBaseColumnDefaults.swift | 153 +++++++++ .../TiDBCheckConstraints.swift | 102 +----- .../OceanBaseColumnDefaultsTests.swift | 297 ++++++++++++++++++ project.yml | 2 + 7 files changed, 729 insertions(+), 99 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift create mode 100644 Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift create mode 100644 Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift create mode 100644 TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift diff --git a/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift b/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift new file mode 100644 index 000000000..985965a35 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift @@ -0,0 +1,166 @@ +// +// MySQLCreateTableScanner.swift +// MySQLDriverPlugin +// + +import Foundation + +internal enum MySQLCreateTableScanner { + static func firstGroup(in text: Substring) -> Substring? { + var depth = 0 + var start: Substring.Index? + for (index, mark) in structuralMarks(in: text) { + switch mark { + case "(": + if depth == 0 { start = text.index(after: index) } + depth += 1 + case ")": + guard depth > 0 else { return nil } + depth -= 1 + if depth == 0, let start { return text[start.. [Substring] { + var elements: [Substring] = [] + var depth = 0 + var elementStart = body.startIndex + for (index, mark) in structuralMarks(in: body) { + switch mark { + case "(": + depth += 1 + case ")": + depth -= 1 + default: + guard depth == 0 else { continue } + elements.append(body[elementStart.. Bool { + let trimmed = rest.drop(while: \.isWhitespace) + guard trimmed.prefix(keyword.count).uppercased() == keyword else { return false } + let remainder = trimmed.dropFirst(keyword.count) + guard let next = remainder.first, next.isWhitespace || next == "`" || next == "(" else { return false } + rest = remainder + return true + } + + static func consumeBacktickName(from rest: inout Substring) -> String? { + consumeQuotedName(from: &rest, quote: "`") + } + + static func consumeQuotedName(from rest: inout Substring, quote: Character) -> String? { + var text = rest.drop(while: \.isWhitespace) + guard text.first == quote else { return nil } + text = text.dropFirst() + var name = "" + while let character = text.first { + text = text.dropFirst() + if character == quote { + guard text.first == quote else { + rest = text + return name + } + text = text.dropFirst() + } + name.append(character) + } + return nil + } + + static func topLevelTokens(of text: Substring) -> [Substring] { + var tokens: [Substring] = [] + var index = text.startIndex + while index < text.endIndex { + guard !text[index].isWhitespace else { + index = text.index(after: index) + continue + } + let start = index + while index < text.endIndex, !text[index].isWhitespace { + index = endOfUnit(in: text, from: index) + } + tokens.append(text[start.. Substring.Index { + let quote = text[start] + var index = text.index(after: start) + while index < text.endIndex { + let character = text[index] + if character == "\\", quote != "`" { + index = text.index(after: index) + guard index < text.endIndex else { return text.endIndex } + index = text.index(after: index) + continue + } + index = text.index(after: index) + guard character == quote else { continue } + guard index < text.endIndex, text[index] == quote else { return index } + index = text.index(after: index) + } + return text.endIndex + } + + private static func endOfUnit(in text: Substring, from index: Substring.Index) -> Substring.Index { + switch text[index] { + case "'", "\"", "`": + return endOfQuoted(in: text, from: index) + case "(": + return endOfGroup(in: text, from: index) + default: + return text.index(after: index) + } + } + + private static func endOfGroup(in text: Substring, from start: Substring.Index) -> Substring.Index { + var depth = 0 + var index = start + while index < text.endIndex { + switch text[index] { + case "'", "\"", "`": + index = endOfQuoted(in: text, from: index) + continue + case "(": + depth += 1 + case ")": + depth -= 1 + if depth == 0 { return text.index(after: index) } + default: + break + } + index = text.index(after: index) + } + return text.endIndex + } + + private static func structuralMarks(in text: Substring) -> [(index: Substring.Index, mark: Character)] { + var marks: [(index: Substring.Index, mark: Character)] = [] + var index = text.startIndex + while index < text.endIndex { + let character = text[index] + switch character { + case "'", "\"", "`": + index = endOfQuoted(in: text, from: index) + continue + case "(", ")", ",": + marks.append((index, character)) + default: + break + } + index = text.index(after: index) + } + return marks + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift new file mode 100644 index 000000000..51a7fa60d --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift @@ -0,0 +1,83 @@ +// +// MySQLPluginDriver+OceanBaseDefaults.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal extension MySQLPluginDriver { + func oceanbaseDefaultClausesByTable( + forRows rows: [[PluginCellValue]], + tableColumn: Int, + typeColumn: Int, + defaultColumn: Int, + schema: String? + ) async throws -> [String: [String: String]] { + guard flavor.isOceanBase else { return [:] } + let candidates = Set(rows.compactMap { row -> String? in + guard let table = row[safe: tableColumn]?.asText, + let dataType = row[safe: typeColumn]?.asText, + OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable( + row[safe: defaultColumn]?.asText, dataType: dataType + ) + else { return nil } + return table + }) + guard !candidates.isEmpty else { return [:] } + var clausesByTable: [String: [String: String]] = [:] + for table in try await baseTableNames(among: candidates, schema: schema).sorted() { + clausesByTable[table] = try await oceanbaseDefaultClauses(table: table, schema: schema) + } + return clausesByTable + } + + func columnDefaultValue( + catalogDefault: String?, + extra: String?, + dataType: String, + column: String, + createTableClauses: [String: String]? + ) -> String? { + if flavor.isOceanBase, let catalogDefault { + if let currentTimestamp = OceanBaseColumnDefaults.currentTimestampDefault(catalogDefault, dataType: dataType) { + return currentTimestamp + } + if let createTableClauses, + OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable(catalogDefault, dataType: dataType) { + let resolution = OceanBaseColumnDefaults.resolve( + clause: createTableClauses[column], catalogDefault: catalogDefault + ) + if case .value(let value) = resolution { + return value + } + Self.logger.warning( + "OceanBase default of \(column, privacy: .public) is not in SHOW CREATE TABLE as reported" + ) + } + if let binaryLiteral = OceanBaseColumnDefaults.binaryLiteralDefault(catalogDefault, dataType: dataType) { + return binaryLiteral + } + } + return mysqlDefaultValueFromCatalog( + catalogDefault, extra: extra, dataType: dataType, quotesLiterals: catalogQuotesDefaults + ) + } + + private func baseTableNames(among tables: Set, schema: String?) async throws -> Set { + let names = tables.sorted().map { "'\(mysqlEscapeStringLiteral($0))'" }.joined(separator: ", ") + let result = try await execute(query: """ + SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '\(effectiveSchemaLiteral(schema))' + AND TABLE_TYPE = 'BASE TABLE' + AND TABLE_NAME IN (\(names)) + """) + return Set(result.rows.compactMap { $0[safe: 0]?.asText }) + } + + private func oceanbaseDefaultClauses(table: String, schema: String?) async throws -> [String: String] { + let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") + guard let createTable = result.rows.first?[safe: 1]?.asText else { return [:] } + return OceanBaseColumnDefaults.defaultClauses(fromCreateTable: createTable) ?? [:] + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 3a1b7b0d4..1ab9e4b08 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -44,6 +44,10 @@ internal extension MySQLPluginDriver { guard !flavor.isDatabend else { return try await databendColumns(table: table, schema: schema) } + guard !flavor.isOceanBase else { + let columnsByTable = try await informationSchemaColumns(schema: schema, table: table) + return columnsByTable[table] ?? (columnsByTable.count == 1 ? columnsByTable.values.first ?? [] : []) + } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") let generationExpressions = try await fetchGenerationExpressions(table: table, schema: schema) @@ -165,7 +169,15 @@ internal extension MySQLPluginDriver { /// the bulk read reports a changed generation expression as no difference at all. func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { guard !flavor.isDatabend else { return try await databendAllColumns(schema: schema) } + return try await informationSchemaColumns(schema: schema, table: nil) + } + + private func informationSchemaColumns( + schema: String?, + table: String? + ) async throws -> [String: [PluginColumnInfo]] { let escapedDb = effectiveSchemaLiteral(schema) + let tableFilter = table.map { " AND TABLE_NAME = '\(mysqlEscapeStringLiteral($0))'" } ?? "" let hasGenerationExpression = MySQLServerVersion.hasGenerationExpression( banner: _serverVersion, flavor: flavor ) @@ -176,11 +188,14 @@ internal extension MySQLPluginDriver { IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, \(generationProjection) FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = '\(escapedDb)' + WHERE TABLE_SCHEMA = '\(escapedDb)'\(tableFilter) ORDER BY TABLE_NAME, ORDINAL_POSITION """ let result = try await execute(query: query) + let createTableClausesByTable = try await oceanbaseDefaultClausesByTable( + forRows: result.rows, tableColumn: 0, typeColumn: 2, defaultColumn: 6, schema: schema + ) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -205,8 +220,12 @@ internal extension MySQLPluginDriver { let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET(")) ? dataType : upperType let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType) - let defaultValue = mysqlDefaultValueFromCatalog( - rawDefault, extra: extra, dataType: normalizedType, quotesLiterals: catalogQuotesDefaults + let defaultValue = columnDefaultValue( + catalogDefault: rawDefault, + extra: extra, + dataType: normalizedType, + column: name, + createTableClauses: createTableClausesByTable[tableName] ) let column = PluginColumnInfo( diff --git a/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift new file mode 100644 index 000000000..d97768f70 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift @@ -0,0 +1,153 @@ +// +// OceanBaseColumnDefaults.swift +// MySQLDriverPlugin +// + +import Foundation + +internal enum OceanBaseColumnDefaults { + enum Resolution: Equatable { + case value(String) + case unverified + } + + private static let literalOnlyBaseTypes: Set = [ + "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", + "DECIMAL", "DEC", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "YEAR", "BOOL", "BOOLEAN", + "BIT", "ENUM", "SET" + ] + + private static let textReportedBinaryBaseTypes: Set = ["BINARY", "VARBINARY"] + + static func catalogDefaultNeedsCreateTable(_ catalogDefault: String?, dataType: String) -> Bool { + guard let catalogDefault, catalogDefault.contains("(") else { return false } + guard currentTimestampDefault(catalogDefault, dataType: dataType) == nil else { return false } + return !literalOnlyBaseTypes.contains(baseType(of: dataType)) + } + + static func currentTimestampDefault(_ catalogDefault: String, dataType: String) -> String? { + guard mysqlTemporalType(dataType) else { return nil } + return mysqlCurrentTimestampExpression(catalogDefault, dataType: dataType) + } + + static func binaryLiteralDefault(_ catalogDefault: String, dataType: String) -> String? { + guard textReportedBinaryBaseTypes.contains(baseType(of: dataType)) else { return nil } + return "'\(mysqlEscapeStringLiteral(catalogDefault))'" + } + + static func defaultClauses(fromCreateTable sql: String) -> [String: String]? { + let lines = sql.split(separator: "\n", omittingEmptySubsequences: false) + guard let header = lines.first, declaresTable(header) else { return nil } + var clauses: [String: String] = [:] + for line in lines.dropFirst() { + var definition = line.drop(while: \.isWhitespace) + guard let name = columnName(consumingFrom: &definition), + let operand = defaultOperand(in: withoutTrailingSeparator(definition)) + else { continue } + clauses[name] = operand + } + return clauses + } + + static func resolve(clause: String?, catalogDefault: String) -> Resolution { + guard let clause, clause.uppercased() != "NULL" else { return .unverified } + if clause.hasPrefix("'") { + guard let literal = decodedStringLiteral(clause), literal == catalogDefault else { return .unverified } + return .value("'\(mysqlEscapeStringLiteral(literal))'") + } + if clause.hasPrefix("("), clause.hasSuffix(")") { + guard clause.dropFirst().dropLast() == catalogDefault else { return .unverified } + return .value(clause) + } + guard clause.caseInsensitiveCompare(catalogDefault) == .orderedSame else { return .unverified } + return .value(clause) + } + + private static func declaresTable(_ header: Substring) -> Bool { + let words = header.prefix { $0 != "`" && $0 != "\"" && $0 != "(" } + .split(whereSeparator: \.isWhitespace) + .map { $0.uppercased() } + guard words.first == "CREATE", let tableIndex = words.firstIndex(of: "TABLE") else { return false } + return !words[.. String? { + if let quote = definition.first, quote == "`" || quote == "\"" { + return MySQLCreateTableScanner.consumeQuotedName(from: &definition, quote: quote) + } + guard let first = definition.first, first.isLetter || first.isNumber || first == "_" || first == "$" else { + return nil + } + let name = definition.prefix { !$0.isWhitespace } + definition = definition.dropFirst(name.count) + return String(name) + } + + private static func withoutTrailingSeparator(_ definition: Substring) -> Substring { + var trimmed = definition + while let last = trimmed.last, last.isWhitespace { + trimmed = trimmed.dropLast() + } + return trimmed.last == "," ? trimmed.dropLast() : trimmed + } + + private static func defaultOperand(in definition: Substring) -> String? { + let tokens = MySQLCreateTableScanner.topLevelTokens(of: definition) + for (index, token) in tokens.enumerated() { + let upper = token.uppercased() + if upper == "DEFAULT" { + return tokens.indices.contains(index + 1) ? String(tokens[index + 1]) : nil + } + if upper.hasPrefix("DEFAULT("), token.count > "DEFAULT".count { + return String(token.dropFirst("DEFAULT".count)) + } + } + return nil + } + + private static func decodedStringLiteral(_ operand: String) -> String? { + var characters = Array(operand) + guard characters.count >= 2, characters.removeFirst() == "'" else { return nil } + var decoded = "" + var index = 0 + while index < characters.count { + let character = characters[index] + switch character { + case "\\": + guard index + 1 < characters.count else { return nil } + decoded.append(unescaped(characters[index + 1])) + index += 2 + case "'": + if index + 1 < characters.count, characters[index + 1] == "'" { + decoded.append("'") + index += 2 + } else { + return index == characters.count - 1 ? decoded : nil + } + default: + decoded.append(character) + index += 1 + } + } + return nil + } + + private static func unescaped(_ character: Character) -> String { + switch character { + case "0": return "\u{0}" + case "b": return "\u{8}" + case "n": return "\n" + case "r": return "\r" + case "t": return "\t" + case "Z": return "\u{1A}" + case "%", "_": return "\\\(character)" + default: return String(character) + } + } + + private static func baseType(of dataType: String) -> String { + let upper = dataType.uppercased() + let beforeParenthesis = upper.split(separator: "(", maxSplits: 1).first.map(String.init) ?? upper + return beforeParenthesis.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? beforeParenthesis + } +} diff --git a/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift b/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift index 1c3136e7a..7c580e540 100644 --- a/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift +++ b/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift @@ -8,108 +8,18 @@ import TableProPluginKit internal enum TiDBCheckConstraints { static func parse(createTable sql: String) -> [PluginCheckConstraintInfo] { - guard let body = firstGroup(in: Substring(sql)) else { return [] } - return topLevelElements(of: body).compactMap(checkConstraint(in:)) + guard let body = MySQLCreateTableScanner.firstGroup(in: Substring(sql)) else { return [] } + return MySQLCreateTableScanner.topLevelElements(of: body).compactMap(checkConstraint(in:)) } private static func checkConstraint(in element: Substring) -> PluginCheckConstraintInfo? { var rest = element - guard consume("CONSTRAINT", from: &rest), - let name = consumeBacktickName(from: &rest), - consume("CHECK", from: &rest) + guard MySQLCreateTableScanner.consume("CONSTRAINT", from: &rest), + let name = MySQLCreateTableScanner.consumeBacktickName(from: &rest), + MySQLCreateTableScanner.consume("CHECK", from: &rest) else { return nil } rest = rest.drop(while: \.isWhitespace) - guard rest.first == "(", let expression = firstGroup(in: rest) else { return nil } + guard rest.first == "(", let expression = MySQLCreateTableScanner.firstGroup(in: rest) else { return nil } return PluginCheckConstraintInfo(name: name, expression: expression.trimmingCharacters(in: .whitespaces)) } - - private static func consume(_ keyword: String, from rest: inout Substring) -> Bool { - let trimmed = rest.drop(while: \.isWhitespace) - guard trimmed.prefix(keyword.count).uppercased() == keyword else { return false } - let remainder = trimmed.dropFirst(keyword.count) - guard let next = remainder.first, next.isWhitespace || next == "`" || next == "(" else { return false } - rest = remainder - return true - } - - private static func consumeBacktickName(from rest: inout Substring) -> String? { - var text = rest.drop(while: \.isWhitespace) - guard text.first == "`" else { return nil } - text = text.dropFirst() - var name = "" - while let character = text.first { - text = text.dropFirst() - if character == "`" { - guard text.first == "`" else { - rest = text - return name - } - text = text.dropFirst() - } - name.append(character) - } - return nil - } - - private static func firstGroup(in text: Substring) -> Substring? { - var depth = 0 - var start: Substring.Index? - for (index, mark) in structuralMarks(in: text) { - switch mark { - case "(": - if depth == 0 { start = text.index(after: index) } - depth += 1 - case ")": - guard depth > 0 else { return nil } - depth -= 1 - if depth == 0, let start { return text[start.. [Substring] { - var elements: [Substring] = [] - var depth = 0 - var elementStart = body.startIndex - for (index, mark) in structuralMarks(in: body) { - switch mark { - case "(": - depth += 1 - case ")": - depth -= 1 - default: - guard depth == 0 else { continue } - elements.append(body[elementStart.. [(index: Substring.Index, mark: Character)] { - var marks: [(index: Substring.Index, mark: Character)] = [] - var openQuote: Character? - var index = text.startIndex - while index < text.endIndex { - let character = text[index] - if let quote = openQuote { - if character == "\\", quote != "`" { - index = text.index(after: index) - } else if character == quote { - openQuote = nil - } - } else if character == "'" || character == "\"" || character == "`" { - openQuote = character - } else if character == "(" || character == ")" || character == "," { - marks.append((index, character)) - } - guard index < text.endIndex else { break } - index = text.index(after: index) - } - return marks - } } diff --git a/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift new file mode 100644 index 000000000..f4da9fa25 --- /dev/null +++ b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift @@ -0,0 +1,297 @@ +// +// OceanBaseColumnDefaultsTests.swift +// TableProTests +// + +import Testing + +@Suite("OceanBase column defaults") +struct OceanBaseColumnDefaultsTests { + private static let tableOptions = "ORGANIZATION INDEX DEFAULT CHARSET = utf8mb4 ROW_FORMAT = DYNAMIC " + + "COMPRESSION = 'zstd_1.3.8' REPLICA_NUM = 1 BLOCK_SIZE = 16384 USE_BLOOM_FILTER = FALSE " + + "ENABLE_MACRO_BLOCK_BLOOM_FILTER = FALSE TABLET_SIZE = 134217728 PCTFREE = 0" + + private static let expressionAndLiteral = #""" + CREATE TABLE `t_dflt` ( + `id` int(11) NOT NULL, + `e` varchar(36) DEFAULT (uuid()), + `l` varchar(36) DEFAULT 'UUID()', + `s` varchar(10) DEFAULT 'abc', + `ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `d` date DEFAULT (curdate()), + `n` int(11) DEFAULT '5', + PRIMARY KEY (`id`) + ) \#(tableOptions) + """# + + private static let awkwardLiterals = #""" + CREATE TABLE `t` ( + `id` int(11) NOT NULL, + `q` varchar(20) DEFAULT 'it\'s', + `p` varchar(20) DEFAULT '(x)', + `dq` varchar(30) DEFAULT 'DEFAULT \'a\', b', + `nul` varchar(5) DEFAULT NULL, + `bt` bit(1) DEFAULT b'1', + `bin` varbinary(4) DEFAULT 'a', + `ts3` datetime(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `gen` int(11) GENERATED ALWAYS AS ((`id` + 1)) VIRTUAL, + `cmt` int(11) DEFAULT '7' COMMENT 'DEFAULT (uuid()) here', + `dec_col` decimal(5,2) DEFAULT '1.50', + `js` json DEFAULT NULL, + `lc` varchar(10) DEFAULT 'uuid()', + `e2` varchar(40) DEFAULT (concat('a',',','b')), + `nn` varchar(5) NOT NULL DEFAULT '', + PRIMARY KEY (`id`) + ) \#(tableOptions) + """# + + private static let escapes = #""" + CREATE TABLE `t` ( + `id` int(11) NOT NULL, + `nl` varchar(20) DEFAULT 'a\nb' COMMENT 'line1\nline2', + `tb` varchar(20) DEFAULT 'tab\there', + `bs` varchar(20) DEFAULT 'back\\slash', + `ex` varchar(40) DEFAULT (concat('it\'s','x')), + `st` set('a','b'c') DEFAULT 'a', + `ch` char(3) DEFAULT 'abc', + PRIMARY KEY (`id`) + ) \#(tableOptions) + """# + + private static let partitionedWithEnum = #""" + CREATE TABLE `p` ( + `id` int(11) NOT NULL, + `kind` enum('a,b','c'd') DEFAULT 'c'd', + `neg` int(11) DEFAULT '-1', + `negd` decimal(6,2) DEFAULT '-1.50', + `note` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT 'x' COMMENT 'has, comma', + PRIMARY KEY (`id`) + ) \#(tableOptions) + partition by range(`id`) + (partition `p0` values less than (100), + partition `p1` values less than (MAXVALUE)) + """# + + private static let quotedNames = #""" + CREATE TABLE `we``ird col` ( + `a``b` int(11) DEFAULT '1', + `c d` varchar(5) DEFAULT 'x' + ) \#(tableOptions) + """# + + @Test("Each column's DEFAULT clause is read as written, and a column without one has none") + func clausesAsWritten() throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.awkwardLiterals)) + #expect(clauses["id"] == nil) + #expect(clauses["gen"] == nil) + #expect(clauses["q"] == #"'it\'s'"#) + #expect(clauses["p"] == "'(x)'") + #expect(clauses["dq"] == #"'DEFAULT \'a\', b'"#) + #expect(clauses["nul"] == "NULL") + #expect(clauses["bt"] == "b'1'") + #expect(clauses["ts3"] == "CURRENT_TIMESTAMP(3)") + #expect(clauses["cmt"] == "'7'") + #expect(clauses["e2"] == "(concat('a',',','b'))") + #expect(clauses["nn"] == "''") + } + + @Test("An expression default and a literal with the same text resolve differently", arguments: [ + ("e", "uuid()", "(uuid())"), + ("l", "UUID()", "'UUID()'"), + ("s", "abc", "'abc'"), + ("ts", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"), + ("d", "curdate()", "(curdate())") + ]) + func expressionOrLiteral(column: String, catalogDefault: String, expected: String) throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.expressionAndLiteral)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) + } + + @Test("Literals come back as the SQL the MySQL writer takes, whatever escapes OceanBase printed", arguments: [ + ("q", "it's", "'it''s'"), + ("p", "(x)", "'(x)'"), + ("dq", "DEFAULT 'a', b", "'DEFAULT ''a'', b'"), + ("bin", "a", "'a'"), + ("ts3", "CURRENT_TIMESTAMP(3)", "CURRENT_TIMESTAMP(3)"), + ("lc", "uuid()", "'uuid()'"), + ("e2", "concat('a',',','b')", "(concat('a',',','b'))"), + ("nn", "", "''") + ]) + func literalsAsSQL(column: String, catalogDefault: String, expected: String) throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.awkwardLiterals)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) + } + + @Test("Newlines, tabs, backslashes and quotes decode to the stored value", arguments: [ + ("nl", "a\nb", #"'a\nb'"#), + ("tb", "tab\there", #"'tab\there'"#), + ("bs", #"back\slash"#, #"'back\\slash'"#), + ("ex", #"concat('it\'s','x')"#, #"(concat('it\'s','x'))"#), + ("ch", "abc", "'abc'") + ]) + func escapesDecode(column: String, catalogDefault: String, expected: String) throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.escapes)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) + } + + @Test("A set member printed with a bare quote does not disturb the columns after it") + func malformedMemberListStaysOnItsLine() throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.escapes)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["ch"], catalogDefault: "abc") == .value("'abc'")) + let partitioned = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.partitionedWithEnum)) + #expect(OceanBaseColumnDefaults.resolve(clause: partitioned["note"], catalogDefault: "x") == .value("'x'")) + #expect(partitioned["p0"] == nil) + } + + @Test("Backtick-escaped table and column names are read") + func quotedNames() throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.quotedNames)) + #expect(clauses["a`b"] == "'1'") + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["c d"], catalogDefault: "x") == .value("'x'")) + } + + @Test("A clause that does not match the catalog is not trusted", arguments: [ + (String?.none, "abc"), + (String?.some("NULL"), "abc"), + (String?.some("'abd'"), "abc"), + (String?.some("(uuid())"), "UUID()"), + (String?.some("CURRENT_DATE"), "CURRENT_TIMESTAMP"), + (String?.some("CURRENT_TIMESTAMP(3)"), "CURRENT_TIMESTAMP"), + (String?.some("b'10'"), "b'1'") + ]) + func mismatchIsUnverified(clause: String?, catalogDefault: String) { + #expect(OceanBaseColumnDefaults.resolve(clause: clause, catalogDefault: catalogDefault) == .unverified) + } + + @Test("A view's CREATE statement yields nothing to resolve against") + func viewIsNotATable() { + let view = "CREATE VIEW `v` AS select `tp_def2`.`p`.`id` AS `id`,`tp_def2`.`p`.`kind` AS `kind` from `tp_def2`.`p`" + #expect(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: view) == nil) + let definer = "CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `v` AS select 1 AS `a`" + #expect(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: definer) == nil) + } + + private static let unquotedIdentifiers = #""" + CREATE TABLE t ( + id int(11) NOT NULL, + a varchar(20) DEFAULT ((1 + 2)), + b varchar(20) DEFAULT 'abc', + c varchar(40) DEFAULT (now()), + d varchar(20) DEFAULT '-1', + g varbinary(4) DEFAULT 'A', + key varchar(5) DEFAULT (upper('k')), + `Mixed Case` varchar(5) DEFAULT (lower('M')), + PRIMARY KEY (id) + ) \#(tableOptions) + """# + + @Test("A binary default, which OceanBase reports as text, comes back as a quoted literal") + func binaryDefaultIsQuoted() { + #expect(OceanBaseColumnDefaults.binaryLiteralDefault("A", dataType: "VARBINARY(4)") == "'A'") + #expect(OceanBaseColumnDefaults.binaryLiteralDefault("it's", dataType: "BINARY(4)") == "'it''s'") + #expect(OceanBaseColumnDefaults.binaryLiteralDefault("A", dataType: "VARCHAR(4)") == nil) + } + + @Test("A literal that does not close where the clause ends is not a literal", arguments: [ + #"'abc\'"#, "'(ab", "'a'b'", "'" + ]) + func unterminatedLiteralIsUnverified(clause: String) { + #expect(OceanBaseColumnDefaults.resolve(clause: clause, catalogDefault: "abc") == .unverified) + #expect(OceanBaseColumnDefaults.resolve(clause: clause, catalogDefault: "(a") == .unverified) + } + + @Test("A bare name may start with a digit, as sql_quote_show_create = 0 prints 1st_id") + func bareNameStartingWithDigit() throws { + let statement = "CREATE TABLE t (\n id int(11) NOT NULL,\n 1st_id varchar(36) DEFAULT (uuid()),\n" + + " `$col` varchar(9) DEFAULT (upper('x')),\n PRIMARY KEY (id)\n) ORGANIZATION INDEX" + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: statement)) + #expect(clauses["1st_id"] == "(uuid())") + #expect(clauses["$col"] == "(upper('x'))") + } + + @Test("A bare CURRENT_TIMESTAMP in the catalog takes the column's own precision, the only one OceanBase accepts") + func currentTimestampTakesColumnPrecision() { + #expect(OceanBaseColumnDefaults.currentTimestampDefault("CURRENT_TIMESTAMP", dataType: "DATETIME(3)") + == "CURRENT_TIMESTAMP(3)") + #expect(OceanBaseColumnDefaults.currentTimestampDefault("CURRENT_TIMESTAMP", dataType: "TIMESTAMP(6)") + == "CURRENT_TIMESTAMP(6)") + #expect(OceanBaseColumnDefaults.currentTimestampDefault("CURRENT_TIMESTAMP", dataType: "DATETIME") + == "CURRENT_TIMESTAMP") + #expect(OceanBaseColumnDefaults.currentTimestampDefault("CURRENT_TIMESTAMP", dataType: "VARCHAR(40)") == nil) + #expect(OceanBaseColumnDefaults.currentTimestampDefault("2020-01-01 00:00:00", dataType: "DATETIME") == nil) + } + + private static let ansiQuotedIdentifiers = #""" + CREATE TABLE "t" ( + "id" int(11) NOT NULL, + "e" varchar(36) DEFAULT (uuid()), + "l" varchar(20) DEFAULT 'it\'s (x)', + "b" varchar(20) DEFAULT 'back\\slash (y)', + "key" varchar(9) DEFAULT (upper('k')), + "say ""hi""" varchar(9) DEFAULT (lower('H')), + PRIMARY KEY ("id") + ) \#(tableOptions) + """# + + @Test("Identifiers in double quotes, as ANSI_QUOTES prints them, are read") + func ansiQuotedIdentifiersAreRead() throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.ansiQuotedIdentifiers)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["e"], catalogDefault: "uuid()") == .value("(uuid())")) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["l"], catalogDefault: "it's (x)") == .value("'it''s (x)'")) + #expect( + OceanBaseColumnDefaults.resolve(clause: clauses["b"], catalogDefault: #"back\slash (y)"#) + == .value(#"'back\\slash (y)'"#) + ) + #expect(clauses["key"] == "(upper('k'))") + #expect(clauses[#"say "hi""#] == "(lower('H'))") + #expect(clauses["PRIMARY"] == nil) + } + + @Test("Identifiers printed without backticks, as sql_quote_show_create = 0 leaves them, are read") + func unquotedIdentifiersAreRead() throws { + let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.unquotedIdentifiers)) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["a"], catalogDefault: "(1 + 2)") == .value("((1 + 2))")) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["c"], catalogDefault: "now()") == .value("(now())")) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["g"], catalogDefault: "A") == .value("'A'")) + #expect(OceanBaseColumnDefaults.resolve(clause: clauses["key"], catalogDefault: "upper('k')") == .value("(upper('k'))")) + #expect(clauses["Mixed Case"] == "(lower('M'))") + #expect(clauses["PRIMARY"] == nil) + } + + @Test("Only a default the catalog cannot tell from a literal needs the CREATE statement", arguments: [ + ("VARCHAR(36)", "uuid()", true), + ("VARCHAR(20)", "(1 + 2)", true), + ("VARCHAR(10)", "(x)", true), + ("DATE", "curdate()", true), + ("JSON", "json_array()", true), + ("VARBINARY(4)", "unhex('41')", true), + ("VARBINARY(4)", "A", false), + ("BINARY(1)", "a", false), + ("TIMESTAMP(6)", "CURRENT_TIMESTAMP(6)", false), + ("VARCHAR(20)", "abc", false), + ("VARCHAR(20)", "-1", false), + ("DATETIME(3)", "CURRENT_TIMESTAMP", false), + ("DATE", "2020-01-01", false), + ("INT(11)", "(1 + 2)", false), + ("BIGINT UNSIGNED", "5", false), + ("DECIMAL(5,2)", "1.50", false), + ("BIT(1)", "b'1'", false), + ("enum('a,b','c''d')", "c'd", false), + ("set('a','b')", "a", false) + ]) + func needsCreateTable(dataType: String, catalogDefault: String, expected: Bool) { + #expect(OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable(catalogDefault, dataType: dataType) == expected) + #expect(!OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable(nil, dataType: dataType)) + } + + @Test("A resolved default survives the MySQL column writer unchanged") + func roundTripsThroughTheWriter() { + #expect(mysqlDefaultValueLiteral("(uuid())", dataType: "VARCHAR(36)", isMariaDB: false) == "(uuid())") + #expect(mysqlDefaultValueLiteral("'uuid()'", dataType: "VARCHAR(10)", isMariaDB: false) == "'uuid()'") + #expect(mysqlDefaultValueLiteral("(curdate())", dataType: "DATE", isMariaDB: false) == "(curdate())") + #expect( + mysqlDefaultValueLiteral("CURRENT_TIMESTAMP(3)", dataType: "DATETIME(3)", isMariaDB: false) + == "CURRENT_TIMESTAMP(3)" + ) + } +} diff --git a/project.yml b/project.yml index 2633160cd..9b8ad294b 100644 --- a/project.yml +++ b/project.yml @@ -502,6 +502,8 @@ targets: - Plugins/MySQLDriverPlugin/DatabendLiteral.swift - Plugins/MySQLDriverPlugin/DatabendResultShape.swift - Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift + - Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift + - Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift - Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift - Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift - Plugins/SQLiteDriverPlugin/SQLiteDefaultValue.swift From f55539f4af1c40474c918371afae13ba9623fd5d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 14 Sep 2026 00:09:41 +0700 Subject: [PATCH 13/15] fix(plugins): match OceanBase capabilities, system databases and row counts to the server Claude-Session: https://claude.ai/code/session_01F31dVJERHgtY1vdBVPZiPy --- .../Core/DataWrite/DataWriteRowCounts.swift | 2 +- ...etadataRegistry+MySQLVariantDefaults.swift | 15 +++-- ...ginMetadataRegistry+SnapshotAdoption.swift | 2 +- .../Core/Plugins/PluginMetadataRegistry.swift | 3 +- .../MySQLProtocolVariantParityTests.swift | 55 +++++++++++++++++++ .../Plugins/MySQLVariantSupportTests.swift | 17 ++++-- .../PluginManagerVariantAccessorTests.swift | 5 +- ...nMetadataRegistrySystemDatabaseTests.swift | 1 + .../Plugins/SQLExportBinaryLiteralTests.swift | 2 +- .../Plugins/SQLExportEncodingTests.swift | 2 +- 10 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift diff --git a/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/TablePro/Core/DataWrite/DataWriteRowCounts.swift index 60da475a8..c1a8415f5 100644 --- a/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/TablePro/Core/DataWrite/DataWriteRowCounts.swift @@ -14,7 +14,7 @@ import Foundation /// count `OracleNIO` carries on a finished stream. enum DataWriteRowCounts { private static let enginesReportingRealCounts: Set = [ - .mysql, .mariadb, .tidb, + .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .cockroachdb, .pglite, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 007f32e08..7b801d521 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -43,7 +43,7 @@ extension PluginMetadataRegistry { "Spatial": ["GEOMETRY", "GEOGRAPHY"], ] - static func mysqlColumnTypesWithoutSpatial(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { + static func tidbColumnTypes(from mysqlColumnTypes: [String: [String]]) -> [String: [String]] { mysqlColumnTypes.filter { $0.key != "Spatial" } } @@ -119,7 +119,7 @@ extension PluginMetadataRegistry { editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: dialect, statementCompletions: [], - columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) + columnTypesByCategory: tidbColumnTypes(from: mysqlColumnTypes) ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [idleReleaseField], @@ -219,7 +219,7 @@ extension PluginMetadataRegistry { supportsSSH: true, supportsSSL: true, supportsCascadeDrop: false, - supportsForeignKeyDisable: false, + supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, @@ -235,7 +235,7 @@ extension PluginMetadataRegistry { supportsRoutines: true, supportsDatabaseTriggerBrowse: true, defaultSSLMode: .preferred, - supportsPrincipalConnectionLimit: false + supportsPrincipalConnectionLimit: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -244,7 +244,10 @@ extension PluginMetadataRegistry { containerEntityName: "Database", defaultPrimaryKeyColumn: nil, immutableColumns: [], - systemDatabaseNames: ["information_schema", "mysql", "oceanbase"], + systemDatabaseNames: [ + "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", + "SYS", "LBACSYS", "ORAAUDITOR" + ], systemSchemaNames: [], fileExtensions: [], databaseGroupingStrategy: .byDatabase, @@ -257,7 +260,7 @@ extension PluginMetadataRegistry { editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: dialect, statementCompletions: [], - columnTypesByCategory: mysqlColumnTypesWithoutSpatial(from: mysqlColumnTypes) + columnTypesByCategory: mysqlColumnTypes ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [idleReleaseField], diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index bc2a81bef..c3045a6b2 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -21,7 +21,7 @@ extension PluginMetadataRegistry { /// /// Two facts qualify: case-insensitive matching, which is why Redshift is spelled /// `postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction)`, and the column type list, - /// which TiDB and OceanBase narrow (no spatial types) and Databend replaces with its own. Each has its own + /// which TiDB narrows (no spatial types) and Databend replaces with its own. Each has its own /// named adoption here rather than a value comparison: `SQLDialectDescriptor` is not /// `Equatable`, and a whole-descriptor diff would report "differs" for Redshift and hand it /// the stub back. diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 08338a527..089ce0131 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -701,7 +701,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { /// Keyed by `databaseTypeId`. Stale plugins from the registry inherit these on registration. static func fallbackCategory(forTypeId typeId: String) -> DatabaseCategory { switch typeId { - case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL", "OceanBase": + case "MySQL", "MariaDB", "PostgreSQL", "SQLite", "Oracle", "MSSQL": return .relational case "Redshift", "ClickHouse", "DuckDB", "BigQuery": return .analytical @@ -728,7 +728,6 @@ final class PluginMetadataRegistry: @unchecked Sendable { switch typeId { case "MySQL": return String(localized: "Most popular open-source SQL database") case "MariaDB": return String(localized: "Open-source fork of MySQL") - case "OceanBase": return String(localized: "Distributed HTAP, MySQL-compatible") case "PostgreSQL": return String(localized: "Advanced object-relational SQL") case "Redshift": return String(localized: "Amazon's columnar warehouse on Postgres") case "SQLite": return String(localized: "Embedded zero-config SQL database") diff --git a/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift b/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift new file mode 100644 index 000000000..aa74e37bd --- /dev/null +++ b/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift @@ -0,0 +1,55 @@ +// +// MySQLProtocolVariantParityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MySQL-protocol variants agree across the family lists") +@MainActor +struct MySQLProtocolVariantParityTests { + nonisolated private static let variants: [DatabaseType] = [.mariadb, .tidb, .databend, .oceanbase] + nonisolated private static let outsideMySQLDialect: Set = [.databend] + + @Test("The pinned variants are exactly the types the registry sends to the MySQL plugin") + func variantListIsComplete() { + let routed = DatabaseType.allKnownTypes.filter { + $0 != .mysql && PluginMetadataRegistry.shared.pluginTypeId(for: $0.rawValue) == DatabaseType.mysql.rawValue + } + #expect(Set(routed) == Set(Self.variants)) + } + + @Test("Every variant is driven by the MySQL plugin", arguments: variants) + func drivenByMySQLPlugin(type: DatabaseType) { + #expect(PluginMetadataRegistry.shared.pluginTypeId(for: type.rawValue) == DatabaseType.mysql.rawValue) + #expect(type.pluginTypeId == DatabaseType.mysql.rawValue) + } + + @Test("A variant that speaks MySQL SQL is MySQL in every list keyed by dialect", arguments: variants) + func dialectListsAgree(type: DatabaseType) { + let speaksMySQL = !Self.outsideMySQLDialect.contains(type) + #expect((SqlDialect.from(databaseTypeId: type.rawValue) == .mysql) == speaksMySQL) + #expect((SQLTypeFamily.of(type) == .mysql) == speaksMySQL) + #expect( + (ImportTypeMapper.sqlType(for: .real, databaseType: type) + == ImportTypeMapper.sqlType(for: .real, databaseType: .mysql)) == speaksMySQL + ) + #expect((ForeignKeyDialect.forType(type) == ForeignKeyDialect.forType(.mysql)) == speaksMySQL) + } + + @Test("A variant whose driver reads mysql_affected_rows holds a keyless save to its row count", arguments: variants) + func rowCountsAgree(type: DatabaseType) { + #expect(DataWriteRowCounts.areMeaningful(for: type) == !Self.outsideMySQLDialect.contains(type)) + } + + @Test("The plugin flavor and the registry name the same system databases") + func systemDatabaseListsAgree() { + let manager = PluginManager.shared + #expect(manager.systemDatabaseNames(for: .tidb) == MySQLServerFlavor.tidb(version: nil).systemDatabaseNames) + #expect(manager.systemDatabaseNames(for: .databend) == MySQLServerFlavor.databend.systemDatabaseNames) + #expect(manager.systemDatabaseNames(for: .oceanbase) == MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames) + } +} diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index 2a6c7271d..71fa7fd2a 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -47,12 +47,14 @@ struct MySQLVariantSupportTests { #expect(types["JSON"] == ["JSON"]) } - @Test("OceanBase keeps its type list without the spatial group once the MySQL plugin registers") + @Test("OceanBase keeps MySQL's spatial types once the MySQL plugin registers") func oceanbaseColumnTypesSurviveRegistration() throws { let registry = PluginMetadataRegistry.shared - registry.registerVariant(pluginSnapshot: try Self.mysqlPluginSnapshot(), forTypeId: "OceanBase", primaryTypeId: "MySQL") + let mysql = try Self.mysqlPluginSnapshot() + registry.registerVariant(pluginSnapshot: mysql, forTypeId: "OceanBase", primaryTypeId: "MySQL") let types = try #require(registry.snapshot(forRegisteredTypeId: "OceanBase")).editor.columnTypesByCategory - #expect(types["Spatial"] == nil) + #expect(types["Spatial"] != nil) + #expect(types["Spatial"] == mysql.editor.columnTypesByCategory["Spatial"]) #expect(types["JSON"] == ["JSON"]) } @@ -84,11 +86,18 @@ struct MySQLVariantSupportTests { @Test("TiDB hides the connection limit it ignores; the others keep it") func principalConnectionLimit() { #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .tidb)) - #expect(!PluginManager.shared.supportsPrincipalConnectionLimit(for: .oceanbase)) + #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .oceanbase)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mysql)) #expect(PluginManager.shared.supportsPrincipalConnectionLimit(for: .mariadb)) } + @Test("OceanBase honours SET FOREIGN_KEY_CHECKS, so the option to skip them stays; Databend has none") + func foreignKeyDisable() { + #expect(PluginManager.shared.supportsForeignKeyDisable(for: .oceanbase)) + #expect(PluginManager.shared.supportsForeignKeyDisable(for: .tidb)) + #expect(!PluginManager.shared.supportsForeignKeyDisable(for: .databend)) + } + @Test("Only Databend leaves column types out of a keyless row match") func rowMatchExclusions() { #expect(PluginManager.shared.rowMatchExcludedTypePrefixes(for: .databend).contains("VARIANT")) diff --git a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift index 4cce008ed..ad1ce5018 100644 --- a/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerVariantAccessorTests.swift @@ -49,7 +49,10 @@ struct PluginManagerVariantAccessorTests { "INFORMATION_SCHEMA", "METRICS_SCHEMA", "PERFORMANCE_SCHEMA", "mysql", "sys" ]) #expect(manager.systemDatabaseNames(for: .databend) == ["information_schema", "system"]) - #expect(manager.systemDatabaseNames(for: .oceanbase) == ["information_schema", "mysql", "oceanbase"]) + #expect(manager.systemDatabaseNames(for: .oceanbase) == [ + "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", + "SYS", "LBACSYS", "ORAAUDITOR" + ]) } /// The reason the editor half of this was reported: Redshift has no non-ASCII ILIKE, so it diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistrySystemDatabaseTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistrySystemDatabaseTests.swift index 224c0a8bc..5ede1c91e 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistrySystemDatabaseTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistrySystemDatabaseTests.swift @@ -49,6 +49,7 @@ struct PluginMetadataRegistrySystemDatabaseTests { ("CockroachDB", "defaultdb"), ("TiDB", "test"), ("Databend", "default"), + ("OceanBase", "test"), ] for (typeId, database) in defaults { guard let names = systemDatabaseNames(forTypeId: typeId) else { diff --git a/TableProTests/Plugins/SQLExportBinaryLiteralTests.swift b/TableProTests/Plugins/SQLExportBinaryLiteralTests.swift index 329ce2879..9addff55e 100644 --- a/TableProTests/Plugins/SQLExportBinaryLiteralTests.swift +++ b/TableProTests/Plugins/SQLExportBinaryLiteralTests.swift @@ -19,7 +19,7 @@ struct SQLExportBinaryLiteralTests { @Test("MySQL and SQLite keep the hex literal they have always taken") func hexLiteralEnginesAreUnchanged() { - for typeId in ["MySQL", "MariaDB", "TiDB", "SQLite", "libSQL", "Turso", "DuckDB", "Cloudflare D1"] { + for typeId in ["MySQL", "MariaDB", "TiDB", "OceanBase", "SQLite", "libSQL", "Turso", "DuckDB", "Cloudflare D1"] { #expect(SQLExportBinaryLiteral.render(sample, databaseTypeId: typeId) == "X'414243'") } } diff --git a/TableProTests/Plugins/SQLExportEncodingTests.swift b/TableProTests/Plugins/SQLExportEncodingTests.swift index e3096004d..542179337 100644 --- a/TableProTests/Plugins/SQLExportEncodingTests.swift +++ b/TableProTests/Plugins/SQLExportEncodingTests.swift @@ -18,7 +18,7 @@ struct SQLExportEncodingTests { @Test("A MySQL dump declares utf8mb4 the way mysqldump does, and puts the session back") func mysqlDeclaresUTF8MB4() { - for typeId in ["MySQL", "MariaDB", "TiDB"] { + for typeId in ["MySQL", "MariaDB", "TiDB", "OceanBase"] { let declaration = SQLExportEncodingDeclaration.forDatabaseType(typeId) #expect(declaration.prologue.contains("/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;")) #expect(declaration.prologue.contains("/*!40101 SET NAMES utf8 */;")) From 7bde779676e3fb1ba7980927caefec71ffdcc4aa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 14 Sep 2026 00:09:41 +0700 Subject: [PATCH 14/15] fix(connections): import OceanBase observer and OBProxy services from Docker Compose Claude-Session: https://claude.ai/code/session_01F31dVJERHgtY1vdBVPZiPy --- .../DockerComposeExtractor.swift | 120 ++++++++++++++---- .../ProjectYamlExtractorTests.swift | 103 +++++++++++---- .../ConnectionURLParserOceanBaseTests.swift | 11 ++ 3 files changed, 184 insertions(+), 50 deletions(-) diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 925489ae7..67626be4f 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -7,9 +7,20 @@ import Foundation import TableProPluginKit enum DockerComposeExtractor { + enum OceanBaseRole { + case observer + case proxy + } + struct ServiceDatabase { let type: DatabaseType let defaultPort: Int + var oceanbaseRole: OceanBaseRole? + } + + struct OceanBaseObserver { + let hostNames: Set + let variables: [String: String] } static func extract( @@ -22,18 +33,20 @@ enum DockerComposeExtractor { let services = YamlMappingSupport.mapping(root["services"]) else { return [] } + let observers = oceanbaseObservers(in: services) return services.keys.sorted().compactMap { name in guard let service = YamlMappingSupport.mapping(services[name]) else { return nil } - return candidate(name: name, service: service, relativePath: relativePath) + return candidate(name: name, service: service, relativePath: relativePath, oceanbaseObservers: observers) } } static func candidate( name: String, service: [String: Any], - relativePath: String + relativePath: String, + oceanbaseObservers: [OceanBaseObserver] ) -> ScannedConnectionCandidate? { guard let image = YamlMappingSupport.string(service["image"]), let database = databaseKind(for: image) else { @@ -43,7 +56,17 @@ enum DockerComposeExtractor { var fields = ScannedConnectionFields(type: database.type) fields.host = "127.0.0.1" fields.connectionName = name - applyCredentials(&fields, type: database.type, variables: variables) + switch database.oceanbaseRole { + case .observer: + applyOceanBaseCredentials(&fields, variables: variables, cluster: nil) + case .proxy: + let observer = proxiedObserver(rsList: variables["RS_LIST"], among: oceanbaseObservers) + applyOceanBaseCredentials( + &fields, variables: observer?.variables ?? [:], cluster: variables["OB_CLUSTER"]?.nilIfEmpty + ) + case nil: + applyCredentials(&fields, type: database.type, variables: variables) + } var warnings: [String] = [] if [fields.username, fields.password, fields.database].contains(where: ComposeInterpolator.isUnresolved) { warnings.append(String(localized: "Some values are set outside this file")) @@ -71,27 +94,29 @@ enum DockerComposeExtractor { "datafuselabs/databend-query", "databendlabs/databend-query", ] - /// The `oceanbase` organization also publishes OCP, obagent, the config server and miniob, none of - /// which speak the MySQL protocol, so the repository is matched rather than the whole image name. - /// OBProxy serves SQL on 2883, the observer on 2881. - private static let oceanbaseRepositories: [String: Int] = [ - "oceanbase/oceanbase-ce": 2_881, - "oceanbase/oceanbase": 2_881, - "oceanbase/obproxy-ce": 2_883, - "oceanbase/obproxy": 2_883, + private static let oceanbaseObserverRepositories: Set = [ + "oceanbase/oceanbase-ce", "oceanbase/oceanbase", + ] + + private static let oceanbaseProxyRepositories: Set = [ + "oceanbase/obproxy-ce", "oceanbase/obproxy", ] static func databaseKind(for image: String) -> ServiceDatabase? { let name = image.lowercased() let repositoryPath = repositoryComponents(of: name) + let repository = repositoryPath.suffix(2).joined(separator: "/") if repositoryPath.last == "tidb" { return ServiceDatabase(type: .tidb, defaultPort: 4_000) } - if databendRepositories.contains(repositoryPath.suffix(2).joined(separator: "/")) { + if databendRepositories.contains(repository) { return ServiceDatabase(type: .databend, defaultPort: 3_307) } - if let port = oceanbaseRepositories[repositoryPath.suffix(2).joined(separator: "/")] { - return ServiceDatabase(type: .oceanbase, defaultPort: port) + if oceanbaseObserverRepositories.contains(repository) { + return ServiceDatabase(type: .oceanbase, defaultPort: 2_881, oceanbaseRole: .observer) + } + if oceanbaseProxyRepositories.contains(repository) { + return ServiceDatabase(type: .oceanbase, defaultPort: 2_883, oceanbaseRole: .proxy) } if name.contains("postgres"), !name.contains("postgrest") { return ServiceDatabase(type: .postgresql, defaultPort: 5_432) @@ -117,18 +142,6 @@ enum DockerComposeExtractor { return nil } - /// OBProxy routes by a cluster the observer's own port does not need, and `root@sys` reaches it - /// only when the proxy was given a default cluster, so the name goes into the username whenever - /// the compose file states it. - private static func applyOceanBaseCredentials(_ fields: inout ScannedConnectionFields, variables: [String: String]) { - let tenantPassword = variables["OB_TENANT_PASSWORD"]?.nilIfEmpty - let tenant = variables["OB_TENANT_NAME"]?.nilIfEmpty ?? (tenantPassword != nil ? "test" : "sys") - let cluster = variables["OB_CLUSTER_NAME"]?.nilIfEmpty - fields.username = "root@\(tenant)" + (cluster.map { "#\($0)" } ?? "") - fields.password = tenant == "sys" ? variables["OB_SYS_PASSWORD"] ?? "" : tenantPassword ?? "" - fields.database = variables["OB_DATABASE"] ?? "" - } - static func repositoryComponents(of image: String) -> [String] { let withoutDigest = image.split(separator: "@", maxSplits: 1).first.map(String.init) ?? image var components = withoutDigest.split(separator: "/").map(String.init) @@ -187,6 +200,59 @@ enum DockerComposeExtractor { return nil } + private static func oceanbaseObservers(in services: [String: Any]) -> [OceanBaseObserver] { + services.keys.sorted().compactMap { name in + guard let service = YamlMappingSupport.mapping(services[name]), + let image = YamlMappingSupport.string(service["image"]), + databaseKind(for: image)?.oceanbaseRole == .observer + else { return nil } + let hostNames = [name, YamlMappingSupport.string(service["container_name"]), + YamlMappingSupport.string(service["hostname"])] + return OceanBaseObserver( + hostNames: Set(hostNames.compactMap { $0 }), + variables: environmentVariables(service["environment"]) + ) + } + } + + private static func proxiedObserver(rsList: String?, among observers: [OceanBaseObserver]) -> OceanBaseObserver? { + let hosts = (rsList ?? "").split(separator: ";").compactMap { entry in + entry.split(separator: ":").first.map { $0.trimmingCharacters(in: .whitespaces) } + } + if let named = observers.first(where: { !$0.hostNames.isDisjoint(with: hosts) }) { + return named + } + return observers.count == 1 ? observers.first : nil + } + + private static func applyOceanBaseCredentials( + _ fields: inout ScannedConnectionFields, + variables: [String: String], + cluster: String? + ) { + let clusterSuffix = cluster.map { "#\($0)" } ?? "" + let tenantName = variables["OB_TENANT_NAME"]?.nilIfEmpty + let tenantPassword = variables["OB_TENANT_PASSWORD"]?.nilIfEmpty + let database = variables["OB_DATABASE"]?.nilIfEmpty + if tenantName != nil || tenantPassword != nil || database != nil { + fields.username = "root@\(tenantName ?? "test")\(clusterSuffix)" + fields.password = tenantPassword ?? "" + fields.database = database ?? "" + return + } + let bootsFromDemoStore = variables["MODE"]?.uppercased() == "SLIM" + let sysPassword = variables["OB_SYS_PASSWORD"]?.nilIfEmpty ?? variables["OB_ROOT_PASSWORD"]?.nilIfEmpty + if !bootsFromDemoStore, let sysPassword { + fields.username = "root@sys\(clusterSuffix)" + fields.password = sysPassword + fields.database = "" + return + } + fields.username = "root@test\(clusterSuffix)" + fields.password = "" + fields.database = "" + } + private static func applyCredentials( _ fields: inout ScannedConnectionFields, type: DatabaseType, @@ -205,8 +271,6 @@ enum DockerComposeExtractor { fields.username = variables["QUERY_DEFAULT_USER"] ?? "root" fields.password = variables["QUERY_DEFAULT_PASSWORD"] ?? "" fields.database = "default" - case .oceanbase: - applyOceanBaseCredentials(&fields, variables: variables) case .mariadb, .mysql: let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil ? "MARIADB" diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index 317749d3a..7570c45fd 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -262,25 +262,27 @@ struct DockerComposeExtractorTests { #expect(databend?.parsedURL.database == "default") } - @Test("OceanBase images map to OceanBase on 2881 as root@sys") - func testOceanBaseImageAndCredentials() { + @Test("An OceanBase observer with no tenant settings imports as root of the image's test tenant") + func testOceanBaseDefaultTenant() { let oceanbase = extract(""" services: ob: image: oceanbase/oceanbase-ce:latest + environment: + OB_TENANT_NAME: "" ports: - "2881:2881" """).first #expect(oceanbase?.parsedURL.type == .oceanbase) #expect(oceanbase?.parsedURL.port == 2_881) - #expect(oceanbase?.parsedURL.username == "root@sys") + #expect(oceanbase?.parsedURL.username == "root@test") #expect(oceanbase?.parsedURL.password.isEmpty == true) #expect(oceanbase?.parsedURL.database.isEmpty == true) } - @Test("An OceanBase tenant password names that tenant, and OBProxy is imported on its own port") - func testOceanBaseTenantAndProxy() { - let candidates = extract(""" + @Test("Tenant variables name the tenant, its password and its database, and the observer takes no cluster") + func testOceanBaseTenantVariables() { + let oceanbase = extract(""" services: ob: image: oceanbase/oceanbase-ce:4.4.2 @@ -289,41 +291,98 @@ struct DockerComposeExtractorTests { OB_TENANT_PASSWORD: tenantpw OB_SYS_PASSWORD: syspw OB_DATABASE: shop + OB_CLUSTER_NAME: obcluster ports: - "2881:2881" + """).first + #expect(oceanbase?.parsedURL.username == "root@app") + #expect(oceanbase?.parsedURL.password == "tenantpw") + #expect(oceanbase?.parsedURL.database == "shop") + } + + @Test("A sys password alone means the sys tenant, except in SLIM mode, which never applies it") + func testOceanBaseSysPassword() { + let candidates = extract(""" + services: + current: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + OB_SYS_PASSWORD: syspw + legacy: + image: oceanbase/oceanbase-ce:4.0.0.0 + environment: + - OB_ROOT_PASSWORD=rootpw + slim: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + MODE: slim + OB_SYS_PASSWORD: syspw + """) + let current = candidates.first { $0.sourceKey == "services.current" } + #expect(current?.parsedURL.username == "root@sys") + #expect(current?.parsedURL.password == "syspw") + let legacy = candidates.first { $0.sourceKey == "services.legacy" } + #expect(legacy?.parsedURL.username == "root@sys") + #expect(legacy?.parsedURL.password == "rootpw") + let slim = candidates.first { $0.sourceKey == "services.slim" } + #expect(slim?.parsedURL.username == "root@test") + #expect(slim?.parsedURL.password.isEmpty == true) + } + + @Test("OBProxy imports on 2883 as the tenant of the observer its RS_LIST names, qualified by its cluster") + func testOceanBaseProxy() { + let candidates = extract(""" + services: proxy: - image: oceanbase/obproxy-ce:latest + image: oceanbase/obproxy-ce:4.3.5.0-3 + environment: + APP_NAME: tablepro + OB_CLUSTER: obcluster + RS_LIST: "observer:2881" ports: - "2883:2883" + observer: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + OB_TENANT_NAME: app + OB_TENANT_PASSWORD: tenantpw + other: + image: oceanbase/oceanbase-ce:4.4.2 + environment: + OB_TENANT_NAME: other """) - let observer = candidates.first { $0.sourceKey == "services.ob" } - #expect(observer?.parsedURL.username == "root@app") - #expect(observer?.parsedURL.password == "tenantpw") - #expect(observer?.parsedURL.database == "shop") let proxy = candidates.first { $0.sourceKey == "services.proxy" } #expect(proxy?.parsedURL.type == .oceanbase) #expect(proxy?.parsedURL.port == 2_883) + #expect(proxy?.parsedURL.username == "root@app#obcluster") + #expect(proxy?.parsedURL.password == "tenantpw") } - @Test("An OBProxy cluster name joins the username, and an empty tenant name keeps the default") - func testOceanBaseClusterAndEmptyTenant() { - let candidates = extract(""" + @Test("A proxy borrows the only observer's tenant, and with none keeps the default tenant") + func testOceanBaseProxyWithoutNamedObserver() { + let single = extract(""" services: proxy: image: oceanbase/obproxy-ce:latest environment: - OB_CLUSTER_NAME: obcluster - ports: - - "2883:2883" + OB_CLUSTER: demo + RS_LIST: "172.20.0.5:2881" ob: image: oceanbase/oceanbase-ce:latest environment: - OB_TENANT_NAME: "" + OB_TENANT_NAME: app + """).first { $0.sourceKey == "services.proxy" } + #expect(single?.parsedURL.username == "root@app#demo") + + let alone = extract(""" + services: + proxy: + image: oceanbase/obproxy-ce:latest ports: - - "2881:2881" - """) - #expect(candidates.first { $0.sourceKey == "services.proxy" }?.parsedURL.username == "root@sys#obcluster") - #expect(candidates.first { $0.sourceKey == "services.ob" }?.parsedURL.username == "root@sys") + - "2883:2883" + """).first + #expect(alone?.parsedURL.username == "root@test") + #expect(alone?.parsedURL.password.isEmpty == true) } @Test("OceanBase images that do not serve SQL are not imported") diff --git a/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift index f05642df9..8afbd2075 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserOceanBaseTests.swift @@ -41,4 +41,15 @@ struct ConnectionURLParserOceanBaseTests { #expect(parsed.host == "host") #expect(parsed.database == "db") } + + @Test("An OBProxy user name keeps its tenant and cluster") + func testProxyUserNameWithCluster() { + let result = ConnectionURLParser.parse("oceanbase://root%40test%23obcluster:pass@proxy:2883/db") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.username == "root@test#obcluster") + #expect(parsed.password == "pass") + #expect(parsed.port == 2_883) + } } From 4123644b71737c2f19cc0532f40dd974f236bddd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 14 Sep 2026 00:09:42 +0700 Subject: [PATCH 15/15] docs: describe OceanBase tenants, OBProxy, timeouts and version floors Claude-Session: https://claude.ai/code/session_01F31dVJERHgtY1vdBVPZiPy --- docs/customization/general-settings.mdx | 2 +- docs/databases/oceanbase.mdx | 44 +++++++++++-------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/customization/general-settings.mdx b/docs/customization/general-settings.mdx index 68b4ff549..0d3d073cc 100644 --- a/docs/customization/general-settings.mdx +++ b/docs/customization/general-settings.mdx @@ -36,7 +36,7 @@ Icons, comments, and row size are also under **View Options**, the button beside How many seconds a query runs before it is cancelled. The default is 60; the picker offers 10 to 600 seconds and **No limit**. The value is read when a connection opens, so a change reaches an open connection only after you reconnect. -Where the engine can enforce it, the timeout becomes a server-side setting: `statement_timeout` on PostgreSQL, `max_execution_time` on MySQL and ClickHouse, `max_statement_time` on MariaDB. On SQLite it bounds how long a statement waits for a locked database instead. Drivers that talk HTTP (BigQuery, Spanner, Cloudflare D1, LibSQL, Etcd, DynamoDB, Elasticsearch, Typesense, and ClickHouse) bound the request at the timeout plus 30 seconds, and Oracle enforces it in the client: the connection is closed to unblock the call, and the next query reconnects and restores your current schema. +Where the engine can enforce it, the timeout becomes a server-side setting: `statement_timeout` on PostgreSQL, `max_execution_time` on MySQL and ClickHouse, `max_statement_time` on MariaDB, `ob_query_timeout` on OceanBase. On SQLite it bounds how long a statement waits for a locked database instead. Drivers that talk HTTP (BigQuery, Spanner, Cloudflare D1, LibSQL, Etcd, DynamoDB, Elasticsearch, Typesense, and ClickHouse) bound the request at the timeout plus 30 seconds, and Oracle enforces it in the client: the connection is closed to unblock the call, and the next query reconnects and restores your current schema. **No limit** still caps an HTTP request at one hour, because the transport needs a ceiling. diff --git a/docs/databases/oceanbase.mdx b/docs/databases/oceanbase.mdx index 7a273b482..b04e25a3b 100644 --- a/docs/databases/oceanbase.mdx +++ b/docs/databases/oceanbase.mdx @@ -1,59 +1,55 @@ --- title: OceanBase -description: Connect to OceanBase MySQL mode through the bundled MySQL driver on port 2881 +description: Connect to an OceanBase MySQL-mode tenant through the bundled MySQL driver, directly or through OBProxy --- -Sign in as `user@tenant`. The sys tenant's root is `root@sys`. Oracle compatibility mode is a different protocol and does not connect. - -No minimum OceanBase version is enforced. Host, password, SSH tunnels, SSL/TLS and `Cmd+K` database switching work as on [MySQL](/databases/mysql). +The tenant goes in the user name: `root@test` on an observer's port 2881, `root@test#obcluster` through OBProxy on 2883. Any OceanBase version in MySQL mode connects. Host, password, SSH tunnels, SSL/TLS and `Cmd+K` database switching work as on [MySQL](/databases/mysql). ## Connection settings | Field | Default | Notes | |-------|---------|-------| -| **Port** | `2881` | The SQL port for MySQL mode. Do not point at an OBProxy extras port unless that is the SQL listener | +| **Port** | `2881` | The observer's MySQL port. OBProxy listens on `2883` | +| **Username** | empty | `user@tenant` on an observer, `user@tenant#cluster` through OBProxy. The sys tenant's administrator is `root@sys` | | **Database** | empty | Optional. Leave it empty to browse every database in the tenant | | **SSL Mode** | Preferred | TLS first, plain text if the server refuses | -| **Username** | empty | `user@tenant`. In a URL, write the `@` as `%40`: `root%40sys` | **Release the Server Connection After** is available under Advanced. AWS IAM, Cloud SQL Auth Proxy and Unix socket are not offered; reach a private cluster through an [SSH tunnel](/connections/ssh-tunneling). ## Connection URL ```text -oceanbase://root%40sys:password@host:2881/database +oceanbase://root%40test:password@host:2881/database +oceanbase://root%40test%23obcluster:password@proxy-host:2883/database ``` -`oceanbase+ssh://` opens it through an SSH tunnel. See [Connection URL Reference](/connections/urls). - -## Tables without a primary key +Write the `@` in the user name as `%40` and the `#` as `%23`. `oceanbase+ssh://` opens it through an SSH tunnel. See [Connection URL Reference](/connections/urls). -OceanBase keeps a hidden `__pk_increment` column on a table declared without a primary key, but the catalog never reports it and a plain `SELECT *` never returns it, so TablePro edits such a table the way it edits one on [MySQL](/databases/mysql): the `UPDATE` or `DELETE` matches every column of the row, and a save that would touch more than one row is rolled back with a message that identical rows cannot be told apart. A `FLOAT`, `DOUBLE` or `JSON` column is compared through `CONCAT()`, because the text those types read back as does not compare equal to the value it came from. +## What differs from MySQL -The connection type decides the sidebar, the type picker, EXPLAIN and Stop. A server reached through a connection saved as **MySQL** is treated as MySQL, because the MySQL handshake reports `5.7.25` and names no engine. Choose **OceanBase** for what this page describes. +- Connecting reads `@@version_comment`. A server that does not answer with `OceanBase` and a version number there is refused, so a wrong host or port fails at connect. +- The sidebar hides `information_schema`, `mysql`, `oceanbase`, `SYS`, `LBACSYS` and `ORAAUDITOR`. A tenant's `test` database stays listed. +- The [query timeout](/customization/general-settings#query-timeout) replaces OceanBase's own 10 second statement limit, and **No limit** removes it. +- CHECK constraints appear in the Structure tab on OceanBase 4.0 and later. Adding one from the Structure tab is not offered. +- Triggers appear in the Structure tab. Adding one from there is not offered. +- `EXPLAIN` shows the plan as text. `EXPLAIN FORMAT=JSON` and `EXPLAIN ANALYZE` are not offered. +- Table Maintenance offers `ANALYZE TABLE` on OceanBase 4.2.2 and later, and nothing on earlier versions. -Opening an OceanBase connection reads `@@version_comment`. A server that does not name OceanBase there is refused, so a mistyped host or port fails at connect instead of part way through a session. +A connection saved as **MySQL** that reaches an OceanBase server gets none of the above, because the MySQL handshake reports `5.7.25` or `5.6.25` and names no engine. Choose **OceanBase** as the connection type. -## What the MySQL driver does not copy +## Tables without a primary key -- The sidebar hides `information_schema`, `mysql` and `oceanbase`. User databases such as `test` stay listed. -- **Stop** sends `KILL QUERY` for the session's own id, as on MySQL. -- `EXPLAIN` is plain text. `EXPLAIN FORMAT=JSON` is not offered. -- The type picker has MySQL's types without the Spatial group. -- [Users & Roles](/features/users-roles) has no connection limit field. -- Table Maintenance offers `ANALYZE TABLE` only. -- Foreign keys can be created. `SET FOREIGN_KEY_CHECKS` is not sent. Triggers appear in the Structure tab; adding one from there is not offered. +An OceanBase table declared without a primary key is edited as it is on MySQL: the `UPDATE` or `DELETE` matches every column of the row, and a save that would change more than one row, or none, stops and reports it. See [Change tracking](/features/change-tracking). ## Limitations -- Oracle compatibility mode does not connect. Use a MySQL-mode tenant and the MySQL protocol port. +- Oracle-mode tenants do not connect. Point the connection at a MySQL-mode tenant. - No Server Dashboard, and **File > Backup Dump…** stays dimmed. - [Compare & Sync](/features/compare-sync) writes no structure script between OceanBase and MySQL or MariaDB. OceanBase against OceanBase works. -- Cluster tenant administration is out of scope. +- Tenant and cluster administration is not offered. Create tenants with SQL as `root@sys`. ## Related - [MySQL](/databases/mysql), for connection fields, SSL/TLS, and troubleshooting -- [MariaDB](/databases/mariadb) - [TiDB](/databases/tidb) - [SSH Tunneling](/connections/ssh-tunneling)