Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e163916
feat(plugin-mysql): add OceanBase MySQL-mode connection type
cursoragent Sep 11, 2026
5327a06
fix(plugin-mysql): spell OceanBase binary literals as MySQL bit-strings
J2TeamNNL Sep 11, 2026
af1c60e
ci(plugin-mysql): retrigger macOS tests after XcodeGen checksum flake
J2TeamNNL Sep 11, 2026
7f128f4
Merge remote-tracking branch 'origin/main' into pr-2741-oceanbase
datlechin Sep 11, 2026
c0fe860
refactor(plugin-mysql): identify OceanBase by its version comment and…
datlechin Sep 12, 2026
5c175dd
fix(plugin-mysql): apply the query timeout to OceanBase statements th…
datlechin Sep 12, 2026
3f39ea9
Merge remote-tracking branch 'origin/main' into pr-2741-oceanbase
datlechin Sep 12, 2026
3837a73
test(plugins): count OceanBase in the built-in database types
datlechin Sep 12, 2026
d4273f6
fix(ios): share PostgreSQL catalog quoting with the iOS driver
J2TeamNNL Sep 12, 2026
bd0aa8c
merge(main): bring maintenance descriptors and later main into OceanBase
J2TeamNNL Sep 12, 2026
a075464
merge(main): pick up iOS PostgreSQL quoting, grid clip, and confirmat…
J2TeamNNL Sep 12, 2026
697150a
test(plugins): pin Weaviate PluginKit to current ABI and theme-colore…
J2TeamNNL Sep 12, 2026
fe1b97f
fix(ios): keep shared catalog quoting off the iOS main actor
J2TeamNNL Sep 12, 2026
81597db
test(settings): scroll the Editor pane to the Vim mode toggle
J2TeamNNL Sep 12, 2026
df5cc6e
merge(main): pick up theme rewrite and keyless FLOAT/JSON row match
J2TeamNNL Sep 12, 2026
90a9868
chore: merge main into the OceanBase branch
datlechin Sep 13, 2026
97a50ee
fix(plugin-mysql): verify OceanBase identity, lift its query timeout …
datlechin Sep 13, 2026
7c1fa9d
fix(plugin-mysql): read OceanBase expression defaults from SHOW CREAT…
datlechin Sep 13, 2026
f55539f
fix(plugins): match OceanBase capabilities, system databases and row …
datlechin Sep 13, 2026
7bde779
fix(connections): import OceanBase observer and OBProxy services from…
datlechin Sep 13, 2026
4123644
docs: describe OceanBase tenants, OBProxy, timeouts and version floors
datlechin Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A line on the welcome window naming the version TablePro updated from, with a link to what changed.
- What's New window, from Help > What's New.
- Update install mode and check frequency in the anonymous usage heartbeat.
- OceanBase MySQL-mode connection type on the MySQL driver. (#1748)

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -43,7 +44,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let weaviate = DatabaseType(rawValue: "Weaviate")

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, .weaviate
Expand All @@ -56,6 +57,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"
Expand Down Expand Up @@ -87,7 +89,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -59,10 +61,11 @@ 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))
#expect(DatabaseType.allKnownTypes.contains(.oceanbase))
#expect(DatabaseType.allKnownTypes.contains(.bigquery))
#expect(DatabaseType.allKnownTypes.contains(.spanner))
#expect(DatabaseType.allKnownTypes.contains(.snowflake))
Expand Down
1 change: 1 addition & 0 deletions Plugins/MySQLDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<string>MariaDB</string>
<string>TiDB</string>
<string>Databend</string>
<string>OceanBase</string>
</array>
</dict>
</plist>
166 changes: 166 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift
Original file line number Diff line number Diff line change
@@ -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..<index] }
default:
continue
}
}
return nil
}

static func topLevelElements(of body: Substring) -> [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])
elementStart = body.index(after: index)
}
}
elements.append(body[elementStart...])
return elements
}

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
}

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..<index])
}
return tokens
}

static func endOfQuoted(in text: Substring, from start: Substring.Index) -> 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
}
}
2 changes: 1 addition & 1 deletion Plugins/MySQLDriverPlugin/MySQLKillTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal extension MySQLServerFlavor {
case .databend:
guard let session = connectionIdentifier, !session.isEmpty else { return .threadId }
return .databendSession(session)
case .mysql, .mariadb:
case .mysql, .mariadb, .oceanbase:
return .threadId
}
}
Expand Down
5 changes: 5 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLMaintenance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,17 @@ 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:
return [MySQLMaintenance.analyzeOperation]
case .oceanbase(let version):
guard let version, version >= Self.oceanbaseBareAnalyzeFloor else { return [] }
return [MySQLMaintenance.analyzeOperation]
}
}
}
6 changes: 3 additions & 3 deletions Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal struct MySQLFlavorMismatchError: Error, Equatable {
enum Kind: Equatable {
case databendNeedsItsOwnType
case notDatabend
case notOceanBase
}

let kind: Kind
Expand All @@ -22,13 +23,22 @@ 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.")
}
}
}

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 {
Expand All @@ -48,6 +58,17 @@ extension MySQLPluginDriver {
throw MySQLFlavorMismatchError(kind: .databendNeedsItsOwnType)
}

if variant == MySQLServerFlavor.oceanbaseVariant {
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 {
return bannerFlavor
}
Expand Down
Loading
Loading