From ffe604b2516c6fdea4e8a89747f9af78bfafda04 Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Fri, 30 Oct 2026 22:05:36 -0700 Subject: [PATCH 1/7] feat(mysql): connect to StarRocks and browse its catalogs and databases StarRocks type on MySQL plugin. Catalogs = database level (SHOW CATALOGS / SET CATALOG), catalog dbs = schema level (USE). Database field takes catalog.database; bare name tried as catalog, else db in default_catalog. Probe SELECT current_version() confirms server. MySQL catalog reads (indexes, FKs, routines, triggers, status) skipped. Co-authored-by: Cursor --- .../TableProCoreTypes/DatabaseType.swift | 6 +- .../SQLLexicalProfile.swift | 1 + .../SQLEngineVocabulary.swift | 1 + .../DatabaseTypeTests.swift | 5 +- .../SQLLexicalReadingsTests.swift | 2 +- .../SQLEngineVocabularyTests.swift | 3 +- apple/Plugins/MySQLDriverPlugin/Info.plist | 1 + .../MySQLAccountStatements.swift | 2 +- .../MySQLCheckConstraints.swift | 6 +- .../MySQLFunctionalKeyParts.swift | 2 +- .../MySQLDriverPlugin/MySQLKillLatch.swift | 6 +- .../MySQLDriverPlugin/MySQLKillTarget.swift | 2 +- .../MySQLDriverPlugin/MySQLMaintenance.swift | 2 + .../MySQLDriverPlugin/MySQLPlugin.swift | 7 +- .../MySQLPluginDriver+BulkMetadata.swift | 3 +- .../MySQLPluginDriver+CreationScopes.swift | 6 +- .../MySQLPluginDriver+DatabaseMetadata.swift | 8 ++ .../MySQLPluginDriver+Flavor.swift | 16 +++ .../MySQLPluginDriver+ForeignKeys.swift | 4 +- .../MySQLPluginDriver+Routines.swift | 4 +- .../MySQLPluginDriver+Schema.swift | 18 ++- .../MySQLPluginDriver+SessionScope.swift | 4 +- .../MySQLPluginDriver+StarRocks.swift | 63 ++++++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 115 +++++++++++++---- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 34 ++++- .../MySQLServerVersion.swift | 6 +- .../MySQLSystemDatabases.swift | 2 + .../MySQLDriverPlugin/MySQLTableOptions.swift | 2 +- .../MySQLDriverPlugin/StarRocksCatalog.swift | 57 +++++++++ .../MySQLVariantSystemDatabasesTests.swift | 1 + .../StarRocksCatalogTests.swift | 116 ++++++++++++++++++ .../TableProPluginKit/SqlDialect.swift | 2 +- .../starrocks-icon.imageset/Contents.json | 16 +++ .../starrocks-icon.imageset/starrocks.svg | 1 + .../Core/Compare/CompareSQLLiteral.swift | 2 +- .../Core/CrossEngine/SQLTypeFamily.swift | 1 + .../Core/DataWrite/DataWriteRowCounts.swift | 2 +- .../Core/Plugins/ImportTypeMapper.swift | 2 +- ...etadataRegistry+MySQLVariantDefaults.swift | 87 +++++++++++++ .../Core/Plugins/PluginMetadataRegistry.swift | 1 + .../CellValues/CellExpressionVocabulary.swift | 2 +- .../Models/Connection/DatabaseType.swift | 1 + .../Models/Schema/ForeignKeyDialect.swift | 2 +- .../TablePro/Resources/Localizable.xcstrings | 7 ++ .../MySQLProtocolVariantParityTests.swift | 3 +- ...PluginMetadataRegistryTypeCountTests.swift | 3 +- .../Database/SQLDDLFallbackPolicyTests.swift | 2 +- .../Models/DatabaseTypeTests.swift | 4 +- 48 files changed, 570 insertions(+), 73 deletions(-) create mode 100644 apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift create mode 100644 apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift create mode 100644 apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift create mode 100644 apple/TablePro/Assets.xcassets/starrocks-icon.imageset/Contents.json create mode 100644 apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg diff --git a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index bb002a60ef..a2bebfb5d7 100644 --- a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -14,6 +14,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let tidb = DatabaseType(rawValue: "TiDB") public static let databend = DatabaseType(rawValue: "Databend") public static let oceanbase = DatabaseType(rawValue: "OceanBase") + public static let starrocks = DatabaseType(rawValue: "StarRocks") public static let postgresql = DatabaseType(rawValue: "PostgreSQL") public static let sqlite = DatabaseType(rawValue: "SQLite") public static let redis = DatabaseType(rawValue: "Redis") @@ -47,7 +48,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let bigtable = DatabaseType(rawValue: "Bigtable") public static let allKnownTypes: [DatabaseType] = [ - .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redis, .mongodb, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .starrocks, .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, .firestore, .firebaseRealtimeDatabase, .bigtable @@ -61,6 +62,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .tidb: return "tidb-icon" case .databend: return "databend-icon" case .oceanbase: return "oceanbase-icon" + case .starrocks: return "starrocks-icon" case .postgresql: return "postgresql-icon" case .redshift: return "redshift-icon" case .sqlite: return "sqlite-icon" @@ -95,7 +97,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public var pluginTypeId: String { switch self { - case .mariadb, .tidb, .databend, .oceanbase: return DatabaseType.mysql.rawValue + case .mariadb, .tidb, .databend, .oceanbase, .starrocks: return DatabaseType.mysql.rawValue case .redshift: return DatabaseType.postgresql.rawValue default: return rawValue } diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index fd905b464c..ff4b9ce578 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -241,6 +241,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { "MariaDB": mySQLFamily, "TiDB": mySQLCompatible, "OceanBase": mySQLCompatible, + "StarRocks": mySQLCompatible, "Databend": SQLLexicalProfile( grammar: databend, undetermined: [.hashLineComments, .nestedBlockComments, .carriageReturnEndsLineComments] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift index 958fc6f6a8..cf7aeecfa0 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift @@ -64,6 +64,7 @@ public struct SQLEngineVocabulary: Sendable, Hashable { "TiDB": mySQL, "Databend": mySQL, "OceanBase": mySQL, + "StarRocks": mySQL, "PostgreSQL": postgreSQL, "CockroachDB": postgreSQL, "PGlite": postgreSQL, diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index ca8b6a75bd..e05dce9665 100644 --- a/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -11,6 +11,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.tidb.rawValue == "TiDB") #expect(DatabaseType.databend.rawValue == "Databend") #expect(DatabaseType.oceanbase.rawValue == "OceanBase") + #expect(DatabaseType.starrocks.rawValue == "StarRocks") #expect(DatabaseType.postgresql.rawValue == "PostgreSQL") #expect(DatabaseType.sqlite.rawValue == "SQLite") #expect(DatabaseType.redis.rawValue == "Redis") @@ -31,6 +32,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.tidb.pluginTypeId == "MySQL") #expect(DatabaseType.databend.pluginTypeId == "MySQL") #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") + #expect(DatabaseType.starrocks.pluginTypeId == "MySQL") #expect(DatabaseType.postgresql.pluginTypeId == "PostgreSQL") #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") #expect(DatabaseType.sqlite.pluginTypeId == "SQLite") @@ -61,11 +63,12 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 33) + #expect(DatabaseType.allKnownTypes.count == 34) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) + #expect(DatabaseType.allKnownTypes.contains(.starrocks)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.spanner)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index a4da4e263f..806d67bc06 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -7,7 +7,7 @@ struct SQLLexicalReadingsTests { @Test("Every SQL engine TablePro ships has a curated grammar") func curatedTableCoversShippedEngines() { let shipped = [ - "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", "KingbaseES", + "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "StarRocks", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", "KingbaseES", "SQLite", "libSQL", "Turso", "Cloudflare D1", "DuckDB", "Oracle", "Dameng", "SQL Server", "ClickHouse", "Snowflake", "BigQuery", "Spanner", "Trino", "Presto", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", "SurrealDB", "Redis", "MongoDB", "etcd", "Elasticsearch", "Typesense", "Weaviate", "Kafka", diff --git a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift index 8cf6396385..c1ac16e312 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift @@ -8,7 +8,7 @@ struct SQLEngineVocabularyTests { func everyEngineHasVocabulary() { let typeIds = SQLEngineVocabulary.curatedDatabaseTypeIds #expect(typeIds == [ - "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", + "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "StarRocks", "PostgreSQL", "CockroachDB", "PGlite", "KingbaseES", "Redshift", "SQLite", "SQL Server", "Oracle", "DuckDB", ]) @@ -30,6 +30,7 @@ struct SQLEngineVocabularyTests { ("TiDB", "MySQL"), ("Databend", "MySQL"), ("OceanBase", "MySQL"), + ("StarRocks", "MySQL"), ("CockroachDB", "PostgreSQL"), ("PGlite", "PostgreSQL"), ("KingbaseES", "PostgreSQL"), diff --git a/apple/Plugins/MySQLDriverPlugin/Info.plist b/apple/Plugins/MySQLDriverPlugin/Info.plist index e736a2c92e..7ea262fa2a 100644 --- a/apple/Plugins/MySQLDriverPlugin/Info.plist +++ b/apple/Plugins/MySQLDriverPlugin/Info.plist @@ -11,6 +11,7 @@ TiDB Databend OceanBase + StarRocks diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift b/apple/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift index c27ca50379..7b1da54227 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift @@ -27,7 +27,7 @@ internal extension MySQLServerVersion { return isKnownBelow((5, 7, 6), banner: banner) ? .grantUsage : .alterUser case .mariadb: return isKnownBelow((10, 2, 0), banner: banner) ? .grantUsage : .alterUser - case .tidb, .oceanbase, .databend: + case .tidb, .oceanbase, .databend, .starrocks: return .alterUser } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift b/apple/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift index 8860644d7c..0d9061812f 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift @@ -41,6 +41,8 @@ internal enum MySQLCheckConstraints { return .informationSchema case .databend: return .databendCatalog + case .starrocks: + return .unavailable } } @@ -98,7 +100,7 @@ internal enum MySQLCheckConstraints { return MySQLServerVersion.components(from: banner) != nil case .tidb(let version), .oceanbase(let version): return version != nil - case .databend: + case .databend, .starrocks: return true } } @@ -109,7 +111,7 @@ internal enum MySQLCheckConstraints { case .mariadb: return "MariaDB 10.2.1" case .tidb: return "TiDB 7.2" case .oceanbase: return "OceanBase 4.0" - case .databend: return nil + case .databend, .starrocks: return nil } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift b/apple/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift index ac86ab6b13..70fd58155c 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift @@ -24,7 +24,7 @@ internal enum MySQLFunctionalKeyParts { case .mysql: guard MySQLServerVersion.isKnownBelow(firstRelease, banner: banner) else { return nil } return String(localized: "Indexing an expression needs MySQL 8.0.13 or later.") - case .tidb, .oceanbase, .databend: + case .tidb, .oceanbase, .databend, .starrocks: return nil } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift b/apple/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift index 6e85876b6d..a1a45549a4 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift @@ -52,13 +52,13 @@ nonisolated internal struct MySQLKillLatch { } /// Only the two engines measured to hold an idle kill pay the absorbing round trip. TiDB drops - /// the session on `KILL QUERY` instead of flagging it, and Databend and OceanBase are unmeasured, - /// so neither gets a statement inserted into its session on a guess. + /// the session on `KILL QUERY` instead of flagging it, and Databend, OceanBase and StarRocks are + /// unmeasured, so none gets a statement inserted into its session on a guess. internal static func absorbsLatchedKill(flavor: MySQLServerFlavor) -> Bool { switch flavor { case .mysql, .mariadb: return true - case .tidb, .databend, .oceanbase: + case .tidb, .databend, .oceanbase, .starrocks: return false } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift b/apple/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift index c3bec97442..375e9d5370 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLKillTarget.swift @@ -40,7 +40,7 @@ internal extension MySQLServerFlavor { case .databend: guard let session = connectionIdentifier, !session.isEmpty else { return .threadId } return .databendSession(session) - case .mysql, .mariadb, .oceanbase: + case .mysql, .mariadb, .oceanbase, .starrocks: return .threadId } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift b/apple/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift index 262443824f..d3e0edc250 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLMaintenance.swift @@ -111,6 +111,8 @@ internal extension MySQLServerFlavor { case .oceanbase(let version): guard let version, version >= Self.oceanbaseBareAnalyzeFloor else { return [] } return [MySQLMaintenance.analyzeOperation] + case .starrocks: + return [] } } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index e38685d0d2..7f87287390 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -16,7 +16,7 @@ import TableProSQLVocabulary final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MySQL Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, and OceanBase support via libmariadb" + static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, OceanBase, and StarRocks support via libmariadb" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MySQL" @@ -34,7 +34,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ), MySQLConnectionEncoding.connectionField ] - static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase"] + static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase", "StarRocks"] // MARK: - UI/Capability Metadata @@ -110,7 +110,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static func driverVariant(for databaseTypeId: String) -> String? { switch databaseTypeId { - case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant: + case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant, + MySQLServerFlavor.starrocksVariant: return databaseTypeId default: return nil diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index 5fee769baa..eab46f9bbf 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift @@ -28,7 +28,7 @@ extension MySQLPluginDriver { /// `SHOW INDEX` returns text, so both are cast rather than read through a text accessor that /// would depend on how the driver rendered an integer cell. func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] { - guard !flavor.isDatabend else { return [:] } + guard flavor.hasMySQLObjectCatalogs else { return [:] } let database = routineSchema(schema) return try await catalogOrShow( database: database, @@ -80,6 +80,7 @@ extension MySQLPluginDriver { /// so a caller asking about another one is answered about the one it asked about. func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] { guard !flavor.isDatabend else { return try await databendAllTableMetadata(database: routineSchema(schema)) } + guard !flavor.isStarRocks else { return [:] } let result = try await execute(query: Self.tableStatusStatement(database: routineSchema(schema))) let appendsInnoDBStatus = serverAppendsInnoDBStatus var metadata: [String: PluginTableMetadata] = [:] diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreationScopes.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreationScopes.swift index b689bafda8..59ef877757 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreationScopes.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreationScopes.swift @@ -9,7 +9,7 @@ import TableProPluginKit internal extension MySQLPluginDriver { func fetchRoutineCreationScope(_ routine: PluginRoutineInfo) async throws -> PluginStatementScope? { - guard !flavor.isDatabend, serverHasInformationSchema else { return nil } + guard flavor.hasMySQLObjectCatalogs, serverHasInformationSchema else { return nil } let exactSchema = await schemaNamesAreCaseSensitive() let query = MySQLObjectQueries.routineCreationSettings( schema: routineSchema(routine.schema), @@ -22,7 +22,7 @@ internal extension MySQLPluginDriver { } func fetchTriggerCreationScope(_ trigger: PluginTriggerInfo) async throws -> PluginStatementScope? { - guard !flavor.isDatabend, holdsForServer(MySQLServerVersion.hasTriggerCatalog(banner:flavor:)) else { + guard flavor.hasMySQLObjectCatalogs, holdsForServer(MySQLServerVersion.hasTriggerCatalog(banner:flavor:)) else { return nil } let query = MySQLObjectQueries.triggerCreationSettings(schema: routineSchema(trigger.schema), name: trigger.name) @@ -31,7 +31,7 @@ internal extension MySQLPluginDriver { } func fetchEventCreationScope(_ event: PluginEventInfo) async throws -> PluginStatementScope? { - guard !flavor.isDatabend, holdsForServer(MySQLServerVersion.hasPartitionAndEventCatalogs(banner:flavor:)) else { + guard flavor.hasMySQLObjectCatalogs, holdsForServer(MySQLServerVersion.hasPartitionAndEventCatalogs(banner:flavor:)) else { return nil } let exactSchema = await schemaNamesAreCaseSensitive() diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift index e31c68c670..bc028245ae 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift @@ -13,6 +13,8 @@ import TableProPluginKit internal extension MySQLPluginDriver { func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { let isSystem = systemDatabaseNamesForConnectionType.contains(database) + // A StarRocks database here is a catalog, and sizing an external one means a scan of the lake behind it. + guard !flavor.isStarRocks else { return PluginDatabaseMetadata(name: database, isSystemDatabase: isSystem) } guard serverHasInformationSchema else { return try await tableStatusMetadata(of: database, isSystem: isSystem) } @@ -38,6 +40,11 @@ internal extension MySQLPluginDriver { func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] { let systemDatabases = systemDatabaseNamesForConnectionType + guard !flavor.isStarRocks else { + return try await fetchDatabases().map { + PluginDatabaseMetadata(name: $0, isSystemDatabase: systemDatabases.contains($0)) + } + } guard serverHasInformationSchema else { var metadata: [PluginDatabaseMetadata] = [] for database in try await fetchDatabases() { @@ -79,6 +86,7 @@ internal extension MySQLPluginDriver { /// 10.10+ reports `utf8mb4_uca1400_ai_ci`, which no `COLLATION_NAME` matches. func allTablesMetadataSQL(schema: String?) -> String? { guard !flavor.isDatabend else { return DatabendCatalog.allTablesMetadataSQL } + guard !flavor.isStarRocks else { return nil } guard serverHasInformationSchema else { return Self.tableStatusStatement(database: effectiveSchema(schema)) } return literalSpelling.respelled(""" SELECT diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index 90a890bc82..d77d87273b 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -13,6 +13,7 @@ internal struct MySQLFlavorMismatchError: Error, Equatable { case databendNeedsItsOwnType case notDatabend case notOceanBase + case notStarRocks } let kind: Kind @@ -27,6 +28,8 @@ extension MySQLFlavorMismatchError: PluginDriverError { 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.") + case .notStarRocks: + return String(localized: "This server did not identify as StarRocks. Check the host and the MySQL port of its frontend.") } } } @@ -38,6 +41,8 @@ extension MySQLPluginDriver { return .databend case MySQLServerFlavor.oceanbaseVariant: return .oceanbase(version: nil) + case MySQLServerFlavor.starrocksVariant: + return .starrocks default: return .mysql } @@ -68,6 +73,17 @@ extension MySQLPluginDriver { throw MySQLFlavorMismatchError(kind: .databendNeedsItsOwnType) } + if variant == MySQLServerFlavor.starrocksVariant { + guard try await probeSucceeds( + MySQLFlavorResolution.starrocksProbe, + on: connection, + deadline: deadline + ) else { + throw MySQLFlavorMismatchError(kind: .notStarRocks) + } + return .starrocks + } + if variant == MySQLServerFlavor.oceanbaseVariant { let identity = try await connection.executeConnectQuery( MySQLFlavorResolution.oceanbaseProbe, diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift index 949446ad5d..e7c93cb4e6 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift @@ -16,7 +16,7 @@ extension MySQLPluginDriver { var tableDDLIncludesForeignKeys: Bool { true } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - guard !flavor.isDatabend else { return [] } + guard flavor.hasMySQLObjectCatalogs else { return [] } let database = effectiveSchema(schema) let identity = serverIdentity let omittedAction = MySQLServerVersion.omittedForeignKeyAction( @@ -41,7 +41,7 @@ extension MySQLPluginDriver { } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { - guard !flavor.isDatabend else { return [:] } + guard flavor.hasMySQLObjectCatalogs else { return [:] } let database = effectiveSchema(schema) return try await catalogOrShow( database: database, diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift index 04ae05f4b7..3d9ecbcd49 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift @@ -10,7 +10,7 @@ import TableProPluginKit extension MySQLPluginDriver { /// The routines and their parameters are two reads; `MySQLRoutineSignatures` says why. func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { - guard !flavor.isDatabend, serverHasInformationSchema else { return [] } + guard flavor.hasMySQLObjectCatalogs, serverHasInformationSchema else { return [] } let resolvedSchema = routineSchema(schema) let exactSchema = await schemaNamesAreCaseSensitive() let query = MySQLObjectQueries.routineList(schema: resolvedSchema, exactSchema: exactSchema) @@ -96,7 +96,7 @@ extension MySQLPluginDriver { var providesBulkTriggerFetch: Bool { true } func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { - guard !flavor.isDatabend else { return [] } + guard flavor.hasMySQLObjectCatalogs else { return [] } return try await triggerList(schema: routineSchema(schema), table: nil) } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 408b7a5a54..add5c16541 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -55,10 +55,16 @@ internal extension MySQLPluginDriver { return columnsByTable[table] ?? (columnsByTable.count == 1 ? columnsByTable.values.first ?? [] : []) } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") - let catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) - let createTableDefaults = try await createTableDefaults( - table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails - ) + // StarRocks answers both lookups for `default_catalog` alone, so an external table would read + // another table's details or fail outright. + var catalogDetails: [String: MySQLCatalogColumnDetail] = [:] + var createTableDefaults: MySQLCreateTableDefaults? + if flavor.hasMySQLObjectCatalogs { + catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) + createTableDefaults = try await self.createTableDefaults( + table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails + ) + } let columns = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = row[safe: 0]?.asText, @@ -323,7 +329,8 @@ internal extension MySQLPluginDriver { } } - var providesBulkColumnFetch: Bool { true } + /// StarRocks reads one table at a time, which a caller weighing the bulk read should not mistake for one query. + var providesBulkColumnFetch: Bool { !flavor.isStarRocks } /// `GENERATION_EXPRESSION` is projected here rather than looked up per table, because a caller /// that takes the bulk list has to receive what `fetchColumns` would have given it. Without the @@ -332,6 +339,7 @@ internal extension MySQLPluginDriver { func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { guard !flavor.isDatabend else { return try await databendAllColumns(schema: schema) } let database = effectiveSchema(schema) + guard !flavor.isStarRocks else { return try await showColumnsByTable(database: database) } return try await catalogOrShow( database: database, catalogExists: serverHasInformationSchema, diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift index 04c6eca032..07cf3ca7e1 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+SessionScope.swift @@ -13,7 +13,7 @@ internal extension MySQLPluginDriver { /// `WHERE`. func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? { let names = MySQLSessionScopePlan.savedNames(for: purpose) - guard !flavor.isDatabend, + guard flavor.hasMySQLObjectCatalogs, holdsForServer(MySQLServerVersion.showAcceptsWhere(banner:flavor:)), !names.isEmpty else { return nil } @@ -44,7 +44,7 @@ internal extension MySQLPluginDriver { } func closeSessionScope(_ scope: PluginSessionScope) async throws { - guard !flavor.isDatabend else { return } + guard flavor.hasMySQLObjectCatalogs else { return } let heldSession = !scope.applied.isEmpty let isRestored: Bool do { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift new file mode 100644 index 0000000000..1fba8eb466 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift @@ -0,0 +1,63 @@ +// +// MySQLPluginDriver+StarRocks.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProMySQLCore +import TableProPluginKit + +extension MySQLPluginDriver { + /// No transactions, because a StarRocks transaction is bound to one table's load and the grid's + /// `BEGIN` would not group its edits. No MySQL catalogs either: an external catalog answers + /// `information_schema` for itself, so compare, routines and accounts are left off. + static let starrocksCapabilities: PluginCapabilities = [ + .cancelQuery, + ] + + /// Enters the catalog and database the session belongs in, on a connection that has just come up + /// in `default_catalog` with no database. + /// + /// A name without a dot is first tried as a catalog, because a pooled metadata connection carries + /// the browsed catalog in that field. A server that has no such catalog takes it as a database of + /// `default_catalog`, which is what a connection typed by hand means by it. + func enterStarRocksSessionScope(on conn: MariaDBPluginConnection, deadline: MySQLConnectDeadline) async throws { + let database = activeDatabaseName + if let catalog = activeCatalogName { + _ = try await conn.executeConnectQuery(StarRocksCatalog.setCatalog(catalog), deadline: deadline) + if !database.isEmpty { + _ = try await conn.executeConnectQuery(StarRocksCatalog.useDatabase(database), deadline: deadline) + } + adoptStarRocksScope(catalog: catalog, database: database) + return + } + guard !database.isEmpty else { + adoptStarRocksScope(catalog: StarRocksCatalog.defaultCatalog, database: "") + return + } + do { + _ = try await conn.executeConnectQuery(StarRocksCatalog.setCatalog(database), deadline: deadline) + adoptStarRocksScope(catalog: database, database: "") + } catch let error as MariaDBPluginError where error.isConnectionTimeout { + throw error + } catch { + _ = try await conn.executeConnectQuery(StarRocksCatalog.useDatabase(database), deadline: deadline) + adoptStarRocksScope(catalog: StarRocksCatalog.defaultCatalog, database: database) + } + } + + /// `SHOW FULL TABLES` tells views apart, but a connector behind an external catalog may refuse + /// it, and plain `SHOW TABLES` still lists what is there. + func starrocksTables(database: String) async throws -> [PluginTableInfo] { + let source = mysqlQuoteIdentifier(database) + let rows: [[PluginCellValue]] + do { + rows = try await execute(query: "SHOW FULL TABLES FROM \(source)").rows + } catch let error as MariaDBPluginError where error.isConnectionTimeout { + throw error + } catch { + rows = try await execute(query: "SHOW TABLES FROM \(source)").rows + } + return MySQLTableListing.tables(from: rows, listsSequencesAsTables: false) + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index d0312506b6..6c7ccfeed4 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -25,6 +25,14 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { sessionLock.withLock { _activeDatabase } } + /// The StarRocks catalog the session is in, which every reconnect enters again before its `USE`. + /// Nil only before the first connect has settled what the Database field named. + private var _activeCatalog: String? + + var activeCatalogName: String? { + sessionLock.withLock { _activeCatalog } + } + internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog? /// One verdict per database about whether `information_schema` describes it, learned from the @@ -99,7 +107,17 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") - var currentSchema: String? { nil } + /// StarRocks browses a catalog's databases as schemas. Every other flavor has no schema level. + var currentSchema: String? { + guard flavor.isStarRocks else { return nil } + return activeDatabaseName.nilIfEmpty + } + + var currentDatabase: String? { + guard flavor.isStarRocks else { return nil } + return activeCatalogName + } + var serverVersion: String? { sessionLock.withLock { _serverVersion } } /// The banner and the flavor together, taken under one lock. `connect()` writes both in the @@ -113,8 +131,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let identity = serverIdentity return MySQLServerVersion.quotesColumnDefault(banner: identity.banner, flavor: identity.flavor) } - var supportsSchemas: Bool { false } - var supportsTransactions: Bool { true } + var supportsSchemas: Bool { flavor.isStarRocks } + var supportsTransactions: Bool { !flavor.isStarRocks } var requiresBackslashEscapingInLiterals: Bool { true } /// `NO_BACKSLASH_ESCAPES` from the status flags of the last reply the connection read. Databend reports no such @@ -143,6 +161,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// Read live by the app, after `connect()` has written the banner it is gated on. var capabilities: PluginCapabilities { guard !flavor.isDatabend else { return Self.databendCapabilities } + guard !flavor.isStarRocks else { return Self.starrocksCapabilities } var capabilities: PluginCapabilities = [ .parameterizedQueries, .transactions, @@ -172,9 +191,30 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } init(config: DriverConnectionConfig) { + let flavor = Self.initialFlavor(for: config) self.config = config - self._activeDatabase = config.database - self._flavor = Self.initialFlavor(for: config) + self._flavor = flavor + guard flavor.isStarRocks else { + self._activeDatabase = config.database + return + } + switch StarRocksCatalog.target(fromConfiguredDatabase: config.database) { + case .qualified(let catalog, let database): + self._activeCatalog = catalog + self._activeDatabase = database + case .bareName(let name): + self._activeDatabase = name + case .serverDefault: + self._activeDatabase = "" + } + } + + /// Written once the session has entered the scope, so a reconnect enters the same one. + func adoptStarRocksScope(catalog: String, database: String) { + sessionLock.withLock { + _activeCatalog = catalog + _activeDatabase = database + } } /// The timer's task outlives the driver it was started for, so a driver dropped without @@ -199,11 +239,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { deadline: connectDeadline ) + // A StarRocks handshake database is read in `default_catalog`, so the scope is entered after connecting. + let enterStarRocksScope = Self.initialFlavor(for: config).isStarRocks let conn = MariaDBPluginConnection( transport: MySQLTransport(host: config.host, port: config.port, additionalFields: config.additionalFields), user: config.username, password: kerberos == nil ? password : nil, - database: activeDatabaseName, + database: enterStarRocksScope ? "" : activeDatabaseName, sslConfig: sslConfig, enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", tunnelSecuresTransport: config.additionalFields["tunnelSecuresTransport"] == "true", @@ -234,6 +276,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return true }) try await conn.completeConnect(deadline: connectDeadline) + if enterStarRocksScope { + try await enterStarRocksSessionScope(on: conn, deadline: connectDeadline) + } } catch { conn.disconnect() throw error @@ -737,6 +782,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } guard !database.isEmpty else { return [] } + guard !flavor.isStarRocks else { return try await starrocksTables(database: database) } let rows: [[PluginCellValue]] if serverHasInformationSchema { @@ -784,7 +830,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// shape, and `PARTITION_DESCRIPTION` on such a row repeats the parent's bound rather than /// describing the subpartition, so a subpartition reports no bound of its own. func fetchPartitionDetails(table: String, schema: String?) async throws -> [PluginPartitionInfo] { - guard !flavor.isDatabend, + guard flavor.hasMySQLObjectCatalogs, holdsForServer(MySQLServerVersion.hasPartitionAndEventCatalogs(banner:flavor:)) else { return [] } let query = MySQLObjectQueries.partitionList( @@ -832,7 +878,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - guard !flavor.isDatabend else { return [] } + guard flavor.hasMySQLObjectCatalogs else { return [] } let result = try await execute(query: "SHOW INDEX FROM \(qualifiedName(table, schema: schema))") let expressionColumn = result.columns.firstIndex(of: "Expression") @@ -855,7 +901,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The same builder the schema-wide list uses, with one more predicate. func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { // A temporary table cannot carry a trigger. - guard !flavor.isDatabend, await !resolvesToTemporaryTable(table, schema: schema) else { return [] } + guard flavor.hasMySQLObjectCatalogs, await !resolvesToTemporaryTable(table, schema: schema) else { return [] } let dbName = effectiveSchema(schema) let triggers = try await triggerList(schema: dbName, table: table) Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) parsed=\(triggers.count)") @@ -863,7 +909,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func createTriggerTemplate(table: String, schema: String?) -> String? { - guard !flavor.isDatabend else { return nil } + guard flavor.hasMySQLObjectCatalogs else { return nil } return """ CREATE TRIGGER \(qualifiedName("trigger_name", schema: schema)) BEFORE INSERT ON \(qualifiedName(table, schema: schema)) FOR EACH ROW @@ -878,7 +924,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { - guard await !resolvesToTemporaryTable(table, schema: schema) else { return nil } + guard !flavor.isStarRocks, await !resolvesToTemporaryTable(table, schema: schema) else { return nil } guard serverHasInformationSchema else { return try await tableStatusRow(table: table, schema: schema)?[safe: 4]?.asText.flatMap { Int($0) } } @@ -916,7 +962,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// Scheduled events. `information_schema.EVENTS` lists them for the current database, and /// `SHOW CREATE EVENT` is the only thing that produces a runnable definition. func fetchEvents(schema: String?) async throws -> [PluginEventInfo] { - guard !flavor.isDatabend, + guard flavor.hasMySQLObjectCatalogs, holdsForServer(MySQLServerVersion.hasPartitionAndEventCatalogs(banner:flavor:)) else { return [] } let exactSchema = await schemaNamesAreCaseSensitive() @@ -957,6 +1003,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { guard !flavor.isDatabend else { return try await databendTableMetadata(table: table, schema: schema) } + guard !flavor.isStarRocks else { return PluginTableMetadata(tableName: table) } guard await !resolvesToTemporaryTable(table, schema: schema), let row = try await tableStatusRow(table: table, schema: schema) else { return PluginTableMetadata(tableName: table) @@ -993,11 +1040,20 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Database Operations + /// StarRocks lists catalogs here, the level above its databases. func fetchDatabases() async throws -> [String] { + guard !flavor.isStarRocks else { + return StarRocksCatalog.names(from: try await execute(query: StarRocksCatalog.showCatalogs).rows) + } let result = try await execute(query: "SHOW DATABASES") return result.rows.compactMap { row in row[safe: 0]?.asText } } + func fetchSchemas() async throws -> [String] { + guard flavor.isStarRocks else { return [] } + return StarRocksCatalog.names(from: try await execute(query: StarRocksCatalog.showDatabases).rows) + } + /// Classified by the type the connection was saved as, the same list the app classifies the database list by, /// rather than by the flavor the banner resolves: the two used to disagree for a TiDB server saved as MySQL. var systemDatabaseNamesForConnectionType: [String] { @@ -1044,16 +1100,31 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The clock still moves, because a database switch is the user using the connection: leaving /// it alone let the idle timer fire seconds after a switch and charge the next click a full /// reconnect. + /// + /// On StarRocks the database level is the catalog. Entering one leaves no database selected, + /// so the schema list that follows is the new catalog's. func switchDatabase(to database: String) async throws { - _ = try await executeWithReconnect( - query: "USE \(quoteIdentifier(database))", - isRetry: false, - countsAsActivity: false - ) - sessionLock.withLock { - _activeDatabase = database - lastActivity = ContinuousClock.now + guard !flavor.isStarRocks else { + try await runSessionSetup(StarRocksCatalog.setCatalog(database)) + sessionLock.withLock { + _activeCatalog = database + _activeDatabase = "" + } + return } + try await runSessionSetup("USE \(quoteIdentifier(database))") + sessionLock.withLock { _activeDatabase = database } + } + + func switchSchema(to schema: String) async throws { + guard flavor.isStarRocks else { return } + try await runSessionSetup(StarRocksCatalog.useDatabase(schema)) + sessionLock.withLock { _activeDatabase = schema } + } + + private func runSessionSetup(_ statement: String) async throws { + _ = try await executeWithReconnect(query: statement, isRetry: false, countsAsActivity: false) + sessionLock.withLock { lastActivity = ContinuousClock.now } } // MARK: - Query Timeout @@ -1316,10 +1387,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Foreign Key Checks func foreignKeyDisableStatements() -> [String]? { - flavor.isDatabend ? nil : ["SET FOREIGN_KEY_CHECKS=0"] + flavor.hasMySQLObjectCatalogs ? ["SET FOREIGN_KEY_CHECKS=0"] : nil } func foreignKeyEnableStatements() -> [String]? { - flavor.isDatabend ? nil : ["SET FOREIGN_KEY_CHECKS=1"] + flavor.hasMySQLObjectCatalogs ? ["SET FOREIGN_KEY_CHECKS=1"] : nil } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index a6d3f2c23b..55dded4040 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -34,13 +34,18 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { case tidb(version: MySQLEngineVersion?) case databend case oceanbase(version: MySQLEngineVersion?) + case starrocks static let tidbVariant = "TiDB" static let databendVariant = "Databend" static let oceanbaseVariant = "OceanBase" + static let starrocksVariant = "StarRocks" static let oceanbaseUnlimitedQueryTimeoutMicroseconds: Int64 = 3_216_672_000_000_000 + /// StarRocks takes `query_timeout` in seconds from 1 to 259200 and has no spelling for unlimited. + static let starrocksMaximumQueryTimeoutSeconds = 259_200 + static func fromBanner(_ banner: String?) -> MySQLServerFlavor { guard let banner else { return .mysql } if let version = tidbVersion(fromBanner: banner) { @@ -95,6 +100,12 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { return true } + var isStarRocks: Bool { self == .starrocks } + + /// Databend and StarRocks speak the protocol without MySQL's object catalogs, so there are no + /// indexes, foreign keys, triggers, routines, events or partitions to read or write the MySQL way. + var hasMySQLObjectCatalogs: Bool { !isDatabend && !isStarRocks } + var tidbVersion: MySQLEngineVersion? { guard case .tidb(let version) = self else { return nil } return version @@ -110,6 +121,9 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { return ["information_schema", "system"] case .oceanbase: return ["information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR"] + case .starrocks: + // The database level of a StarRocks connection is its catalogs, and none of them is a system one. + return [] } } @@ -118,13 +132,13 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { /// /// Measured with the app's own libmariadb 3.4.4 against MySQL 5.5.62 and 8.4.11, MariaDB 5.5.64 /// and 11.4.13 and TiDB v8.5.1: all five report the flag, including the transaction that - /// `SET autocommit = 0` plus a write opens. Databend and OceanBase are unmeasured, so they - /// report nothing rather than reporting "no transaction" from a flag that may never be set. + /// `SET autocommit = 0` plus a write opens. Databend, OceanBase and StarRocks are unmeasured, so + /// they report nothing rather than reporting "no transaction" from a flag that may never be set. var reportsSessionStatusFlags: Bool { switch self { case .mysql, .mariadb, .tidb: return true - case .databend, .oceanbase: + case .databend, .oceanbase, .starrocks: return false } } @@ -133,10 +147,11 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var dropsIdleSessionOnKillQuery: Bool { isTiDB } - var preparesOnServer: Bool { !isDatabend } + /// StarRocks took `COM_STMT_PREPARE` only in 3.2, and a 3.1 frontend refuses it. + var preparesOnServer: Bool { !isDatabend && !isStarRocks } func beginTransactionStatement(mode: PluginTransactionAccessMode) -> String { - guard !isDatabend else { return "BEGIN" } + guard !isDatabend, !isStarRocks else { return "BEGIN" } guard mode == .readWrite else { return "START TRANSACTION" } return "START TRANSACTION \(readWriteAccessModeClause)" } @@ -145,7 +160,7 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { switch self { case .mysql, .mariadb: return "/*!50605 READ WRITE */" - case .tidb, .oceanbase, .databend: + case .tidb, .oceanbase, .databend, .starrocks: return "READ WRITE" } } @@ -162,6 +177,9 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { ? PluginQueryTimeout.microseconds(seconds) : Self.oceanbaseUnlimitedQueryTimeoutMicroseconds return ["SET SESSION ob_query_timeout = \(microseconds)", "SET SESSION max_execution_time = 0"] + case .starrocks: + let maximum = Self.starrocksMaximumQueryTimeoutSeconds + return ["SET query_timeout = \(seconds > 0 ? min(seconds, maximum) : maximum)"] case .mysql, .tidb: return ["SET SESSION max_execution_time = \(PluginQueryTimeout.milliseconds(seconds))"] } @@ -196,6 +214,10 @@ nonisolated internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } + /// The StarRocks handshake carries `mysql_server_version`, which defaults to a plain MySQL + /// version, so only a function MySQL lacks tells the two apart. + static let starrocksProbe = "SELECT current_version()" + static func oceanbaseFlavor(versionComment: String?, serverVersion: String?) -> MySQLServerFlavor? { let version = versionComment.flatMap(MySQLServerFlavor.oceanbaseVersion(fromVersionComment:)) ?? serverVersion.flatMap(MySQLServerFlavor.oceanbaseVersion(fromServerVersion:)) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/apple/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index 73e0e4235e..373f0dafff 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -46,7 +46,7 @@ nonisolated internal enum MySQLServerVersion { return isAtLeast((5, 7, 8), banner: banner) case .mariadb: return isAtLeast((10, 1, 1), banner: banner) - case .tidb, .oceanbase, .databend: + case .tidb, .oceanbase, .databend, .starrocks: return true } } @@ -61,7 +61,7 @@ nonisolated internal enum MySQLServerVersion { return isAtLeast((10, 2, 0), banner: banner) case .tidb, .oceanbase: return true - case .databend: + case .databend, .starrocks: return false } } @@ -190,7 +190,7 @@ nonisolated internal enum MySQLServerVersion { switch flavor { case .mysql, .mariadb: return isKnownBelow(target, banner: banner) - case .tidb, .oceanbase, .databend: + case .tidb, .oceanbase, .databend, .starrocks: return false } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLSystemDatabases.swift b/apple/Plugins/MySQLDriverPlugin/MySQLSystemDatabases.swift index 2cf56de273..a10570f0ec 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLSystemDatabases.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLSystemDatabases.swift @@ -29,6 +29,8 @@ nonisolated internal enum MySQLSystemDatabases { return MySQLServerFlavor.databend.systemDatabaseNames case MySQLServerFlavor.oceanbaseVariant: return MySQLServerFlavor.oceanbase(version: nil).systemDatabaseNames + case MySQLServerFlavor.starrocksVariant: + return MySQLServerFlavor.starrocks.systemDatabaseNames default: return mysql } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift b/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift index 4835cc9544..30b1c5a46a 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLTableOptions.swift @@ -60,7 +60,7 @@ internal enum MySQLTableOptions { } static func readsCatalog(flavor: MySQLServerFlavor) -> Bool { - !flavor.isDatabend + flavor.hasMySQLObjectCatalogs } /// A default change leaves existing columns on their own character set; a convert rewrites every text diff --git a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift new file mode 100644 index 0000000000..aa3d81a00f --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift @@ -0,0 +1,57 @@ +// +// StarRocksCatalog.swift +// MySQLDriverPlugin +// +// StarRocks puts catalogs above databases: `default_catalog` holds its own tables, and an external +// catalog fronts Iceberg, Hive, Delta Lake or another engine. The connection browses catalogs where +// MySQL browses databases, and a catalog's databases where a schema-switching engine browses schemas. +// + +import Foundation +import TableProPluginKit + +nonisolated internal enum StarRocksCatalog { + static let defaultCatalog = "default_catalog" + + static let systemDatabaseNames = ["_statistics_", "information_schema", "sys"] + + static let showCatalogs = "SHOW CATALOGS" + + static let showDatabases = "SHOW DATABASES" + + static func setCatalog(_ catalog: String) -> String { + "SET CATALOG \(mysqlQuoteIdentifier(catalog))" + } + + static func useDatabase(_ database: String) -> String { + "USE \(mysqlQuoteIdentifier(database))" + } + + /// What the Database field of a connection names. + /// + /// `catalog.database` is the form StarRocks itself takes in its handshake and in `USE`, and the one + /// a connection imported from another client carries. Sent whole as one quoted name, the server + /// reads it as a database called `catalog.database` and an Iceberg REST catalog refuses it as a + /// malformed namespace. A name without a dot is ambiguous: a pooled metadata connection fills the + /// field with the browsed catalog, while a connection typed by hand means a database in + /// `default_catalog`, so it is settled against the server when the session opens. + enum ConfiguredTarget: Equatable, Sendable { + case serverDefault + case qualified(catalog: String, database: String) + case bareName(String) + } + + static func target(fromConfiguredDatabase text: String) -> ConfiguredTarget { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .serverDefault } + guard let dot = trimmed.firstIndex(of: ".") else { return .bareName(trimmed) } + let catalog = String(trimmed[.. [String] { + rows.compactMap { $0[safe: 0]?.asText?.nilIfEmpty } + } +} diff --git a/apple/Plugins/MySQLDriverTests/MySQLVariantSystemDatabasesTests.swift b/apple/Plugins/MySQLDriverTests/MySQLVariantSystemDatabasesTests.swift index 7ea5f9d19a..786819c1cb 100644 --- a/apple/Plugins/MySQLDriverTests/MySQLVariantSystemDatabasesTests.swift +++ b/apple/Plugins/MySQLDriverTests/MySQLVariantSystemDatabasesTests.swift @@ -24,5 +24,6 @@ struct MySQLVariantSystemDatabasesTests { #expect(MySQLSystemDatabases.names(forVariant: MySQLServerFlavor.oceanbaseVariant) == [ "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR" ]) + #expect(MySQLSystemDatabases.names(forVariant: MySQLServerFlavor.starrocksVariant) == []) } } diff --git a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift new file mode 100644 index 0000000000..a05ce6b905 --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -0,0 +1,116 @@ +// +// StarRocksCatalogTests.swift +// MySQLDriverTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct StarRocksCatalogTests { + @Test("The Database field reads as a catalog and a database, a bare name, or nothing", arguments: [ + ("glue_iceberg_rest.lakehouse_events", StarRocksCatalog.ConfiguredTarget.qualified( + catalog: "glue_iceberg_rest", database: "lakehouse_events" + )), + ("default_catalog.sales", .qualified(catalog: "default_catalog", database: "sales")), + ("hive.", .qualified(catalog: "hive", database: "")), + ("sales", .bareName("sales")), + (".sales", .bareName("sales")), + (" sales ", .bareName("sales")), + ("", .serverDefault), + (" ", .serverDefault), + ]) + func configuredTarget(text: String, expected: StarRocksCatalog.ConfiguredTarget) { + #expect(StarRocksCatalog.target(fromConfiguredDatabase: text) == expected) + } + + @Test("Only the first dot splits, so a database name keeps any dot after it") + func firstDotSplits() { + #expect(StarRocksCatalog.target(fromConfiguredDatabase: "iceberg.ns.events") + == .qualified(catalog: "iceberg", database: "ns.events")) + } + + @Test("Catalog and database switches quote the name") + func statementsQuote() { + #expect(StarRocksCatalog.setCatalog("glue_iceberg_rest") == "SET CATALOG `glue_iceberg_rest`") + #expect(StarRocksCatalog.useDatabase("lake`events") == "USE `lake``events`") + } + + @Test("Names come from the first column and skip empty cells") + func namesFromRows() { + let rows: [[PluginCellValue]] = [ + [.text("default_catalog"), .text("Internal")], + [.text("glue_iceberg_rest"), .text("Iceberg")], + [.text("")], + [], + ] + #expect(StarRocksCatalog.names(from: rows) == ["default_catalog", "glue_iceberg_rest"]) + } + + @Test("StarRocks has no MySQL object catalogs and its databases are not system databases") + func flavorShape() { + let flavor = MySQLServerFlavor.starrocks + #expect(flavor.isStarRocks) + #expect(!flavor.hasMySQLObjectCatalogs) + #expect(MySQLServerFlavor.databend.hasMySQLObjectCatalogs == false) + #expect(MySQLServerFlavor.mysql.hasMySQLObjectCatalogs) + #expect(flavor.systemDatabaseNames.isEmpty) + #expect(!flavor.reportsSessionStatusFlags) + #expect(!flavor.preparesOnServer) + #expect(flavor.maintenanceOperations.isEmpty) + } + + @Test("StarRocks begins with BEGIN whatever the access mode") + func begins() { + #expect(MySQLServerFlavor.starrocks.beginTransactionStatement(mode: .readWrite) == "BEGIN") + #expect(MySQLServerFlavor.starrocks.beginTransactionStatement(mode: .serverDefault) == "BEGIN") + } + + @Test("The query timeout is capped at StarRocks's maximum, which also stands in for no limit", arguments: [ + (30, "SET query_timeout = 30"), + (300_000, "SET query_timeout = 259200"), + (0, "SET query_timeout = 259200"), + ]) + func queryTimeout(seconds: Int, expected: String) { + #expect(MySQLServerFlavor.starrocks.queryTimeoutStatements(seconds: seconds) == [expected]) + } + + @Test("The StarRocks connection type starts as StarRocks before the probe") + func initialFlavor() { + let config = DriverConnectionConfig( + host: "localhost", port: 9_030, username: "root", password: "", database: "", + additionalFields: ["driverVariant": MySQLServerFlavor.starrocksVariant] + ) + #expect(MySQLPluginDriver.initialFlavor(for: config) == .starrocks) + } + + @Test("A qualified Database field is held as the catalog and the database to enter") + func driverHoldsQualifiedScope() { + let driver = MySQLPluginDriver(config: Self.config(database: "glue_iceberg_rest.lakehouse_events")) + #expect(driver.activeCatalogName == "glue_iceberg_rest") + #expect(driver.activeDatabaseName == "lakehouse_events") + } + + @Test("A bare name waits for the server to settle whether it is a catalog") + func driverHoldsBareName() { + let driver = MySQLPluginDriver(config: Self.config(database: "sales")) + #expect(driver.activeCatalogName == nil) + #expect(driver.activeDatabaseName == "sales") + } + + @Test("A MySQL connection keeps a dotted name whole") + func mysqlKeepsDottedName() { + let driver = MySQLPluginDriver(config: DriverConnectionConfig( + host: "localhost", port: 3_306, username: "root", password: "", database: "a.b" + )) + #expect(driver.activeCatalogName == nil) + #expect(driver.activeDatabaseName == "a.b") + } + + private static func config(database: String) -> DriverConnectionConfig { + DriverConnectionConfig( + host: "localhost", port: 9_030, username: "root", password: "", database: database, + additionalFields: ["driverVariant": MySQLServerFlavor.starrocksVariant] + ) + } +} diff --git a/apple/Plugins/TableProPluginKit/SqlDialect.swift b/apple/Plugins/TableProPluginKit/SqlDialect.swift index bf55ad82ca..34dd718df8 100644 --- a/apple/Plugins/TableProPluginKit/SqlDialect.swift +++ b/apple/Plugins/TableProPluginKit/SqlDialect.swift @@ -11,7 +11,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { switch databaseTypeId { case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES": return .postgres - case "MySQL", "MariaDB", "TiDB", "OceanBase": + case "MySQL", "MariaDB", "TiDB", "OceanBase", "StarRocks": return .mysql case "SQLite", "libSQL", "Turso", "DuckDB", "Cloudflare D1": return .sqlite diff --git a/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/Contents.json b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/Contents.json new file mode 100644 index 0000000000..1be0eff4ac --- /dev/null +++ b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "starrocks.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg new file mode 100644 index 0000000000..b5baf9acef --- /dev/null +++ b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg @@ -0,0 +1 @@ + diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index 0217e55e14..7532d23848 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -99,7 +99,7 @@ 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, .oceanbase, .sqlite, .clickhouse, .libsql, .turso, + case .mysql, .mariadb, .tidb, .databend, .oceanbase, .starrocks, .sqlite, .clickhouse, .libsql, .turso, .cloudflareD1: return .bitString case .duckdb: diff --git a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift index 408762ef7f..fdf9f91e46 100644 --- a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -68,6 +68,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "MariaDB": .mysql, "TiDB": .mysql, "OceanBase": .mysql, + "StarRocks": .mysql, "PostgreSQL": .postgres, "Redshift": .postgres, "CockroachDB": .postgres, diff --git a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift index 7c584aff47..c4772a5f64 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/apple/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, .oceanbase, + .mysql, .mariadb, .tidb, .oceanbase, .starrocks, .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index 9f88679c97..aac4791113 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -20,7 +20,7 @@ enum ImportTypeMapper { for: databaseType, serverVersion: serverVersion ) ) - case .mysql, .mariadb, .tidb, .oceanbase: + case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: return mysqlType(type) case .sqlite: return sqliteType(type) diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 6201c6ac76..11b925527e 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -63,9 +63,96 @@ extension PluginMetadataRegistry { tidbVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), databendVariant(dialect: dialect, idleReleaseField: idleReleaseField), oceanbaseVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), + starrocksVariant(dialect: dialect, idleReleaseField: idleReleaseField), ] } + static let starrocksColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT", "LARGEINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL"], + "String": ["CHAR", "VARCHAR", "STRING"], + "Date": ["DATE", "DATETIME"], + "Binary": ["BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN"], + "Semi-structured": ["JSON", "ARRAY", "MAP", "STRUCT"], + "Aggregate": ["BITMAP", "HLL", "PERCENTILE"], + ] + + static let starrocksRowMatchExcludedTypePrefixes = [ + "ARRAY", "MAP", "STRUCT", "JSON", "BITMAP", "HLL", "PERCENTILE", "BINARY", "VARBINARY", + ] + + /// Catalogs fill the database level and a catalog's databases the schema level, the shape Trino + /// has. The Database field takes `catalog.database`, as StarRocks's own clients write it. + private static func starrocksVariant( + dialect: SQLDialectDescriptor, + idleReleaseField: ConnectionField + ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { + ("StarRocks", PluginMetadataSnapshot( + displayName: "StarRocks", iconName: "starrocks-icon", defaultPort: 9_030, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: mysqlVariantExplainVariants, pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], + postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], + brandColorHex: "#4E5BF2", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + structureEditing: SchemaEditingSupport(columnReorder: .unsupported, foreignKeyEdit: .unsupported), + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameTable: false, + supportsRenameView: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsTriggers: false, + supportsTriggerEditing: false, + supportsCheckConstraints: false, + supportsCheckConstraintEditing: false, + supportsGeneratedColumns: false, + supportsRoutines: false, + supportsDatabaseTriggerBrowse: false, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Tables", + containerEntityName: "Catalog", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: ["_statistics_", "information_schema", "sys"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment], + rowMatchExcludedTypePrefixes: starrocksRowMatchExcludedTypePrefixes + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: starrocksColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [idleReleaseField], + category: .analytical, + tagline: String(localized: "Lakehouse analytics, MySQL protocol") + ) + )) + } + private static func tidbVariant( dialect: SQLDialectDescriptor, mysqlColumnTypes: [String: [String]], diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift index bb72c1db7e..3622be46ad 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -506,6 +506,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["TiDB"] = "MySQL" reverseTypeIndex["Databend"] = "MySQL" reverseTypeIndex["OceanBase"] = "MySQL" + reverseTypeIndex["StarRocks"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" reverseTypeIndex["PGlite"] = "PostgreSQL" diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index d46a043edc..1c7b50ede7 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -62,7 +62,7 @@ enum CellExpressionVocabulary { case .postgresql, .pglite, .kingbasees: postgreSQL case .cockroachdb: cockroachDB case .redshift: redshift - case .mysql, .mariadb, .tidb, .oceanbase: mySQL + case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: mySQL case .databend: databend case .sqlite, .libsql, .turso, .cloudflareD1: sqlite case .duckdb: duckDB diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index bce99781a3..42a6750fa1 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -19,6 +19,7 @@ extension DatabaseType { static let tidb = DatabaseType(rawValue: "TiDB") static let databend = DatabaseType(rawValue: "Databend") static let oceanbase = DatabaseType(rawValue: "OceanBase") + static let starrocks = DatabaseType(rawValue: "StarRocks") static let postgresql = DatabaseType(rawValue: "PostgreSQL") static let sqlite = DatabaseType(rawValue: "SQLite") static let redshift = DatabaseType(rawValue: "Redshift") diff --git a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift index ace0be0dad..cc745f2a2d 100644 --- a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -44,7 +44,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: false, allowsOmittedReferencedColumns: true ) - case .mysql, .mariadb, .tidb, .oceanbase: + case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: return ForeignKeyDialect( deleteActions: [.noAction, .restrict, .cascade, .setNull], updateActions: [.noAction, .restrict, .cascade, .setNull], diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 7712f107eb..38f28079bb 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -99220,6 +99220,9 @@ } } } + }, + "Lakehouse analytics, MySQL protocol" : { + }, "Language %@" : { "localizations" : { @@ -198622,6 +198625,10 @@ } } }, + "This server did not identify as StarRocks. Check the host and the MySQL port of its frontend." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This server has no credential. Add its token in Settings > Integrations." : { "extractionState" : "stale", "localizations" : { diff --git a/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift b/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift index 4ccdd7da4d..fcb3f71317 100644 --- a/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift +++ b/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift @@ -10,7 +10,7 @@ import Testing @MainActor struct MySQLProtocolVariantParityTests { - nonisolated private static let variants: [DatabaseType] = [.mariadb, .tidb, .databend, .oceanbase] + nonisolated private static let variants: [DatabaseType] = [.mariadb, .tidb, .databend, .oceanbase, .starrocks] nonisolated private static let outsideMySQLDialect: Set = [.databend] @Test("The pinned variants are exactly the types the registry sends to the MySQL plugin") @@ -68,6 +68,7 @@ struct MySQLProtocolVariantParityTests { "information_schema", "performance_schema" ], .databend: ["information_schema", "system"], + .starrocks: [], .oceanbase: [ "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR" ] diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index e5d6ddaacd..a565d65e1f 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -10,7 +10,7 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "KingbaseES", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", - "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" + "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "StarRocks", "Firestore", "Firebase Realtime Database", "Bigtable" ] private static func builtInTypeIds() -> Set { @@ -34,6 +34,7 @@ struct PluginMetadataRegistryTypeCountTests { "TiDB": "MySQL", "Databend": "MySQL", "OceanBase": "MySQL", + "StarRocks": "MySQL", "Redshift": "PostgreSQL", "CockroachDB": "PostgreSQL", "PGlite": "PostgreSQL", diff --git a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift index 0496512e5d..77063fdeb0 100644 --- a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift +++ b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -58,7 +58,7 @@ struct SQLDDLFallbackPolicyTests { } private static let fabricatingTypes: Set = [ - .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .kingbasees, .sqlite, .redshift, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .starrocks, .postgresql, .kingbasees, .sqlite, .redshift, .cockroachdb, .pglite, .mssql, .oracle, .snowflake, .dameng, .clickhouse, .duckdb, .cassandra, .scylladb, .cloudflareD1, .cloudflareR2SQL, .bigQuery, .spanner, .libsql, .turso, .teradata, .trino, .presto, .sapHana, diff --git a/apple/TableProTests/Models/DatabaseTypeTests.swift b/apple/TableProTests/Models/DatabaseTypeTests.swift index c560e82974..33b8797bea 100644 --- a/apple/TableProTests/Models/DatabaseTypeTests.swift +++ b/apple/TableProTests/Models/DatabaseTypeTests.swift @@ -40,7 +40,8 @@ struct DatabaseTypeTests { DatabaseType.mariadb, DatabaseType.tidb, DatabaseType.databend, - DatabaseType.oceanbase + DatabaseType.oceanbase, + DatabaseType.starrocks ]) func testMariaDBClientEnginesDefaultSSLPreferred(type: DatabaseType) { #expect(type.defaultSSLMode == .preferred) @@ -79,6 +80,7 @@ struct DatabaseTypeTests { DatabaseType.tidb, DatabaseType.databend, DatabaseType.oceanbase, + DatabaseType.starrocks, DatabaseType.mssql ]) func testOpportunisticTLSSupported(type: DatabaseType) { From 7abdffbba1fb9e4916332f5a8b384bec8149014c Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Fri, 30 Oct 2026 22:24:58 -0700 Subject: [PATCH 2/7] fix(mysql): StarRocks logo, and VARIANT/JSON results read as text StarRocks labels JSON/VARIANT results charset 63; grid showed hex. Binary-labelled strings now decode as UTF-8 text, else bytes. Icon: official StarRocks logo (MIT, log-z/logos), template rendering, brand color #01808F. Co-authored-by: Cursor --- .../MySQLDriverPlugin/MariaDBPluginConnection.swift | 5 +++-- .../MySQLDriverPlugin/MySQLServerFlavor.swift | 4 ++++ .../MySQLDriverTests/StarRocksCatalogTests.swift | 13 +++++++++++++ .../starrocks-icon.imageset/starrocks.svg | 2 +- ...luginMetadataRegistry+MySQLVariantDefaults.swift | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 915015f95a..a07a9cf656 100644 --- a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -932,8 +932,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { } private func binaryStringsAreText(in query: String, flavor: MySQLServerFlavor) -> Bool { - MySQLServerVersion.labelsShowTextAsBinary(banner: serverVersion(), flavor: flavor) - && mysqlStatementListsServerMetadata(query) + flavor.labelsTextResultsAsBinary + || (MySQLServerVersion.labelsShowTextAsBinary(banner: serverVersion(), flavor: flavor) + && mysqlStatementListsServerMetadata(query)) } private func runTextStatement( diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index 55dded4040..f483968392 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -147,6 +147,10 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var dropsIdleSessionOnKillQuery: Bool { isTiDB } + /// StarRocks labels a JSON or an Iceberg VARIANT result charset 63 although it sends UTF-8 text, + /// so the grid showed `{"` as `0x7B22`. A VARBINARY value that is not UTF-8 still reads as bytes. + var labelsTextResultsAsBinary: Bool { isStarRocks } + /// StarRocks took `COM_STMT_PREPARE` only in 3.2, and a 3.1 frontend refuses it. var preparesOnServer: Bool { !isDatabend && !isStarRocks } diff --git a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift index a05ce6b905..2ef31d88ab 100644 --- a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -60,6 +60,19 @@ struct StarRocksCatalogTests { #expect(flavor.maintenanceOperations.isEmpty) } + /// Iceberg VARIANT and JSON columns arrive as charset 63 with `BINARY_FLAG`, and read as hex without this. + @Test("StarRocks binary-labelled strings decode as text when they are UTF-8") + func binaryLabelledStringsAreText() { + #expect(MySQLServerFlavor.starrocks.labelsTextResultsAsBinary) + #expect(!MySQLServerFlavor.mysql.labelsTextResultsAsBinary) + let longBlob: UInt32 = 251 + let decoding = MySQLColumnDecoding( + typeRaw: longBlob, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, + binaryStringsAreText: MySQLServerFlavor.starrocks.labelsTextResultsAsBinary + ) + #expect(decoding == .utf8TextOrBytes) + } + @Test("StarRocks begins with BEGIN whatever the access mode") func begins() { #expect(MySQLServerFlavor.starrocks.beginTransactionStatement(mode: .readWrite) == "BEGIN") diff --git a/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg index b5baf9acef..1de2232110 100644 --- a/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg +++ b/apple/TablePro/Assets.xcassets/starrocks-icon.imageset/starrocks.svg @@ -1 +1 @@ - + diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 11b925527e..5e5f4245b8 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -95,7 +95,7 @@ extension PluginMetadataRegistry { navigationModel: .standard, explainVariants: mysqlVariantExplainVariants, pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], - brandColorHex: "#4E5BF2", + brandColorHex: "#01808F", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, structureEditing: SchemaEditingSupport(columnReorder: .unsupported, foreignKeyEdit: .unsupported), From e54f53fe0cf0668fcbb5bd823c0895c569e85eb8 Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Fri, 30 Oct 2026 22:27:06 -0700 Subject: [PATCH 3/7] fix(mysql): no Create Database form for StarRocks catalogs; drop unused list, tighten comments Co-authored-by: Cursor --- .../MySQLPluginDriver+CreateDatabase.swift | 3 +++ .../MySQLDriverPlugin/MySQLPluginDriver+Schema.swift | 4 ++-- .../MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift | 6 +++--- apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift | 2 -- apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift index 37d1147aee..fa63f49794 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift @@ -6,6 +6,9 @@ import TableProPluginKit extension MySQLPluginDriver { func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { guard !flavor.isDatabend else { return PluginCreateDatabaseFormSpec(fields: [], footnote: nil) } + // The StarRocks database level is its catalogs, and `CREATE EXTERNAL CATALOG` takes connector properties + // no charset form can describe. + guard !flavor.isStarRocks else { return nil } let charsetDefaults = try await fetchCharsetDefaults() let collations = try await fetchCollationCatalog() let serverDefaults = await fetchServerCharsetDefaults() diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index add5c16541..1e1fb9ff28 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -55,8 +55,8 @@ internal extension MySQLPluginDriver { return columnsByTable[table] ?? (columnsByTable.count == 1 ? columnsByTable.values.first ?? [] : []) } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") - // StarRocks answers both lookups for `default_catalog` alone, so an external table would read - // another table's details or fail outright. + // Both lookups read MySQL's `information_schema` and `SHOW CREATE TABLE` shapes, which an external + // StarRocks catalog is not held to, and a failed lookup would fail the whole column read. var catalogDetails: [String: MySQLCatalogColumnDetail] = [:] var createTableDefaults: MySQLCreateTableDefaults? if flavor.hasMySQLObjectCatalogs { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift index 1fba8eb466..8ed0b0f7fb 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift @@ -8,9 +8,9 @@ import TableProMySQLCore import TableProPluginKit extension MySQLPluginDriver { - /// No transactions, because a StarRocks transaction is bound to one table's load and the grid's - /// `BEGIN` would not group its edits. No MySQL catalogs either: an external catalog answers - /// `information_schema` for itself, so compare, routines and accounts are left off. + /// No transactions: before 3.5 StarRocks has none across statements, and an external catalog has + /// none at all. Compare, routines and accounts read MySQL catalogs StarRocks does not keep, and + /// prepared statements are left off because a frontend before 3.2 refuses `COM_STMT_PREPARE`. static let starrocksCapabilities: PluginCapabilities = [ .cancelQuery, ] diff --git a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift index aa3d81a00f..16e2aa3af4 100644 --- a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift +++ b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift @@ -13,8 +13,6 @@ import TableProPluginKit nonisolated internal enum StarRocksCatalog { static let defaultCatalog = "default_catalog" - static let systemDatabaseNames = ["_statistics_", "information_schema", "sys"] - static let showCatalogs = "SHOW CATALOGS" static let showDatabases = "SHOW DATABASES" diff --git a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift index 2ef31d88ab..98f57fdd0d 100644 --- a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -65,9 +65,9 @@ struct StarRocksCatalogTests { func binaryLabelledStringsAreText() { #expect(MySQLServerFlavor.starrocks.labelsTextResultsAsBinary) #expect(!MySQLServerFlavor.mysql.labelsTextResultsAsBinary) - let longBlob: UInt32 = 251 + let varString: UInt32 = 253 let decoding = MySQLColumnDecoding( - typeRaw: longBlob, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, + typeRaw: varString, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, binaryStringsAreText: MySQLServerFlavor.starrocks.labelsTextResultsAsBinary ) #expect(decoding == .utf8TextOrBytes) From ea5d44ccfec3fc948a8bc28954415c5cbc0e1ac8 Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Sat, 31 Oct 2026 07:47:25 -0700 Subject: [PATCH 4/7] docs(changelog): StarRocks Co-authored-by: Cursor --- apple/changelog.d/3939.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3939.added.md diff --git a/apple/changelog.d/3939.added.md b/apple/changelog.d/3939.added.md new file mode 100644 index 0000000000..a408b4112a --- /dev/null +++ b/apple/changelog.d/3939.added.md @@ -0,0 +1 @@ +Connect to StarRocks and browse its external catalogs, such as Iceberg, alongside default_catalog. From 371abe6b8f786f27ede926036a6a4db5252fec5f Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Sat, 31 Oct 2026 09:50:12 -0700 Subject: [PATCH 5/7] feat(mysql): StarRocks row counts, sizes, keys and materialized views Columns come from information_schema.columns, which names each key model and leaves out the _file and _pos an Iceberg table's SHOW FULL COLUMNS adds. EXPLAIN offers LOGICAL, VERBOSE and COSTS, and drops ANALYZE, whose report is colored with terminal escape codes. Co-authored-by: Cursor --- .../MySQLPluginDriver+BulkMetadata.swift | 13 ++- .../MySQLPluginDriver+Schema.swift | 10 ++- .../MySQLPluginDriver+StarRocks.swift | 31 ++++++- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 20 ++++- .../MySQLDriverPlugin/StarRocksCatalog.swift | 76 +++++++++++++++++ .../StarRocksCatalogTests.swift | 84 +++++++++++++++++++ ...etadataRegistry+MySQLVariantDefaults.swift | 11 ++- 7 files changed, 233 insertions(+), 12 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index eab46f9bbf..efcf1d6274 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift @@ -80,8 +80,17 @@ extension MySQLPluginDriver { /// so a caller asking about another one is answered about the one it asked about. func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] { guard !flavor.isDatabend else { return try await databendAllTableMetadata(database: routineSchema(schema)) } - guard !flavor.isStarRocks else { return [:] } - let result = try await execute(query: Self.tableStatusStatement(database: routineSchema(schema))) + let statement = Self.tableStatusStatement(database: routineSchema(schema)) + guard !flavor.isStarRocks else { + let rows = try await starrocksCatalogRead(fallback: []) { try await self.execute(query: statement).rows } + var metadata: [String: PluginTableMetadata] = [:] + for row in rows { + guard let name = row[safe: 0]?.asText else { continue } + metadata[name] = StarRocksCatalog.tableMetadata(fromStatusRow: row, tableName: name) + } + return metadata + } + let result = try await execute(query: statement) let appendsInnoDBStatus = serverAppendsInnoDBStatus var metadata: [String: PluginTableMetadata] = [:] for row in result.rows { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 1e1fb9ff28..6e47d94f1a 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -54,9 +54,10 @@ internal extension MySQLPluginDriver { let columnsByTable = try await informationSchemaColumns(schema: schema, table: table) return columnsByTable[table] ?? (columnsByTable.count == 1 ? columnsByTable.values.first ?? [] : []) } + guard !flavor.isStarRocks else { + return try await starrocksColumns(database: effectiveSchema(schema), table: table)[table] ?? [] + } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") - // Both lookups read MySQL's `information_schema` and `SHOW CREATE TABLE` shapes, which an external - // StarRocks catalog is not held to, and a failed lookup would fail the whole column read. var catalogDetails: [String: MySQLCatalogColumnDetail] = [:] var createTableDefaults: MySQLCreateTableDefaults? if flavor.hasMySQLObjectCatalogs { @@ -329,7 +330,8 @@ internal extension MySQLPluginDriver { } } - /// StarRocks reads one table at a time, which a caller weighing the bulk read should not mistake for one query. + /// StarRocks answers the bulk read in one query, but an external catalog loads every table's schema + /// from the lake behind it to do so, which a caller weighing the bulk read should not mistake for cheap. var providesBulkColumnFetch: Bool { !flavor.isStarRocks } /// `GENERATION_EXPRESSION` is projected here rather than looked up per table, because a caller @@ -339,7 +341,7 @@ internal extension MySQLPluginDriver { func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { guard !flavor.isDatabend else { return try await databendAllColumns(schema: schema) } let database = effectiveSchema(schema) - guard !flavor.isStarRocks else { return try await showColumnsByTable(database: database) } + guard !flavor.isStarRocks else { return try await starrocksColumns(database: database, table: nil) } return try await catalogOrShow( database: database, catalogExists: serverHasInformationSchema, diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift index 8ed0b0f7fb..0980e181a7 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift @@ -47,7 +47,8 @@ extension MySQLPluginDriver { } /// `SHOW FULL TABLES` tells views apart, but a connector behind an external catalog may refuse - /// it, and plain `SHOW TABLES` still lists what is there. + /// it, and plain `SHOW TABLES` still lists what is there. It types a materialized view as `VIEW`, + /// so those are named from `information_schema.materialized_views`. func starrocksTables(database: String) async throws -> [PluginTableInfo] { let source = mysqlQuoteIdentifier(database) let rows: [[PluginCellValue]] @@ -58,6 +59,32 @@ extension MySQLPluginDriver { } catch { rows = try await execute(query: "SHOW TABLES FROM \(source)").rows } - return MySQLTableListing.tables(from: rows, listsSequencesAsTables: false) + let tables = MySQLTableListing.tables(from: rows, listsSequencesAsTables: false) + let materialized = try await starrocksCatalogRead(fallback: []) { + StarRocksCatalog.names(from: try await self.execute( + ownStatement: StarRocksCatalog.materializedViewNames(database: database) + ).rows) + } + return StarRocksCatalog.markingMaterializedViews(Set(materialized), in: tables) + } + + /// Keyed by table. An external catalog's `SHOW FULL COLUMNS` adds the connector's `_file` and + /// `_pos`, which `SELECT *` never returns; `information_schema.columns` lists what the table holds. + func starrocksColumns(database: String, table: String?) async throws -> [String: [PluginColumnInfo]] { + let rows = try await execute( + ownStatement: StarRocksCatalog.columns(database: database, table: table) + ).rows + return StarRocksCatalog.columnsByTable(from: rows) + } + + /// `information_schema.tables_config`, `materialized_views` and the table status of an external + /// catalog are missing or empty, so a refusal there reads as nothing to report. + func starrocksCatalogRead(fallback: T, _ read: () async throws -> T) async throws -> T { + do { + return try await read() + } catch let error as MariaDBPluginError + where !error.isConnectionTimeout && MySQLTableListing.showFullTablesSettlesCatalogFailure(code: error.code) { + return fallback + } } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 6c7ccfeed4..0b3fa2eee0 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -924,7 +924,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { - guard !flavor.isStarRocks, await !resolvesToTemporaryTable(table, schema: schema) else { return nil } + guard !flavor.isStarRocks else { + return try await starrocksCatalogRead(fallback: nil) { + try await self.tableStatusRow(table: table, schema: schema).flatMap(StarRocksCatalog.rowCount(fromStatusRow:)) + } + } + guard await !resolvesToTemporaryTable(table, schema: schema) else { return nil } guard serverHasInformationSchema else { return try await tableStatusRow(table: table, schema: schema)?[safe: 4]?.asText.flatMap { Int($0) } } @@ -990,7 +995,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { guard !flavor.isDatabend else { return try await databendViewDefinition(view: view, schema: schema) } - let result = try await execute(query: "SHOW CREATE VIEW \(qualifiedName(view, schema: schema))") + // StarRocks's `SHOW CREATE VIEW` rewrites a materialized view as a plain `CREATE VIEW`, while + // `SHOW CREATE TABLE` gives each kind its own statement. + let show = flavor.isStarRocks ? "SHOW CREATE TABLE" : "SHOW CREATE VIEW" + let result = try await execute(query: "\(show) \(qualifiedName(view, schema: schema))") guard let firstRow = result.rows.first, let ddl = firstRow[safe: 1]?.asText @@ -1003,7 +1011,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { guard !flavor.isDatabend else { return try await databendTableMetadata(table: table, schema: schema) } - guard !flavor.isStarRocks else { return PluginTableMetadata(tableName: table) } + guard !flavor.isStarRocks else { + let row = try await starrocksCatalogRead(fallback: nil) { + try await self.tableStatusRow(table: table, schema: schema) + } + return row.map { StarRocksCatalog.tableMetadata(fromStatusRow: $0, tableName: table) } + ?? PluginTableMetadata(tableName: table) + } guard await !resolvesToTemporaryTable(table, schema: schema), let row = try await tableStatusRow(table: table, schema: schema) else { return PluginTableMetadata(tableName: table) diff --git a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift index 16e2aa3af4..8c2c75c39b 100644 --- a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift +++ b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift @@ -8,6 +8,7 @@ // import Foundation +import TableProMySQLCore import TableProPluginKit nonisolated internal enum StarRocksCatalog { @@ -52,4 +53,79 @@ nonisolated internal enum StarRocksCatalog { static func names(from rows: [[PluginCellValue]]) -> [String] { rows.compactMap { $0[safe: 0]?.asText?.nilIfEmpty } } + + static func materializedViewNames(database: String) -> String { + """ + SELECT TABLE_NAME FROM information_schema.materialized_views + WHERE TABLE_SCHEMA = '\(mysqlEscapeStringLiteral(database))' + """ + } + + static func markingMaterializedViews(_ names: Set, in tables: [PluginTableInfo]) -> [PluginTableInfo] { + guard !names.isEmpty else { return tables } + return tables.map { table in + guard names.contains(table.name) else { return table } + return PluginTableInfo( + name: table.name, type: "MATERIALIZED VIEW", rowCount: table.rowCount, schema: table.schema, + comment: table.comment, partitionCount: nil, ownedByTable: nil, ownedByColumn: nil + ) + } + } + + /// A `SHOW TABLE STATUS` row, at MySQL's positions. StarRocks reports a view's rows and sizes as + /// -1 and never an index length, so the data length is the whole size. + static func tableMetadata(fromStatusRow row: [PluginCellValue], tableName: String) -> PluginTableMetadata { + let dataSize = measured(row[safe: 6]) + return PluginTableMetadata( + tableName: tableName, + dataSize: dataSize, + totalSize: dataSize, + avgRowLength: measured(row[safe: 5]), + rowCount: measured(row[safe: 4]), + comment: row[safe: 17]?.asText?.nilIfEmpty, + engine: row[safe: 1]?.asText?.nilIfEmpty, + collation: row[safe: 14]?.asText?.nilIfEmpty + ) + } + + static func rowCount(fromStatusRow row: [PluginCellValue]) -> Int? { + measured(row[safe: 4]).map { Int($0) } + } + + private static func measured(_ cell: PluginCellValue?) -> Int64? { + cell?.asText.flatMap { Int64($0) }.flatMap { $0 >= 0 ? $0 : nil } + } + + static func columns(database: String, table: String?) -> String { + let tableFilter = table.map { " AND TABLE_NAME = '\(mysqlEscapeStringLiteral($0))'" } ?? "" + return """ + SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, COLUMN_COMMENT + FROM information_schema.columns + WHERE TABLE_SCHEMA = '\(mysqlEscapeStringLiteral(database))'\(tableFilter) + ORDER BY TABLE_NAME, ORDINAL_POSITION + """ + } + + /// `COLUMN_KEY` names the table's key model on each key column. A primary, unique or aggregate key + /// holds one row per key, so it picks out the row a grid edit means; a duplicate key holds any number. + static let rowIdentifyingKeys: Set = ["PRI", "UNI", "AGG"] + + static func columnsByTable(from rows: [[PluginCellValue]]) -> [String: [PluginColumnInfo]] { + var columns: [String: [PluginColumnInfo]] = [:] + for row in rows { + guard let table = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText, + let dataType = row[safe: 2]?.asText + else { continue } + columns[table, default: []].append(PluginColumnInfo( + name: name, + dataType: dataType.uppercased(), + isNullable: row[safe: 3]?.asText == "YES", + isPrimaryKey: rowIdentifyingKeys.contains(row[safe: 4]?.asText ?? ""), + defaultValue: row[safe: 5]?.asText, + comment: row[safe: 6]?.asText?.nilIfEmpty + )) + } + return columns + } } diff --git a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift index 98f57fdd0d..500dcdf214 100644 --- a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -120,6 +120,90 @@ struct StarRocksCatalogTests { #expect(driver.activeDatabaseName == "a.b") } + /// `information_schema.columns` rows as StarRocks 4.1.7 answers them, one table per key model. + @Test("A primary, unique or aggregate key picks out a row, and a duplicate key does not") + func keyModels() { + let row: (String, String, String, String, String) -> [PluginCellValue] = { table, column, type, nullable, key in + [.text(table), .text(column), .text(type), .text(nullable), .text(key), .null, .text("")] + } + let columns = StarRocksCatalog.columnsByTable(from: [ + row("pk", "id", "bigint(20)", "NO", "PRI"), + row("pk", "v", "varchar(20)", "YES", ""), + row("uq", "a", "int(11)", "YES", "UNI"), + row("agg", "a", "int(11)", "YES", "AGG"), + row("dup", "a", "int(11)", "YES", "DUP"), + ]) + #expect(columns["pk"]?.map(\.isPrimaryKey) == [true, false]) + #expect(columns["pk"]?.first?.isNullable == false) + #expect(columns["pk"]?.first?.dataType == "BIGINT(20)") + #expect(columns["pk"]?.first?.comment == nil) + #expect(columns["pk"]?.first?.defaultValue == nil) + #expect(columns["uq"]?.first?.isPrimaryKey == true) + #expect(columns["agg"]?.first?.isPrimaryKey == true) + #expect(columns["dup"]?.first?.isPrimaryKey == false) + } + + @Test("A column keeps its default and comment") + func columnDetails() { + let columns = StarRocksCatalog.columnsByTable(from: [ + [.text("cm"), .text("id"), .text("int(11)"), .text("NO"), .text("PRI"), .null, .text("the id")], + [.text("cm"), .text("d"), .text("datetime"), .text("YES"), .text(""), .text("CURRENT_TIMESTAMP"), .text("")], + ]) + #expect(columns["cm"]?.map(\.name) == ["id", "d"]) + #expect(columns["cm"]?.first?.comment == "the id") + #expect(columns["cm"]?.last?.defaultValue == "CURRENT_TIMESTAMP") + } + + @Test("Catalog reads escape the names they filter on") + func catalogReadsEscape() { + #expect(StarRocksCatalog.columns(database: "o'b", table: "t'1").contains("TABLE_SCHEMA = 'o''b' AND TABLE_NAME = 't''1'")) + #expect(!StarRocksCatalog.columns(database: "db", table: nil).contains("TABLE_NAME =")) + #expect(StarRocksCatalog.materializedViewNames(database: "o'b").contains("TABLE_SCHEMA = 'o''b'")) + } + + @Test("A materialized view listed as VIEW is retyped, and the rest keep their type") + func materializedViews() { + let tables = [ + PluginTableInfo(name: "dup", type: "TABLE", comment: nil, partitionCount: nil, ownedByTable: nil, ownedByColumn: nil), + PluginTableInfo(name: "mv1", type: "VIEW", comment: nil, partitionCount: nil, ownedByTable: nil, ownedByColumn: nil), + PluginTableInfo(name: "v1", type: "VIEW", comment: nil, partitionCount: nil, ownedByTable: nil, ownedByColumn: nil), + ] + let marked = StarRocksCatalog.markingMaterializedViews(["mv1"], in: tables) + #expect(marked.map(\.type) == ["TABLE", "MATERIALIZED VIEW", "VIEW"]) + } + + /// `SHOW TABLE STATUS` rows as StarRocks 4.1.7 answers them: MySQL's positions, a view at -1, + /// and no index length. + @Test("Table status reads the data length as the whole size, and a view's -1 as unknown") + func tableStatus() { + let table = Self.statusRow(name: "pk", engine: "StarRocks", rows: "42", avgRowLength: "12", dataLength: "504") + let metadata = StarRocksCatalog.tableMetadata(fromStatusRow: table, tableName: "pk") + #expect(metadata.rowCount == 42) + #expect(metadata.dataSize == 504) + #expect(metadata.totalSize == 504) + #expect(metadata.indexSize == nil) + #expect(metadata.engine == "StarRocks") + #expect(metadata.collation == "utf8_general_ci") + #expect(StarRocksCatalog.rowCount(fromStatusRow: table) == 42) + + let view = Self.statusRow(name: "v1", engine: nil, rows: "-1", avgRowLength: "-1", dataLength: "-1") + let viewMetadata = StarRocksCatalog.tableMetadata(fromStatusRow: view, tableName: "v1") + #expect(viewMetadata.rowCount == nil) + #expect(viewMetadata.dataSize == nil) + #expect(viewMetadata.engine == nil) + #expect(StarRocksCatalog.rowCount(fromStatusRow: view) == nil) + } + + private static func statusRow( + name: String, engine: String?, rows: String, avgRowLength: String, dataLength: String + ) -> [PluginCellValue] { + [ + .text(name), engine.map { .text($0) } ?? .null, .null, .text(""), .text(rows), .text(avgRowLength), + .text(dataLength), .null, .null, .null, .null, .text("2026-10-31 15:19:44"), .text("2026-10-31 15:19:44"), + .null, .text("utf8_general_ci"), .null, .text(""), .text(""), + ] + } + private static func config(database: String) -> DriverConnectionConfig { DriverConnectionConfig( host: "localhost", port: 9_030, username: "root", password: "", database: database, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 5e5f4245b8..041467c7af 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -23,6 +23,15 @@ extension PluginMetadataRegistry { ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), ] + /// Measured on StarRocks 4.1.7. No `EXPLAIN ANALYZE`: it colors its report with terminal escape + /// codes, which a plain-text plan shows as `[0m` throughout. + static let starrocksExplainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), + ExplainVariant(id: "logical", label: "EXPLAIN LOGICAL", sqlPrefix: "EXPLAIN LOGICAL", format: .plainText), + ExplainVariant(id: "verbose", label: "EXPLAIN VERBOSE", sqlPrefix: "EXPLAIN VERBOSE", format: .plainText), + ExplainVariant(id: "costs", label: "EXPLAIN COSTS", sqlPrefix: "EXPLAIN COSTS", format: .plainText), + ] + /// A grid reads every cell back as text and a keyless save compares that text to the stored /// value. Measured on MySQL 8.4.11 and OceanBase CE 4.4.2.1, on these three types it does not /// compare equal to what it was read from, so the save matched no row and was reported as a @@ -92,7 +101,7 @@ extension PluginMetadataRegistry { displayName: "StarRocks", iconName: "starrocks-icon", defaultPort: 9_030, requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, isDownloadable: false, primaryUrlScheme: "", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: mysqlVariantExplainVariants, pathFieldRole: .database, + navigationModel: .standard, explainVariants: starrocksExplainVariants, pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], brandColorHex: "#01808F", From bec5a70b9c40d837f0243ebb1522d0dd5a4dc455 Mon Sep 17 00:00:00 2001 From: Victor Babenko Date: Sat, 31 Oct 2026 12:19:46 -0700 Subject: [PATCH 6/7] fix(sessions): drop schema the driver left behind on catalog switch Co-authored-by: Cursor --- .../Database/DatabaseManager+Sessions.swift | 10 ++++-- .../Core/Database/DatabaseManagerTests.swift | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index 7383aeeec9..1d29a9db82 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -461,9 +461,13 @@ extension DatabaseManager { } updateSession(connectionId) { session in session.browseDatabase = database - if grouping == .bySchema { - session.browseSchema = adapter.currentSchema - } + if grouping == .bySchema { + session.browseSchema = adapter.currentSchema + } else if grouping == .hierarchicalSchema, adapter.currentSchema == nil { + // The old schema may not exist in the new catalog; keeping it pins pooled + // metadata connections to it and every read fails. + session.browseSchema = nil + } } } diff --git a/apple/TableProTests/Core/Database/DatabaseManagerTests.swift b/apple/TableProTests/Core/Database/DatabaseManagerTests.swift index a34d9957ed..fb0af467ff 100644 --- a/apple/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -363,4 +363,38 @@ struct DatabaseManagerDatabaseSwitchTests { #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == adapter.currentSchema) } + + /// StarRocks: `idb` lives in the `ice` catalog, and pinning it to a `default_catalog` + /// connection ran `USE idb` there, which failed every metadata read. + @Test("A catalog switch that leaves the driver on no schema leaves the session on none") + func hierarchicalSwitchDropsSchemaTheDriverLeft() async throws { + let connection = TestFixtures.makeConnection(type: .starrocks) + let pluginDriver = DatabaseSwitchingDriver(currentSchema: nil) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + var session = ConnectionSession(connection: connection, driver: adapter) + session.browseSchema = "idb" + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + try await DatabaseManager.shared.switchDatabase(to: "default_catalog", for: connection.id, persist: false) + + let updated = DatabaseManager.shared.session(for: connection.id) + #expect(updated?.browseDatabase == "default_catalog") + #expect(updated?.browseSchema == nil) + } + + @Test("A catalog switch that keeps the driver's schema keeps the session's") + func hierarchicalSwitchKeepsSchemaTheDriverKept() async throws { + let connection = TestFixtures.makeConnection(type: .trino) + let pluginDriver = DatabaseSwitchingDriver(currentSchema: "web") + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + var session = ConnectionSession(connection: connection, driver: adapter) + session.browseSchema = "web" + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + try await DatabaseManager.shared.switchDatabase(to: "hive", for: connection.id, persist: false) + + #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == "web") + } } From b038a97c04a6f41cc3940944cbc94bd35b8d7058 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 03:31:41 +0700 Subject: [PATCH 7/7] refactor(starrocks): ship as a registry driver and enter catalog scopes in one statement --- .github/plugin-registry.json | 3 + .../TableProCoreTypes/DatabaseType.swift | 2 +- .../DatabaseTypeTests.swift | 2 +- apple/Plugins/MySQLDriverPlugin/Info.plist | 1 - .../MySQLDriverPlugin/MySQLPlugin.swift | 7 +- .../MySQLPluginDriver+BulkMetadata.swift | 13 +- .../MySQLPluginDriver+CreateDatabase.swift | 3 +- .../MySQLPluginDriver+DatabaseMetadata.swift | 2 +- .../MySQLPluginDriver+Schema.swift | 15 +- .../MySQLPluginDriver+StarRocks.swift | 84 +++----- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 98 ++++------ .../MySQLDriverPlugin/MySQLServerFlavor.swift | 15 +- .../MySQLDriverPlugin/MySQLTableListing.swift | 19 +- .../MySQLDriverPlugin/StarRocksCatalog.swift | 109 +++++------ .../StarRocksCatalogTests.swift | 180 ++++++++++-------- .../Plugins/StarRocksDriverPlugin/Info.plist | 12 ++ .../StarRocksPlugin.swift | 65 +++++++ .../Core/DataWrite/DataWriteRowCounts.swift | 2 +- .../Database/DatabaseManager+Health.swift | 13 +- .../Database/DatabaseManager+Sessions.swift | 17 +- .../Core/Plugins/DatabaseType+Registry.swift | 2 +- .../Core/Plugins/ImportTypeMapper.swift | 2 +- ...uginMetadataRegistry+CuratedDefaults.swift | 1 + ...etadataRegistry+MySQLVariantDefaults.swift | 96 ---------- ...inMetadataRegistry+StarRocksDefaults.swift | 106 +++++++++++ .../Core/Plugins/PluginMetadataRegistry.swift | 1 - .../CellValues/CellExpressionVocabulary.swift | 2 +- .../Models/Schema/ForeignKeyDialect.swift | 2 +- .../Core/Database/DatabaseManagerTests.swift | 29 ++- .../MySQLProtocolVariantParityTests.swift | 3 +- ...PluginMetadataRegistryStarRocksTests.swift | 54 ++++++ ...PluginMetadataRegistryTypeCountTests.swift | 1 - apple/changelog.d/3939.added.md | 2 +- apple/project.yml | 12 ++ 34 files changed, 542 insertions(+), 433 deletions(-) create mode 100644 apple/Plugins/StarRocksDriverPlugin/Info.plist create mode 100644 apple/Plugins/StarRocksDriverPlugin/StarRocksPlugin.swift create mode 100644 apple/TablePro/Core/Plugins/PluginMetadataRegistry+StarRocksDefaults.swift create mode 100644 apple/TableProTests/Core/Plugins/PluginMetadataRegistryStarRocksTests.swift diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index f6bf29a547..f838133d8f 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -67,6 +67,9 @@ "SpannerDriverPlugin": { "name": "Spanner Driver" }, + "StarRocksDriverPlugin": { + "name": "StarRocks Driver" + }, "SurrealDBDriverPlugin": { "name": "SurrealDB Driver" }, diff --git a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index a2bebfb5d7..cfa5a5fb91 100644 --- a/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/apple/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -97,7 +97,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public var pluginTypeId: String { switch self { - case .mariadb, .tidb, .databend, .oceanbase, .starrocks: 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/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index e05dce9665..c125adbab9 100644 --- a/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -32,7 +32,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.tidb.pluginTypeId == "MySQL") #expect(DatabaseType.databend.pluginTypeId == "MySQL") #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") - #expect(DatabaseType.starrocks.pluginTypeId == "MySQL") + #expect(DatabaseType.starrocks.pluginTypeId == "StarRocks") #expect(DatabaseType.postgresql.pluginTypeId == "PostgreSQL") #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") #expect(DatabaseType.sqlite.pluginTypeId == "SQLite") diff --git a/apple/Plugins/MySQLDriverPlugin/Info.plist b/apple/Plugins/MySQLDriverPlugin/Info.plist index 7ea262fa2a..e736a2c92e 100644 --- a/apple/Plugins/MySQLDriverPlugin/Info.plist +++ b/apple/Plugins/MySQLDriverPlugin/Info.plist @@ -11,7 +11,6 @@ TiDB Databend OceanBase - StarRocks diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 7f87287390..e38685d0d2 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -16,7 +16,7 @@ import TableProSQLVocabulary final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MySQL Driver" static let pluginVersion = "1.0.0" - static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, OceanBase, and StarRocks support via libmariadb" + static let pluginDescription = "MySQL, MariaDB, TiDB, Databend, and OceanBase support via libmariadb" static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MySQL" @@ -34,7 +34,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ), MySQLConnectionEncoding.connectionField ] - static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase", "StarRocks"] + static let additionalDatabaseTypeIds: [String] = ["MariaDB", "TiDB", "Databend", "OceanBase"] // MARK: - UI/Capability Metadata @@ -110,8 +110,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static func driverVariant(for databaseTypeId: String) -> String? { switch databaseTypeId { - case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant, - MySQLServerFlavor.starrocksVariant: + case MySQLServerFlavor.tidbVariant, MySQLServerFlavor.databendVariant, MySQLServerFlavor.oceanbaseVariant: return databaseTypeId default: return nil diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index efcf1d6274..9ac9e58ffb 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift @@ -80,17 +80,8 @@ extension MySQLPluginDriver { /// so a caller asking about another one is answered about the one it asked about. func fetchAllTableMetadata(schema: String?) async throws -> [String: PluginTableMetadata] { guard !flavor.isDatabend else { return try await databendAllTableMetadata(database: routineSchema(schema)) } - let statement = Self.tableStatusStatement(database: routineSchema(schema)) - guard !flavor.isStarRocks else { - let rows = try await starrocksCatalogRead(fallback: []) { try await self.execute(query: statement).rows } - var metadata: [String: PluginTableMetadata] = [:] - for row in rows { - guard let name = row[safe: 0]?.asText else { continue } - metadata[name] = StarRocksCatalog.tableMetadata(fromStatusRow: row, tableName: name) - } - return metadata - } - let result = try await execute(query: statement) + guard !flavor.isStarRocks else { return try await starrocksTableStatus(database: routineSchema(schema)) } + let result = try await execute(query: Self.tableStatusStatement(database: routineSchema(schema))) let appendsInnoDBStatus = serverAppendsInnoDBStatus var metadata: [String: PluginTableMetadata] = [:] for row in result.rows { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift index ac6854a89d..a47f1a5e68 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift @@ -6,8 +6,7 @@ import TableProPluginKit extension MySQLPluginDriver { func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { guard !flavor.isDatabend else { return PluginCreateDatabaseFormSpec(fields: [], footnote: nil) } - // The StarRocks database level is its catalogs, and `CREATE EXTERNAL CATALOG` takes connector properties - // no charset form can describe. + // The StarRocks database level is its catalogs, which a charset form cannot describe. guard !flavor.isStarRocks else { return nil } let charsetDefaults = try await fetchCharsetDefaults() let collations = try await fetchCollationCatalog() diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift index bc028245ae..252b48ee32 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+DatabaseMetadata.swift @@ -13,7 +13,7 @@ import TableProPluginKit internal extension MySQLPluginDriver { func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { let isSystem = systemDatabaseNamesForConnectionType.contains(database) - // A StarRocks database here is a catalog, and sizing an external one means a scan of the lake behind it. + // A StarRocks catalog is sized only by scanning the lake behind it. guard !flavor.isStarRocks else { return PluginDatabaseMetadata(name: database, isSystemDatabase: isSystem) } guard serverHasInformationSchema else { return try await tableStatusMetadata(of: database, isSystem: isSystem) diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 6e47d94f1a..57fdf6a758 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -58,14 +58,10 @@ internal extension MySQLPluginDriver { return try await starrocksColumns(database: effectiveSchema(schema), table: table)[table] ?? [] } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") - var catalogDetails: [String: MySQLCatalogColumnDetail] = [:] - var createTableDefaults: MySQLCreateTableDefaults? - if flavor.hasMySQLObjectCatalogs { - catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) - createTableDefaults = try await self.createTableDefaults( - table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails - ) - } + let catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) + let createTableDefaults = try await createTableDefaults( + table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails + ) let columns = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = row[safe: 0]?.asText, @@ -330,8 +326,7 @@ internal extension MySQLPluginDriver { } } - /// StarRocks answers the bulk read in one query, but an external catalog loads every table's schema - /// from the lake behind it to do so, which a caller weighing the bulk read should not mistake for cheap. + // StarRocks answers the bulk read by loading every table's schema from the lake behind an external catalog. var providesBulkColumnFetch: Bool { !flavor.isStarRocks } /// `GENERATION_EXPRESSION` is projected here rather than looked up per table, because a caller diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift index 0980e181a7..bbc2cc4724 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift @@ -8,68 +8,23 @@ import TableProMySQLCore import TableProPluginKit extension MySQLPluginDriver { - /// No transactions: before 3.5 StarRocks has none across statements, and an external catalog has - /// none at all. Compare, routines and accounts read MySQL catalogs StarRocks does not keep, and - /// prepared statements are left off because a frontend before 3.2 refuses `COM_STMT_PREPARE`. + // No multi-statement transactions before 3.5, and a frontend before 3.2 refuses `COM_STMT_PREPARE`. static let starrocksCapabilities: PluginCapabilities = [ .cancelQuery, ] - /// Enters the catalog and database the session belongs in, on a connection that has just come up - /// in `default_catalog` with no database. - /// - /// A name without a dot is first tried as a catalog, because a pooled metadata connection carries - /// the browsed catalog in that field. A server that has no such catalog takes it as a database of - /// `default_catalog`, which is what a connection typed by hand means by it. - func enterStarRocksSessionScope(on conn: MariaDBPluginConnection, deadline: MySQLConnectDeadline) async throws { - let database = activeDatabaseName - if let catalog = activeCatalogName { - _ = try await conn.executeConnectQuery(StarRocksCatalog.setCatalog(catalog), deadline: deadline) - if !database.isEmpty { - _ = try await conn.executeConnectQuery(StarRocksCatalog.useDatabase(database), deadline: deadline) - } - adoptStarRocksScope(catalog: catalog, database: database) - return - } - guard !database.isEmpty else { - adoptStarRocksScope(catalog: StarRocksCatalog.defaultCatalog, database: "") - return - } - do { - _ = try await conn.executeConnectQuery(StarRocksCatalog.setCatalog(database), deadline: deadline) - adoptStarRocksScope(catalog: database, database: "") - } catch let error as MariaDBPluginError where error.isConnectionTimeout { - throw error - } catch { - _ = try await conn.executeConnectQuery(StarRocksCatalog.useDatabase(database), deadline: deadline) - adoptStarRocksScope(catalog: StarRocksCatalog.defaultCatalog, database: database) - } - } - - /// `SHOW FULL TABLES` tells views apart, but a connector behind an external catalog may refuse - /// it, and plain `SHOW TABLES` still lists what is there. It types a materialized view as `VIEW`, - /// so those are named from `information_schema.materialized_views`. func starrocksTables(database: String) async throws -> [PluginTableInfo] { - let source = mysqlQuoteIdentifier(database) - let rows: [[PluginCellValue]] - do { - rows = try await execute(query: "SHOW FULL TABLES FROM \(source)").rows - } catch let error as MariaDBPluginError where error.isConnectionTimeout { - throw error - } catch { - rows = try await execute(query: "SHOW TABLES FROM \(source)").rows - } + let rows = try await execute( + query: MySQLObjectQueries.listedTables(schema: database, hasInformationSchema: true) + ).rows let tables = MySQLTableListing.tables(from: rows, listsSequencesAsTables: false) - let materialized = try await starrocksCatalogRead(fallback: []) { - StarRocksCatalog.names(from: try await self.execute( - ownStatement: StarRocksCatalog.materializedViewNames(database: database) - ).rows) - } + guard starrocksScope.keepsTableStatus else { return tables } + let materialized = StarRocksCatalog.names(from: try await execute( + ownStatement: StarRocksCatalog.materializedViewNames(database: database) + ).rows) return StarRocksCatalog.markingMaterializedViews(Set(materialized), in: tables) } - /// Keyed by table. An external catalog's `SHOW FULL COLUMNS` adds the connector's `_file` and - /// `_pos`, which `SELECT *` never returns; `information_schema.columns` lists what the table holds. func starrocksColumns(database: String, table: String?) async throws -> [String: [PluginColumnInfo]] { let rows = try await execute( ownStatement: StarRocksCatalog.columns(database: database, table: table) @@ -77,14 +32,21 @@ extension MySQLPluginDriver { return StarRocksCatalog.columnsByTable(from: rows) } - /// `information_schema.tables_config`, `materialized_views` and the table status of an external - /// catalog are missing or empty, so a refusal there reads as nothing to report. - func starrocksCatalogRead(fallback: T, _ read: () async throws -> T) async throws -> T { - do { - return try await read() - } catch let error as MariaDBPluginError - where !error.isConnectionTimeout && MySQLTableListing.showFullTablesSettlesCatalogFailure(code: error.code) { - return fallback + func starrocksTableStatus(database: String) async throws -> [String: PluginTableMetadata] { + guard starrocksScope.keepsTableStatus else { return [:] } + let rows = try await execute(query: Self.tableStatusStatement(database: database)).rows + var metadata: [String: PluginTableMetadata] = [:] + for row in rows { + guard let name = row[safe: 0]?.asText else { continue } + metadata[name] = MySQLTableStatusRow.metadata(from: row, tableName: name, reportsIndexLength: false) + } + return metadata + } + + func starrocksTableStatus(table: String, schema: String?) async throws -> PluginTableMetadata? { + guard starrocksScope.keepsTableStatus, let row = try await tableStatusRow(table: table, schema: schema) else { + return nil } + return MySQLTableStatusRow.metadata(from: row, tableName: table, reportsIndexLength: false) } } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 0b3fa2eee0..6510311b5a 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -25,12 +25,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { sessionLock.withLock { _activeDatabase } } - /// The StarRocks catalog the session is in, which every reconnect enters again before its `USE`. - /// Nil only before the first connect has settled what the Database field named. - private var _activeCatalog: String? + // StarRocks only: the catalog `_activeDatabase` lives in, which every reconnect enters again. + private var _activeCatalog: String - var activeCatalogName: String? { - sessionLock.withLock { _activeCatalog } + var starrocksScope: StarRocksCatalog.Scope { + sessionLock.withLock { StarRocksCatalog.Scope(catalog: _activeCatalog, database: _activeDatabase) } } internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog? @@ -107,7 +106,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") - /// StarRocks browses a catalog's databases as schemas. Every other flavor has no schema level. var currentSchema: String? { guard flavor.isStarRocks else { return nil } return activeDatabaseName.nilIfEmpty @@ -115,7 +113,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var currentDatabase: String? { guard flavor.isStarRocks else { return nil } - return activeCatalogName + return starrocksScope.catalog } var serverVersion: String? { sessionLock.withLock { _serverVersion } } @@ -192,29 +190,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { init(config: DriverConnectionConfig) { let flavor = Self.initialFlavor(for: config) + let scope = flavor.isStarRocks ? StarRocksCatalog.Scope(configuredDatabase: config.database) : nil self.config = config self._flavor = flavor - guard flavor.isStarRocks else { - self._activeDatabase = config.database - return - } - switch StarRocksCatalog.target(fromConfiguredDatabase: config.database) { - case .qualified(let catalog, let database): - self._activeCatalog = catalog - self._activeDatabase = database - case .bareName(let name): - self._activeDatabase = name - case .serverDefault: - self._activeDatabase = "" - } - } - - /// Written once the session has entered the scope, so a reconnect enters the same one. - func adoptStarRocksScope(catalog: String, database: String) { - sessionLock.withLock { - _activeCatalog = catalog - _activeDatabase = database - } + self._activeDatabase = scope?.database ?? config.database + self._activeCatalog = scope?.catalog ?? StarRocksCatalog.defaultCatalog } /// The timer's task outlives the driver it was started for, so a driver dropped without @@ -239,13 +219,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { deadline: connectDeadline ) - // A StarRocks handshake database is read in `default_catalog`, so the scope is entered after connecting. - let enterStarRocksScope = Self.initialFlavor(for: config).isStarRocks + // A StarRocks handshake database is read in default_catalog, so the scope is entered after connecting. + let entersStarRocksScope = Self.initialFlavor(for: config).isStarRocks let conn = MariaDBPluginConnection( transport: MySQLTransport(host: config.host, port: config.port, additionalFields: config.additionalFields), user: config.username, password: kerberos == nil ? password : nil, - database: enterStarRocksScope ? "" : activeDatabaseName, + database: entersStarRocksScope ? "" : activeDatabaseName, sslConfig: sslConfig, enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", tunnelSecuresTransport: config.additionalFields["tunnelSecuresTransport"] == "true", @@ -276,8 +256,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return true }) try await conn.completeConnect(deadline: connectDeadline) - if enterStarRocksScope { - try await enterStarRocksSessionScope(on: conn, deadline: connectDeadline) + if entersStarRocksScope { + let scope = starrocksScope + if !scope.isServerDefault { + _ = try await conn.executeConnectQuery(scope.enterStatement, deadline: connectDeadline) + } } } catch { conn.disconnect() @@ -925,9 +908,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { guard !flavor.isStarRocks else { - return try await starrocksCatalogRead(fallback: nil) { - try await self.tableStatusRow(table: table, schema: schema).flatMap(StarRocksCatalog.rowCount(fromStatusRow:)) - } + return try await starrocksTableStatus(table: table, schema: schema)?.rowCount.map { Int($0) } } guard await !resolvesToTemporaryTable(table, schema: schema) else { return nil } guard serverHasInformationSchema else { @@ -995,8 +976,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { guard !flavor.isDatabend else { return try await databendViewDefinition(view: view, schema: schema) } - // StarRocks's `SHOW CREATE VIEW` rewrites a materialized view as a plain `CREATE VIEW`, while - // `SHOW CREATE TABLE` gives each kind its own statement. + // StarRocks's `SHOW CREATE VIEW` rewrites a materialized view as a plain `CREATE VIEW`. let show = flavor.isStarRocks ? "SHOW CREATE TABLE" : "SHOW CREATE VIEW" let result = try await execute(query: "\(show) \(qualifiedName(view, schema: schema))") @@ -1012,11 +992,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { guard !flavor.isDatabend else { return try await databendTableMetadata(table: table, schema: schema) } guard !flavor.isStarRocks else { - let row = try await starrocksCatalogRead(fallback: nil) { - try await self.tableStatusRow(table: table, schema: schema) - } - return row.map { StarRocksCatalog.tableMetadata(fromStatusRow: $0, tableName: table) } - ?? PluginTableMetadata(tableName: table) + return try await starrocksTableStatus(table: table, schema: schema) ?? PluginTableMetadata(tableName: table) } guard await !resolvesToTemporaryTable(table, schema: schema), let row = try await tableStatusRow(table: table, schema: schema) else { @@ -1054,7 +1030,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Database Operations - /// StarRocks lists catalogs here, the level above its databases. func fetchDatabases() async throws -> [String] { guard !flavor.isStarRocks else { return StarRocksCatalog.names(from: try await execute(query: StarRocksCatalog.showCatalogs).rows) @@ -1114,31 +1089,34 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// The clock still moves, because a database switch is the user using the connection: leaving /// it alone let the idle timer fire seconds after a switch and charge the next click a full /// reconnect. - /// - /// On StarRocks the database level is the catalog. Entering one leaves no database selected, - /// so the schema list that follows is the new catalog's. func switchDatabase(to database: String) async throws { guard !flavor.isStarRocks else { - try await runSessionSetup(StarRocksCatalog.setCatalog(database)) - sessionLock.withLock { - _activeCatalog = database - _activeDatabase = "" - } - return + return try await enterStarRocks(starrocksScope.entering(catalog: database)) + } + _ = try await executeWithReconnect( + query: "USE \(quoteIdentifier(database))", + isRetry: false, + countsAsActivity: false + ) + sessionLock.withLock { + _activeDatabase = database + lastActivity = ContinuousClock.now } - try await runSessionSetup("USE \(quoteIdentifier(database))") - sessionLock.withLock { _activeDatabase = database } } func switchSchema(to schema: String) async throws { guard flavor.isStarRocks else { return } - try await runSessionSetup(StarRocksCatalog.useDatabase(schema)) - sessionLock.withLock { _activeDatabase = schema } + try await enterStarRocks(StarRocksCatalog.Scope(catalog: starrocksScope.catalog, database: schema)) } - private func runSessionSetup(_ statement: String) async throws { - _ = try await executeWithReconnect(query: statement, isRetry: false, countsAsActivity: false) - sessionLock.withLock { lastActivity = ContinuousClock.now } + // One statement re-asserts both levels, as `USE` does on MySQL. + private func enterStarRocks(_ scope: StarRocksCatalog.Scope) async throws { + _ = try await executeWithReconnect(query: scope.enterStatement, isRetry: false, countsAsActivity: false) + sessionLock.withLock { + _activeCatalog = scope.catalog + _activeDatabase = scope.database + lastActivity = ContinuousClock.now + } } // MARK: - Query Timeout diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index f483968392..ab3e92be0f 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -43,7 +43,7 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { static let oceanbaseUnlimitedQueryTimeoutMicroseconds: Int64 = 3_216_672_000_000_000 - /// StarRocks takes `query_timeout` in seconds from 1 to 259200 and has no spelling for unlimited. + // StarRocks takes `query_timeout` in seconds up to 259200 and has no spelling for unlimited. static let starrocksMaximumQueryTimeoutSeconds = 259_200 static func fromBanner(_ banner: String?) -> MySQLServerFlavor { @@ -102,8 +102,7 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var isStarRocks: Bool { self == .starrocks } - /// Databend and StarRocks speak the protocol without MySQL's object catalogs, so there are no - /// indexes, foreign keys, triggers, routines, events or partitions to read or write the MySQL way. + // No indexes, foreign keys, triggers, routines, events or partitions to read the MySQL way. var hasMySQLObjectCatalogs: Bool { !isDatabend && !isStarRocks } var tidbVersion: MySQLEngineVersion? { @@ -122,7 +121,7 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { case .oceanbase: return ["information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR"] case .starrocks: - // The database level of a StarRocks connection is its catalogs, and none of them is a system one. + // The database level is the catalogs, and none of them is a system one. return [] } } @@ -147,11 +146,10 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var dropsIdleSessionOnKillQuery: Bool { isTiDB } - /// StarRocks labels a JSON or an Iceberg VARIANT result charset 63 although it sends UTF-8 text, - /// so the grid showed `{"` as `0x7B22`. A VARBINARY value that is not UTF-8 still reads as bytes. + // Iceberg VARIANT arrives as a charset 63 string of UTF-8 text; VARBINARY arrives as a BLOB and stays bytes. var labelsTextResultsAsBinary: Bool { isStarRocks } - /// StarRocks took `COM_STMT_PREPARE` only in 3.2, and a 3.1 frontend refuses it. + // A StarRocks frontend before 3.2 refuses `COM_STMT_PREPARE`. var preparesOnServer: Bool { !isDatabend && !isStarRocks } func beginTransactionStatement(mode: PluginTransactionAccessMode) -> String { @@ -218,8 +216,7 @@ nonisolated internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } - /// The StarRocks handshake carries `mysql_server_version`, which defaults to a plain MySQL - /// version, so only a function MySQL lacks tells the two apart. + // The StarRocks handshake reports a plain MySQL version, so only a function MySQL lacks tells them apart. static let starrocksProbe = "SELECT current_version()" static func oceanbaseFlavor(versionComment: String?, serverVersion: String?) -> MySQLServerFlavor? { diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLTableListing.swift b/apple/Plugins/MySQLDriverPlugin/MySQLTableListing.swift index eefa35f42e..3d607345f2 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLTableListing.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLTableListing.swift @@ -122,14 +122,16 @@ enum MySQLTableStatusRow { static func metadata( from row: [PluginCellValue], tableName: String, - appendsInnoDBStatus: Bool = false + appendsInnoDBStatus: Bool = false, + reportsIndexLength: Bool = true ) -> PluginTableMetadata { - let dataSize = (row[safe: 6]?.asText).flatMap { Int64($0) } - let indexSize = (row[safe: 8]?.asText).flatMap { Int64($0) } + let dataSize = measured(row[safe: 6]) + let indexSize = reportsIndexLength ? measured(row[safe: 8]) : nil let totalSize: Int64? = { - guard let data = dataSize, let index = indexSize else { return nil } - return data + index + guard let data = dataSize else { return nil } + guard reportsIndexLength else { return data } + return indexSize.map { data + $0 } }() return PluginTableMetadata( @@ -137,10 +139,15 @@ enum MySQLTableStatusRow { dataSize: dataSize, indexSize: indexSize, totalSize: totalSize, - rowCount: (row[safe: 4]?.asText).flatMap { Int64($0) }, + rowCount: measured(row[safe: 4]), comment: MySQLTableListing.userComment(row[safe: 17]?.asText, appendsInnoDBStatus: appendsInnoDBStatus), engine: row[safe: 1]?.asText, collation: row[safe: 14]?.asText?.nilIfEmpty ) } + + // StarRocks reports a view's rows and lengths as -1. + private static func measured(_ cell: PluginCellValue?) -> Int64? { + cell?.asText.flatMap { Int64($0) }.flatMap { $0 >= 0 ? $0 : nil } + } } diff --git a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift index 8c2c75c39b..54574494ae 100644 --- a/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift +++ b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift @@ -2,15 +2,13 @@ // StarRocksCatalog.swift // MySQLDriverPlugin // -// StarRocks puts catalogs above databases: `default_catalog` holds its own tables, and an external -// catalog fronts Iceberg, Hive, Delta Lake or another engine. The connection browses catalogs where -// MySQL browses databases, and a catalog's databases where a schema-switching engine browses schemas. -// import Foundation import TableProMySQLCore import TableProPluginKit +// StarRocks puts catalogs above databases, so its catalogs fill the database level and a catalog's +// databases the schema level. nonisolated internal enum StarRocksCatalog { static let defaultCatalog = "default_catalog" @@ -18,36 +16,49 @@ nonisolated internal enum StarRocksCatalog { static let showDatabases = "SHOW DATABASES" - static func setCatalog(_ catalog: String) -> String { - "SET CATALOG \(mysqlQuoteIdentifier(catalog))" - } + struct Scope: Equatable, Sendable { + let catalog: String + let database: String - static func useDatabase(_ database: String) -> String { - "USE \(mysqlQuoteIdentifier(database))" - } + init(catalog: String, database: String) { + self.catalog = catalog + self.database = database + } - /// What the Database field of a connection names. - /// - /// `catalog.database` is the form StarRocks itself takes in its handshake and in `USE`, and the one - /// a connection imported from another client carries. Sent whole as one quoted name, the server - /// reads it as a database called `catalog.database` and an Iceberg REST catalog refuses it as a - /// malformed namespace. A name without a dot is ambiguous: a pooled metadata connection fills the - /// field with the browsed catalog, while a connection typed by hand means a database in - /// `default_catalog`, so it is settled against the server when the session opens. - enum ConfiguredTarget: Equatable, Sendable { - case serverDefault - case qualified(catalog: String, database: String) - case bareName(String) - } + // `catalog.database`, the form StarRocks clients take; a bare name is a database in default_catalog. + init(configuredDatabase text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let dot = trimmed.firstIndex(of: ".") else { + self.init(catalog: StarRocksCatalog.defaultCatalog, database: trimmed) + return + } + let catalog = String(trimmed[.. ConfiguredTarget { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return .serverDefault } - guard let dot = trimmed.firstIndex(of: ".") else { return .bareName(trimmed) } - let catalog = String(trimmed[.. Scope { + Scope(catalog: target, database: target == catalog ? database : "") + } } static func names(from rows: [[PluginCellValue]]) -> [String] { @@ -61,6 +72,7 @@ nonisolated internal enum StarRocksCatalog { """ } + // `SHOW FULL TABLES` types a materialized view as `VIEW`. static func markingMaterializedViews(_ names: Set, in tables: [PluginTableInfo]) -> [PluginTableInfo] { guard !names.isEmpty else { return tables } return tables.map { table in @@ -72,30 +84,7 @@ nonisolated internal enum StarRocksCatalog { } } - /// A `SHOW TABLE STATUS` row, at MySQL's positions. StarRocks reports a view's rows and sizes as - /// -1 and never an index length, so the data length is the whole size. - static func tableMetadata(fromStatusRow row: [PluginCellValue], tableName: String) -> PluginTableMetadata { - let dataSize = measured(row[safe: 6]) - return PluginTableMetadata( - tableName: tableName, - dataSize: dataSize, - totalSize: dataSize, - avgRowLength: measured(row[safe: 5]), - rowCount: measured(row[safe: 4]), - comment: row[safe: 17]?.asText?.nilIfEmpty, - engine: row[safe: 1]?.asText?.nilIfEmpty, - collation: row[safe: 14]?.asText?.nilIfEmpty - ) - } - - static func rowCount(fromStatusRow row: [PluginCellValue]) -> Int? { - measured(row[safe: 4]).map { Int($0) } - } - - private static func measured(_ cell: PluginCellValue?) -> Int64? { - cell?.asText.flatMap { Int64($0) }.flatMap { $0 >= 0 ? $0 : nil } - } - + // `SHOW FULL COLUMNS` on an Iceberg table adds the connector's `_file` and `_pos`, which `SELECT *` never returns. static func columns(database: String, table: String?) -> String { let tableFilter = table.map { " AND TABLE_NAME = '\(mysqlEscapeStringLiteral($0))'" } ?? "" return """ @@ -106,8 +95,7 @@ nonisolated internal enum StarRocksCatalog { """ } - /// `COLUMN_KEY` names the table's key model on each key column. A primary, unique or aggregate key - /// holds one row per key, so it picks out the row a grid edit means; a duplicate key holds any number. + // `COLUMN_KEY` names the key model. A duplicate key (`DUP`) holds any number of equal rows. static let rowIdentifyingKeys: Set = ["PRI", "UNI", "AGG"] static func columnsByTable(from rows: [[PluginCellValue]]) -> [String: [PluginColumnInfo]] { @@ -117,12 +105,15 @@ nonisolated internal enum StarRocksCatalog { let name = row[safe: 1]?.asText, let dataType = row[safe: 2]?.asText else { continue } + let isNullable = row[safe: 3]?.asText == "YES" columns[table, default: []].append(PluginColumnInfo( name: name, dataType: dataType.uppercased(), - isNullable: row[safe: 3]?.asText == "YES", + isNullable: isNullable, isPrimaryKey: rowIdentifyingKeys.contains(row[safe: 4]?.asText ?? ""), - defaultValue: row[safe: 5]?.asText, + defaultValue: mysqlColumnDefault( + .bare(row[safe: 5]?.asText), extra: nil, dataType: dataType, isNullable: isNullable + ), comment: row[safe: 6]?.asText?.nilIfEmpty )) } diff --git a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift index 500dcdf214..f9424ed5f5 100644 --- a/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -8,51 +8,63 @@ import TableProPluginKit import Testing struct StarRocksCatalogTests { - @Test("The Database field reads as a catalog and a database, a bare name, or nothing", arguments: [ - ("glue_iceberg_rest.lakehouse_events", StarRocksCatalog.ConfiguredTarget.qualified( - catalog: "glue_iceberg_rest", database: "lakehouse_events" - )), - ("default_catalog.sales", .qualified(catalog: "default_catalog", database: "sales")), - ("hive.", .qualified(catalog: "hive", database: "")), - ("sales", .bareName("sales")), - (".sales", .bareName("sales")), - (" sales ", .bareName("sales")), - ("", .serverDefault), - (" ", .serverDefault), + @Test("The Database field reads as catalog.database, and a bare name as a database in default_catalog", arguments: [ + ("glue_iceberg_rest.lakehouse_events", "glue_iceberg_rest", "lakehouse_events"), + ("default_catalog.sales", "default_catalog", "sales"), + ("hive.", "hive", ""), + ("sales", "default_catalog", "sales"), + (".sales", "default_catalog", "sales"), + (" sales ", "default_catalog", "sales"), + ("iceberg.ns.events", "iceberg", "ns.events"), + ("", "default_catalog", ""), + (" ", "default_catalog", ""), ]) - func configuredTarget(text: String, expected: StarRocksCatalog.ConfiguredTarget) { - #expect(StarRocksCatalog.target(fromConfiguredDatabase: text) == expected) + func configuredScope(text: String, catalog: String, database: String) { + #expect(StarRocksCatalog.Scope(configuredDatabase: text) == StarRocksCatalog.Scope(catalog: catalog, database: database)) } - @Test("Only the first dot splits, so a database name keeps any dot after it") - func firstDotSplits() { - #expect(StarRocksCatalog.target(fromConfiguredDatabase: "iceberg.ns.events") - == .qualified(catalog: "iceberg", database: "ns.events")) + // Measured on 4.1.7: USE `default_catalog.idb` enters both, USE `default_catalog`.`idb` fails with 5501. + @Test("A scope is entered in one statement, with catalog.database quoted as one name") + func enterStatements() { + #expect(StarRocksCatalog.Scope(catalog: "hive", database: "").enterStatement == "SET CATALOG `hive`") + #expect(StarRocksCatalog.Scope(catalog: "ice", database: "idb").enterStatement == "USE `ice.idb`") + #expect(StarRocksCatalog.Scope(catalog: "ice", database: "lake`events").enterStatement == "USE `ice.lake``events`") } - @Test("Catalog and database switches quote the name") - func statementsQuote() { - #expect(StarRocksCatalog.setCatalog("glue_iceberg_rest") == "SET CATALOG `glue_iceberg_rest`") - #expect(StarRocksCatalog.useDatabase("lake`events") == "USE `lake``events`") + // Measured on 4.1.7: SET CATALOG clears the database even when the catalog does not change. + @Test("Entering the same catalog keeps the database, and another catalog leaves none") + func enteringCatalog() { + let scope = StarRocksCatalog.Scope(catalog: "ice", database: "idb") + #expect(scope.entering(catalog: "ice") == scope) + #expect(scope.entering(catalog: "default_catalog") == StarRocksCatalog.Scope(catalog: "default_catalog", database: "")) + } + + @Test("Only default_catalog keeps table status, and only an empty default_catalog scope needs no statement") + func scopeQuestions() { + #expect(StarRocksCatalog.Scope(catalog: "default_catalog", database: "idb").keepsTableStatus) + #expect(!StarRocksCatalog.Scope(catalog: "ice", database: "idb").keepsTableStatus) + #expect(StarRocksCatalog.Scope(configuredDatabase: "").isServerDefault) + #expect(!StarRocksCatalog.Scope(configuredDatabase: "sales").isServerDefault) + #expect(!StarRocksCatalog.Scope(configuredDatabase: "ice.").isServerDefault) } @Test("Names come from the first column and skip empty cells") func namesFromRows() { let rows: [[PluginCellValue]] = [ [.text("default_catalog"), .text("Internal")], - [.text("glue_iceberg_rest"), .text("Iceberg")], + [.text("ice"), .text("Iceberg")], [.text("")], [], ] - #expect(StarRocksCatalog.names(from: rows) == ["default_catalog", "glue_iceberg_rest"]) + #expect(StarRocksCatalog.names(from: rows) == ["default_catalog", "ice"]) } - @Test("StarRocks has no MySQL object catalogs and its databases are not system databases") + @Test("StarRocks has no MySQL object catalogs and its catalogs are not system databases") func flavorShape() { let flavor = MySQLServerFlavor.starrocks #expect(flavor.isStarRocks) #expect(!flavor.hasMySQLObjectCatalogs) - #expect(MySQLServerFlavor.databend.hasMySQLObjectCatalogs == false) + #expect(!MySQLServerFlavor.databend.hasMySQLObjectCatalogs) #expect(MySQLServerFlavor.mysql.hasMySQLObjectCatalogs) #expect(flavor.systemDatabaseNames.isEmpty) #expect(!flavor.reportsSessionStatusFlags) @@ -60,17 +72,20 @@ struct StarRocksCatalogTests { #expect(flavor.maintenanceOperations.isEmpty) } - /// Iceberg VARIANT and JSON columns arrive as charset 63 with `BINARY_FLAG`, and read as hex without this. - @Test("StarRocks binary-labelled strings decode as text when they are UTF-8") - func binaryLabelledStringsAreText() { - #expect(MySQLServerFlavor.starrocks.labelsTextResultsAsBinary) - #expect(!MySQLServerFlavor.mysql.labelsTextResultsAsBinary) - let varString: UInt32 = 253 - let decoding = MySQLColumnDecoding( - typeRaw: varString, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, + // Measured on 4.1.7: VARBINARY and CAST AS VARBINARY arrive as BLOB (252) with charset 63. + @Test("A charset 63 string reads as text when it is UTF-8, and a BLOB stays bytes") + func binaryLabelledStrings() { + let textOrBytes = MySQLColumnDecoding( + typeRaw: 253, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, binaryStringsAreText: MySQLServerFlavor.starrocks.labelsTextResultsAsBinary ) - #expect(decoding == .utf8TextOrBytes) + let blob = MySQLColumnDecoding( + typeRaw: 252, charsetnr: 63, characterSetName: "binary", flavor: .starrocks, + binaryStringsAreText: MySQLServerFlavor.starrocks.labelsTextResultsAsBinary + ) + #expect(textOrBytes == .utf8TextOrBytes) + #expect(blob == .bytes) + #expect(!MySQLServerFlavor.mysql.labelsTextResultsAsBinary) } @Test("StarRocks begins with BEGIN whatever the access mode") @@ -79,6 +94,7 @@ struct StarRocksCatalogTests { #expect(MySQLServerFlavor.starrocks.beginTransactionStatement(mode: .serverDefault) == "BEGIN") } + // Measured on 4.1.7: `SET query_timeout = 259201` fails with "must be equal or smaller than 259200". @Test("The query timeout is capped at StarRocks's maximum, which also stands in for no limit", arguments: [ (30, "SET query_timeout = 30"), (300_000, "SET query_timeout = 259200"), @@ -90,68 +106,72 @@ struct StarRocksCatalogTests { @Test("The StarRocks connection type starts as StarRocks before the probe") func initialFlavor() { - let config = DriverConnectionConfig( - host: "localhost", port: 9_030, username: "root", password: "", database: "", - additionalFields: ["driverVariant": MySQLServerFlavor.starrocksVariant] - ) - #expect(MySQLPluginDriver.initialFlavor(for: config) == .starrocks) + #expect(MySQLPluginDriver.initialFlavor(for: Self.config(database: "")) == .starrocks) } - @Test("A qualified Database field is held as the catalog and the database to enter") - func driverHoldsQualifiedScope() { - let driver = MySQLPluginDriver(config: Self.config(database: "glue_iceberg_rest.lakehouse_events")) - #expect(driver.activeCatalogName == "glue_iceberg_rest") - #expect(driver.activeDatabaseName == "lakehouse_events") - } + @Test("The driver reports the catalog as its database and the database inside it as its schema") + func driverScope() { + let qualified = MySQLPluginDriver(config: Self.config(database: "glue.lakehouse")) + #expect(qualified.currentDatabase == "glue") + #expect(qualified.currentSchema == "lakehouse") - @Test("A bare name waits for the server to settle whether it is a catalog") - func driverHoldsBareName() { - let driver = MySQLPluginDriver(config: Self.config(database: "sales")) - #expect(driver.activeCatalogName == nil) - #expect(driver.activeDatabaseName == "sales") + let bare = MySQLPluginDriver(config: Self.config(database: "sales")) + #expect(bare.currentDatabase == "default_catalog") + #expect(bare.currentSchema == "sales") + + let catalogOnly = MySQLPluginDriver(config: Self.config(database: "ice.")) + #expect(catalogOnly.currentDatabase == "ice") + #expect(catalogOnly.currentSchema == nil) } - @Test("A MySQL connection keeps a dotted name whole") + @Test("A MySQL connection keeps a dotted name whole and reports no schema level") func mysqlKeepsDottedName() { let driver = MySQLPluginDriver(config: DriverConnectionConfig( host: "localhost", port: 3_306, username: "root", password: "", database: "a.b" )) - #expect(driver.activeCatalogName == nil) #expect(driver.activeDatabaseName == "a.b") + #expect(driver.currentDatabase == nil) + #expect(driver.currentSchema == nil) } - /// `information_schema.columns` rows as StarRocks 4.1.7 answers them, one table per key model. + // `information_schema.columns` rows as 4.1.7 answers them, one table per key model. @Test("A primary, unique or aggregate key picks out a row, and a duplicate key does not") func keyModels() { let row: (String, String, String, String, String) -> [PluginCellValue] = { table, column, type, nullable, key in [.text(table), .text(column), .text(type), .text(nullable), .text(key), .null, .text("")] } let columns = StarRocksCatalog.columnsByTable(from: [ - row("pk", "id", "bigint(20)", "NO", "PRI"), - row("pk", "v", "varchar(20)", "YES", ""), - row("uq", "a", "int(11)", "YES", "UNI"), - row("agg", "a", "int(11)", "YES", "AGG"), - row("dup", "a", "int(11)", "YES", "DUP"), + row("pk", "id", "int(11)", "NO", "PRI"), + row("pk", "s", "varchar(20)", "YES", ""), + row("uniq", "id", "int(11)", "YES", "UNI"), + row("agg", "id", "int(11)", "YES", "AGG"), + row("dup", "id", "int(11)", "YES", "DUP"), ]) #expect(columns["pk"]?.map(\.isPrimaryKey) == [true, false]) #expect(columns["pk"]?.first?.isNullable == false) - #expect(columns["pk"]?.first?.dataType == "BIGINT(20)") + #expect(columns["pk"]?.first?.dataType == "INT(11)") #expect(columns["pk"]?.first?.comment == nil) - #expect(columns["pk"]?.first?.defaultValue == nil) - #expect(columns["uq"]?.first?.isPrimaryKey == true) + #expect(columns["uniq"]?.first?.isPrimaryKey == true) #expect(columns["agg"]?.first?.isPrimaryKey == true) #expect(columns["dup"]?.first?.isPrimaryKey == false) } - @Test("A column keeps its default and comment") - func columnDetails() { + // 4.1.7 reports `VARCHAR DEFAULT "abc"` as abc, `BIGINT DEFAULT "0"` as 0, and no default as NULL. + @Test("A column default reads as the SQL that follows DEFAULT") + func columnDefaults() { + let row: (String, String, String, PluginCellValue) -> [PluginCellValue] = { column, type, nullable, value in + [.text("dup"), .text(column), .text(type), .text(nullable), .text(""), value, .text("")] + } let columns = StarRocksCatalog.columnsByTable(from: [ + row("s", "varchar(20)", "YES", .text("abc")), + row("c", "bigint(20)", "YES", .text("0")), + row("ts", "datetime", "YES", .text("CURRENT_TIMESTAMP")), + row("n", "int(11)", "YES", .null), + row("k", "int(11)", "NO", .null), [.text("cm"), .text("id"), .text("int(11)"), .text("NO"), .text("PRI"), .null, .text("the id")], - [.text("cm"), .text("d"), .text("datetime"), .text("YES"), .text(""), .text("CURRENT_TIMESTAMP"), .text("")], ]) - #expect(columns["cm"]?.map(\.name) == ["id", "d"]) + #expect(columns["dup"]?.map(\.defaultValue) == ["'abc'", "0", "CURRENT_TIMESTAMP", "NULL", nil]) #expect(columns["cm"]?.first?.comment == "the id") - #expect(columns["cm"]?.last?.defaultValue == "CURRENT_TIMESTAMP") } @Test("Catalog reads escape the names they filter on") @@ -172,34 +192,30 @@ struct StarRocksCatalogTests { #expect(marked.map(\.type) == ["TABLE", "MATERIALIZED VIEW", "VIEW"]) } - /// `SHOW TABLE STATUS` rows as StarRocks 4.1.7 answers them: MySQL's positions, a view at -1, - /// and no index length. + // `SHOW TABLE STATUS` rows as 4.1.7 answers them: a view at -1, and Index_length NULL. @Test("Table status reads the data length as the whole size, and a view's -1 as unknown") func tableStatus() { - let table = Self.statusRow(name: "pk", engine: "StarRocks", rows: "42", avgRowLength: "12", dataLength: "504") - let metadata = StarRocksCatalog.tableMetadata(fromStatusRow: table, tableName: "pk") - #expect(metadata.rowCount == 42) - #expect(metadata.dataSize == 504) - #expect(metadata.totalSize == 504) + let table = Self.statusRow(name: "dup", engine: "StarRocks", rows: "2", dataLength: "2148") + let metadata = MySQLTableStatusRow.metadata(from: table, tableName: "dup", reportsIndexLength: false) + #expect(metadata.rowCount == 2) + #expect(metadata.dataSize == 2_148) + #expect(metadata.totalSize == 2_148) #expect(metadata.indexSize == nil) #expect(metadata.engine == "StarRocks") #expect(metadata.collation == "utf8_general_ci") - #expect(StarRocksCatalog.rowCount(fromStatusRow: table) == 42) - let view = Self.statusRow(name: "v1", engine: nil, rows: "-1", avgRowLength: "-1", dataLength: "-1") - let viewMetadata = StarRocksCatalog.tableMetadata(fromStatusRow: view, tableName: "v1") + let view = Self.statusRow(name: "v1", engine: nil, rows: "-1", dataLength: "-1") + let viewMetadata = MySQLTableStatusRow.metadata(from: view, tableName: "v1", reportsIndexLength: false) #expect(viewMetadata.rowCount == nil) #expect(viewMetadata.dataSize == nil) + #expect(viewMetadata.totalSize == nil) #expect(viewMetadata.engine == nil) - #expect(StarRocksCatalog.rowCount(fromStatusRow: view) == nil) } - private static func statusRow( - name: String, engine: String?, rows: String, avgRowLength: String, dataLength: String - ) -> [PluginCellValue] { + private static func statusRow(name: String, engine: String?, rows: String, dataLength: String) -> [PluginCellValue] { [ - .text(name), engine.map { .text($0) } ?? .null, .null, .text(""), .text(rows), .text(avgRowLength), - .text(dataLength), .null, .null, .null, .null, .text("2026-10-31 15:19:44"), .text("2026-10-31 15:19:44"), + .text(name), engine.map { .text($0) } ?? .null, .null, .text(""), .text(rows), .text(rows), + .text(dataLength), .null, .null, .null, .null, .text("2026-10-31 19:56:50"), .text("2026-10-31 19:58:00"), .null, .text("utf8_general_ci"), .null, .text(""), .text(""), ] } diff --git a/apple/Plugins/StarRocksDriverPlugin/Info.plist b/apple/Plugins/StarRocksDriverPlugin/Info.plist new file mode 100644 index 0000000000..259ec20952 --- /dev/null +++ b/apple/Plugins/StarRocksDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + StarRocks + + + diff --git a/apple/Plugins/StarRocksDriverPlugin/StarRocksPlugin.swift b/apple/Plugins/StarRocksDriverPlugin/StarRocksPlugin.swift new file mode 100644 index 0000000000..202f21c7b0 --- /dev/null +++ b/apple/Plugins/StarRocksDriverPlugin/StarRocksPlugin.swift @@ -0,0 +1,65 @@ +import Foundation +import TableProPluginKit + +final class StarRocksPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "StarRocks Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "StarRocks support via libmariadb" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "StarRocks" + static let databaseDisplayName = "StarRocks" + static let iconName = "starrocks-icon" + static let defaultPort = 9_030 + static let isDownloadable = true + static let urlSchemes = ["starrocks"] + static let brandColorHex = "#01808F" + static let additionalConnectionFields = MySQLPlugin.additionalConnectionFields.filter { + $0.id == "mysqlIdleReleaseMinutes" + } + + static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession, .selectSchemaFromLastSession] + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let containerEntityName = "Catalog" + static let defaultGroupName = "default" + static let defaultSchemaName = "" + static let systemSchemaNames = ["_statistics_", "information_schema", "sys"] + static let supportsSchemaSwitching = true + static let sqlDialect = MySQLPlugin.sqlDialect + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] + + // Measured on 4.1.7. `EXPLAIN ANALYZE` colors its report with terminal escape codes. + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), + ExplainVariant(id: "logical", label: "EXPLAIN LOGICAL", sqlPrefix: "EXPLAIN LOGICAL", format: .plainText), + ExplainVariant(id: "verbose", label: "EXPLAIN VERBOSE", sqlPrefix: "EXPLAIN VERBOSE", format: .plainText), + ExplainVariant(id: "costs", label: "EXPLAIN COSTS", sqlPrefix: "EXPLAIN COSTS", format: .plainText), + ] + + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT", "LARGEINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL"], + "String": ["CHAR", "VARCHAR", "STRING"], + "Date": ["DATE", "DATETIME"], + "Binary": ["BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN"], + "Semi-structured": ["JSON", "ARRAY", "MAP", "STRUCT"], + "Aggregate": ["BITMAP", "HLL", "PERCENTILE"], + ] + + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsImport = false + static let supportsForeignKeyDisable = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + + static func driverVariant(for databaseTypeId: String) -> String? { + databaseTypeId == Self.databaseTypeId ? databaseTypeId : nil + } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + MySQLPluginDriver(config: config) + } +} diff --git a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift index c4772a5f64..7c584aff47 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/apple/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, .oceanbase, .starrocks, + .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, diff --git a/apple/TablePro/Core/Database/DatabaseManager+Health.swift b/apple/TablePro/Core/Database/DatabaseManager+Health.swift index dad575f0b0..d5169a470d 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Health.swift @@ -346,19 +346,20 @@ extension DatabaseManager { savedSchema: String?, savedDatabase: String? ) async { - if let savedSchema, let schemaDriver = driver as? SchemaSwitchable { + // The schema lives inside the database, and a database switch can leave the driver on none. + if let savedDatabase, let adapter = driver as? PluginDriverAdapter { do { - try await schemaDriver.switchSchema(to: savedSchema) + try await adapter.switchDatabase(to: savedDatabase) } catch { - Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)") + Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)") } } - if let savedDatabase, let adapter = driver as? PluginDriverAdapter { + if let savedSchema, let schemaDriver = driver as? SchemaSwitchable { do { - try await adapter.switchDatabase(to: savedDatabase) + try await schemaDriver.switchSchema(to: savedSchema) } catch { - Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)") + Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)") } } } diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index 1d29a9db82..e845a66cf1 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -442,8 +442,10 @@ extension DatabaseManager { } } else if driver is PluginDriverAdapter { let grouping = pm?.schema.databaseGroupingStrategy ?? .byDatabase + let browsesSchemas = grouping == .bySchema || grouping == .hierarchicalSchema let sessionStartedAt = session(for: connectionId)?.connectedAt - let adapter = try await sessionDriverGate.withExclusiveAccess(connectionId) { + // Read inside the gate: a pinned operation queued behind the switch can move the driver. + let schema = try await sessionDriverGate.withExclusiveAccess(connectionId) { try await trackOperation(sessionId: connectionId) { try Task.checkCancellation() guard session(for: connectionId)?.connectedAt == sessionStartedAt else { @@ -456,18 +458,17 @@ extension DatabaseManager { if grouping == .bySchema { await resetSchema(on: adapter, to: pm?.schema.defaultSchemaName) } - return adapter + return adapter.currentSchema } } updateSession(connectionId) { session in session.browseDatabase = database - if grouping == .bySchema { - session.browseSchema = adapter.currentSchema - } else if grouping == .hierarchicalSchema, adapter.currentSchema == nil { - // The old schema may not exist in the new catalog; keeping it pins pooled - // metadata connections to it and every read fails. - session.browseSchema = nil + if browsesSchemas { + session.browseSchema = schema + } } + if persist, browsesSchemas { + appSettingsStorage.saveLastSchema(schema, for: connectionId) } } diff --git a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift index a05f015296..549259fa11 100644 --- a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift +++ b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift @@ -175,7 +175,7 @@ extension DatabaseType { Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. \ Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon. """) - case "KingbaseES": + case "KingbaseES", "StarRocks": return String(localized: "Preferred tries TLS first, falls back to plain.") case _ where pluginTypeId == "MySQL": return String(localized: """ diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index aac4791113..9f88679c97 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -20,7 +20,7 @@ enum ImportTypeMapper { for: databaseType, serverVersion: serverVersion ) ) - case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: + case .mysql, .mariadb, .tidb, .oceanbase: return mysqlType(type) case .sqlite: return sqliteType(type) diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index b194fc5c04..33083a0e0a 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -717,6 +717,7 @@ extension PluginMetadataRegistry { return defaults + mysqlVariantDefaults( dialect: mysqlDialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: mysqlIdleReleaseField ) + defaults.filter { $0.typeId == "PostgreSQL" }.flatMap { kingbaseESDefaults(postgreSQL: $0.snapshot) } + + starRocksDefaults(dialect: mysqlDialect, idleReleaseField: mysqlIdleReleaseField) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 041467c7af..6201c6ac76 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -23,15 +23,6 @@ extension PluginMetadataRegistry { ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), ] - /// Measured on StarRocks 4.1.7. No `EXPLAIN ANALYZE`: it colors its report with terminal escape - /// codes, which a plain-text plan shows as `[0m` throughout. - static let starrocksExplainVariants: [ExplainVariant] = [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), - ExplainVariant(id: "logical", label: "EXPLAIN LOGICAL", sqlPrefix: "EXPLAIN LOGICAL", format: .plainText), - ExplainVariant(id: "verbose", label: "EXPLAIN VERBOSE", sqlPrefix: "EXPLAIN VERBOSE", format: .plainText), - ExplainVariant(id: "costs", label: "EXPLAIN COSTS", sqlPrefix: "EXPLAIN COSTS", format: .plainText), - ] - /// A grid reads every cell back as text and a keyless save compares that text to the stored /// value. Measured on MySQL 8.4.11 and OceanBase CE 4.4.2.1, on these three types it does not /// compare equal to what it was read from, so the save matched no row and was reported as a @@ -72,96 +63,9 @@ extension PluginMetadataRegistry { tidbVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), databendVariant(dialect: dialect, idleReleaseField: idleReleaseField), oceanbaseVariant(dialect: dialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: idleReleaseField), - starrocksVariant(dialect: dialect, idleReleaseField: idleReleaseField), ] } - static let starrocksColumnTypes: [String: [String]] = [ - "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT", "LARGEINT"], - "Float": ["FLOAT", "DOUBLE", "DECIMAL"], - "String": ["CHAR", "VARCHAR", "STRING"], - "Date": ["DATE", "DATETIME"], - "Binary": ["BINARY", "VARBINARY"], - "Boolean": ["BOOLEAN"], - "Semi-structured": ["JSON", "ARRAY", "MAP", "STRUCT"], - "Aggregate": ["BITMAP", "HLL", "PERCENTILE"], - ] - - static let starrocksRowMatchExcludedTypePrefixes = [ - "ARRAY", "MAP", "STRUCT", "JSON", "BITMAP", "HLL", "PERCENTILE", "BINARY", "VARBINARY", - ] - - /// Catalogs fill the database level and a catalog's databases the schema level, the shape Trino - /// has. The Database field takes `catalog.database`, as StarRocks's own clients write it. - private static func starrocksVariant( - dialect: SQLDialectDescriptor, - idleReleaseField: ConnectionField - ) -> (typeId: String, snapshot: PluginMetadataSnapshot) { - ("StarRocks", PluginMetadataSnapshot( - displayName: "StarRocks", iconName: "starrocks-icon", defaultPort: 9_030, - requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: starrocksExplainVariants, pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: [], - postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], - brandColorHex: "#01808F", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - structureEditing: SchemaEditingSupport(columnReorder: .unsupported, foreignKeyEdit: .unsupported), - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: false, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsRenameTable: false, - supportsRenameView: false, - supportsRenameColumn: false, - supportsAddIndex: false, - supportsDropIndex: false, - supportsModifyPrimaryKey: false, - supportsTriggers: false, - supportsTriggerEditing: false, - supportsCheckConstraints: false, - supportsCheckConstraintEditing: false, - supportsGeneratedColumns: false, - supportsRoutines: false, - supportsDatabaseTriggerBrowse: false, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "", - defaultGroupName: "default", - tableEntityName: "Tables", - containerEntityName: "Catalog", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: ["_statistics_", "information_schema", "sys"], - fileExtensions: [], - databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment], - rowMatchExcludedTypePrefixes: starrocksRowMatchExcludedTypePrefixes - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: dialect, - statementCompletions: [], - columnTypesByCategory: starrocksColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [idleReleaseField], - category: .analytical, - tagline: String(localized: "Lakehouse analytics, MySQL protocol") - ) - )) - } - private static func tidbVariant( dialect: SQLDialectDescriptor, mysqlColumnTypes: [String: [String]], diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+StarRocksDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+StarRocksDefaults.swift new file mode 100644 index 0000000000..6beaa1e230 --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+StarRocksDefaults.swift @@ -0,0 +1,106 @@ +// +// PluginMetadataRegistry+StarRocksDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + static let starRocksExplainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), + ExplainVariant(id: "logical", label: "EXPLAIN LOGICAL", sqlPrefix: "EXPLAIN LOGICAL", format: .plainText), + ExplainVariant(id: "verbose", label: "EXPLAIN VERBOSE", sqlPrefix: "EXPLAIN VERBOSE", format: .plainText), + ExplainVariant(id: "costs", label: "EXPLAIN COSTS", sqlPrefix: "EXPLAIN COSTS", format: .plainText), + ] + + static let starRocksColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT", "LARGEINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL"], + "String": ["CHAR", "VARCHAR", "STRING"], + "Date": ["DATE", "DATETIME"], + "Binary": ["BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN"], + "Semi-structured": ["JSON", "ARRAY", "MAP", "STRUCT"], + "Aggregate": ["BITMAP", "HLL", "PERCENTILE"], + ] + + static let starRocksRowMatchExcludedTypePrefixes = [ + "ARRAY", "MAP", "STRUCT", "JSON", "BITMAP", "HLL", "PERCENTILE", "BINARY", "VARBINARY", + ] + + // Measured on 4.1.7: `f = 1.1` misses a FLOAT holding 1.1, `CONCAT(f) = '1.1'` finds it. + static let starRocksRowMatchTextTypePrefixes = ["FLOAT"] + + static func starRocksDefaults( + dialect: SQLDialectDescriptor, + idleReleaseField: ConnectionField + ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [("StarRocks", PluginMetadataSnapshot( + displayName: "StarRocks", iconName: "starrocks-icon", defaultPort: 9_030, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "starrocks", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: starRocksExplainVariants, pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["starrocks"], + postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], + brandColorHex: "#01808F", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + structureEditing: SchemaEditingSupport(columnReorder: .unsupported, foreignKeyEdit: .unsupported), + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameTable: false, + supportsRenameView: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsTriggers: false, + supportsTriggerEditing: false, + supportsCheckConstraints: false, + supportsCheckConstraintEditing: false, + supportsGeneratedColumns: false, + supportsRoutines: false, + supportsDatabaseTriggerBrowse: false, + defaultSSLMode: .preferred, + // The Database field is `catalog.database`, so a pooled connection keeps it and switches after. + authenticationIsDatabaseScoped: true + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Tables", + containerEntityName: "Catalog", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: ["_statistics_", "information_schema", "sys"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment], + rowMatchExcludedTypePrefixes: starRocksRowMatchExcludedTypePrefixes, + rowMatchTextTypePrefixes: starRocksRowMatchTextTypePrefixes + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: starRocksColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [idleReleaseField], + category: .analytical, + tagline: String(localized: "Lakehouse analytics, MySQL protocol") + ) + ))] + } +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 3622be46ad..bb72c1db7e 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -506,7 +506,6 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["TiDB"] = "MySQL" reverseTypeIndex["Databend"] = "MySQL" reverseTypeIndex["OceanBase"] = "MySQL" - reverseTypeIndex["StarRocks"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" reverseTypeIndex["PGlite"] = "PostgreSQL" diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index 1c7b50ede7..d46a043edc 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -62,7 +62,7 @@ enum CellExpressionVocabulary { case .postgresql, .pglite, .kingbasees: postgreSQL case .cockroachdb: cockroachDB case .redshift: redshift - case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: mySQL + case .mysql, .mariadb, .tidb, .oceanbase: mySQL case .databend: databend case .sqlite, .libsql, .turso, .cloudflareD1: sqlite case .duckdb: duckDB diff --git a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift index cc745f2a2d..ace0be0dad 100644 --- a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -44,7 +44,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: false, allowsOmittedReferencedColumns: true ) - case .mysql, .mariadb, .tidb, .oceanbase, .starrocks: + case .mysql, .mariadb, .tidb, .oceanbase: return ForeignKeyDialect( deleteActions: [.noAction, .restrict, .cascade, .setNull], updateActions: [.noAction, .restrict, .cascade, .setNull], diff --git a/apple/TableProTests/Core/Database/DatabaseManagerTests.swift b/apple/TableProTests/Core/Database/DatabaseManagerTests.swift index fb0af467ff..1d05998bfb 100644 --- a/apple/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -310,6 +310,7 @@ private class DatabaseSwitchBaseDriver { private final class DatabaseSwitchingDriver: DatabaseSwitchBaseDriver, PluginDatabaseDriver, @unchecked Sendable { private(set) var switchedDatabases: [String] = [] + private(set) var switches: [String] = [] private var schema: String? override var currentSchema: String? { schema } @@ -321,10 +322,12 @@ private final class DatabaseSwitchingDriver: DatabaseSwitchBaseDriver, PluginDat func switchDatabase(to database: String) async throws { switchedDatabases.append(database) + switches.append("database \(database)") } func switchSchema(to schema: String) async throws { self.schema = schema + switches.append("schema \(schema)") } } @@ -364,9 +367,8 @@ struct DatabaseManagerDatabaseSwitchTests { #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == adapter.currentSchema) } - /// StarRocks: `idb` lives in the `ice` catalog, and pinning it to a `default_catalog` - /// connection ran `USE idb` there, which failed every metadata read. - @Test("A catalog switch that leaves the driver on no schema leaves the session on none") + // `idb` lives in the `ice` catalog; kept, the pool ran `USE idb` in default_catalog and every read failed. + @Test("A catalog switch that leaves the driver on no schema leaves the session and the saved schema on none") func hierarchicalSwitchDropsSchemaTheDriverLeft() async throws { let connection = TestFixtures.makeConnection(type: .starrocks) let pluginDriver = DatabaseSwitchingDriver(currentSchema: nil) @@ -374,13 +376,19 @@ struct DatabaseManagerDatabaseSwitchTests { var session = ConnectionSession(connection: connection, driver: adapter) session.browseSchema = "idb" DatabaseManager.shared.injectSession(session, for: connection.id) - defer { DatabaseManager.shared.removeSession(for: connection.id) } + AppSettingsStorage.shared.saveLastSchema("idb", for: connection.id) + defer { + DatabaseManager.shared.removeSession(for: connection.id) + AppSettingsStorage.shared.saveLastSchema(nil, for: connection.id) + AppSettingsStorage.shared.saveLastDatabase(nil, for: connection.id) + } - try await DatabaseManager.shared.switchDatabase(to: "default_catalog", for: connection.id, persist: false) + try await DatabaseManager.shared.switchDatabase(to: "default_catalog", for: connection.id, persist: true) let updated = DatabaseManager.shared.session(for: connection.id) #expect(updated?.browseDatabase == "default_catalog") #expect(updated?.browseSchema == nil) + #expect(AppSettingsStorage.shared.loadLastSchema(for: connection.id) == nil) } @Test("A catalog switch that keeps the driver's schema keeps the session's") @@ -397,4 +405,15 @@ struct DatabaseManagerDatabaseSwitchTests { #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == "web") } + + @Test("A reconnect enters the database before the schema that lives in it") + func reconnectRestoresDatabaseThenSchema() async { + let connection = TestFixtures.makeConnection(type: .starrocks) + let pluginDriver = DatabaseSwitchingDriver() + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + + await DatabaseManager.shared.restoreSchemaAndDatabase(on: adapter, savedSchema: "idb", savedDatabase: "ice") + + #expect(pluginDriver.switches == ["database ice", "schema idb"]) + } } diff --git a/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift b/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift index fcb3f71317..4ccdd7da4d 100644 --- a/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift +++ b/apple/TableProTests/Core/Plugins/MySQLProtocolVariantParityTests.swift @@ -10,7 +10,7 @@ import Testing @MainActor struct MySQLProtocolVariantParityTests { - nonisolated private static let variants: [DatabaseType] = [.mariadb, .tidb, .databend, .oceanbase, .starrocks] + 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") @@ -68,7 +68,6 @@ struct MySQLProtocolVariantParityTests { "information_schema", "performance_schema" ], .databend: ["information_schema", "system"], - .starrocks: [], .oceanbase: [ "information_schema", "mysql", "oceanbase", "__recyclebin", "__public", "SYS", "LBACSYS", "ORAAUDITOR" ] diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryStarRocksTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryStarRocksTests.swift new file mode 100644 index 0000000000..c82b0b9db1 --- /dev/null +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryStarRocksTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PluginMetadataRegistryStarRocksTests { + @Test("StarRocks is offered before installation and resolves to its own registry plugin") + func registryIdentity() throws { + let registry = PluginMetadataRegistry.shared + #expect(registry.allRegisteredTypeIds().contains("StarRocks")) + #expect(registry.pluginTypeId(for: "StarRocks") == "StarRocks") + #expect(DatabaseType.starrocks.pluginTypeId == "StarRocks") + #expect(DatabaseType.starrocks.isDownloadablePlugin) + let snapshot = try #require(registry.snapshot(forRegisteredTypeId: "StarRocks")) + #expect(snapshot.defaultPort == 9_030) + #expect(snapshot.schema.databaseGroupingStrategy == .hierarchicalSchema) + #expect(snapshot.capabilities.supportsSchemaSwitching) + #expect(!snapshot.supportsForeignKeys) + } + + @Test("A pooled StarRocks connection keeps the catalog.database field and switches after connecting") + func poolKeepsTheConfiguredScope() { + #expect(DatabaseType.starrocks.authenticationIsDatabaseScoped) + let plan = MetadataConnectionPool.planConnection( + configuredDatabase: "glue.lakehouse", + targetDatabase: "ice", + authenticationIsDatabaseScoped: DatabaseType.starrocks.authenticationIsDatabaseScoped + ) + #expect(plan == MetadataConnectionPool.ConnectionPlan(connectDatabase: "glue.lakehouse", switchDatabase: "ice")) + } + + @Test("A keyless match compares FLOAT through its text and refuses the types it cannot compare") + func rowMatching() throws { + let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "StarRocks")) + #expect(snapshot.schema.rowMatchTextTypePrefixes == ["FLOAT"]) + #expect(snapshot.schema.rowMatchExcludedTypePrefixes.contains("VARBINARY")) + } + + @Test("A DELETE on a duplicate key table reports 0 rows, so a keyless save is not held to its count") + func rowCountsAreNotMeaningful() { + #expect(!DataWriteRowCounts.areMeaningful(for: .starrocks)) + } + + @Test("A StarRocks URL keeps the catalog.database path and routes to StarRocks") + func connectionURL() throws { + let parsed = try ConnectionURLParser.parse("starrocks://root:pass@fe.local:19030/glue.lakehouse").get() + #expect(parsed.type == .starrocks) + #expect(parsed.host == "fe.local") + #expect(parsed.port == 19_030) + #expect(parsed.database == "glue.lakehouse") + #expect(parsed.username == "root") + } +} diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index a565d65e1f..83a18a960b 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -34,7 +34,6 @@ struct PluginMetadataRegistryTypeCountTests { "TiDB": "MySQL", "Databend": "MySQL", "OceanBase": "MySQL", - "StarRocks": "MySQL", "Redshift": "PostgreSQL", "CockroachDB": "PostgreSQL", "PGlite": "PostgreSQL", diff --git a/apple/changelog.d/3939.added.md b/apple/changelog.d/3939.added.md index a408b4112a..13786ef870 100644 --- a/apple/changelog.d/3939.added.md +++ b/apple/changelog.d/3939.added.md @@ -1 +1 @@ -Connect to StarRocks and browse its external catalogs, such as Iceberg, alongside default_catalog. +Connect to StarRocks with a downloadable driver, and browse its external catalogs, such as Iceberg, alongside default_catalog. diff --git a/apple/project.yml b/apple/project.yml index cbf751b9d2..d82af2b55d 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1187,6 +1187,17 @@ targets: - package: TableProCore product: TableProNumberFormatting + StarRocksDriverPlugin: + templates: [DriverPlugin, MySQLLinkage] + templateAttributes: + folder: StarRocksDriverPlugin + principalClass: StarRocksPlugin + sources: + - path: Plugins/MySQLDriverPlugin + excludes: + - Info.plist + - README.md + SurrealDBDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -1308,6 +1319,7 @@ aggregateTargets: - ParquetExport - SnowflakeDriverPlugin - SpannerDriverPlugin + - StarRocksDriverPlugin - SurrealDBDriverPlugin - TeradataDriver - TrinoDriverPlugin