diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 49dbf6ef8c..f6f39ddbb2 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -70,6 +70,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 bb002a60ef..cfa5a5fb91 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" diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index cd65a2a312..d749954aae 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -266,6 +266,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { "MariaDB": mySQLFamily, "TiDB": mySQLCompatible, "OceanBase": mySQLCompatible, + "StarRocks": mySQLCompatible, "Databend": SQLLexicalProfile( grammar: databend, undetermined: [ 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..c125adbab9 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 == "StarRocks") #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 805465c15f..5ccd8bf9df 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/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/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/MySQLPluginDriver+BulkMetadata.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index 5fee769baa..9ac9e58ffb 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 try await starrocksTableStatus(database: routineSchema(schema)) } 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+CreateDatabase.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift index 9d0a0e4c44..a47f1a5e68 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CreateDatabase.swift @@ -6,6 +6,8 @@ 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, which a charset form cannot 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+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..252b48ee32 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 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) } @@ -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..57fdf6a758 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -54,6 +54,9 @@ 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))") let catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) let createTableDefaults = try await createTableDefaults( @@ -323,7 +326,8 @@ internal extension MySQLPluginDriver { } } - var providesBulkColumnFetch: Bool { true } + // 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 /// that takes the bulk list has to receive what `fetchColumns` would have given it. Without the @@ -332,6 +336,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 starrocksColumns(database: database, table: nil) } 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..bbc2cc4724 --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver+StarRocks.swift @@ -0,0 +1,52 @@ +// +// MySQLPluginDriver+StarRocks.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProMySQLCore +import TableProPluginKit + +extension MySQLPluginDriver { + // No multi-statement transactions before 3.5, and a frontend before 3.2 refuses `COM_STMT_PREPARE`. + static let starrocksCapabilities: PluginCapabilities = [ + .cancelQuery, + ] + + func starrocksTables(database: String) async throws -> [PluginTableInfo] { + let rows = try await execute( + query: MySQLObjectQueries.listedTables(schema: database, hasInformationSchema: true) + ).rows + let tables = MySQLTableListing.tables(from: rows, listsSequencesAsTables: false) + 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) + } + + 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) + } + + 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 d0312506b6..6510311b5a 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -25,6 +25,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { sessionLock.withLock { _activeDatabase } } + // StarRocks only: the catalog `_activeDatabase` lives in, which every reconnect enters again. + private var _activeCatalog: String + + var starrocksScope: StarRocksCatalog.Scope { + sessionLock.withLock { StarRocksCatalog.Scope(catalog: _activeCatalog, database: _activeDatabase) } + } + internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog? /// One verdict per database about whether `information_schema` describes it, learned from the @@ -99,7 +106,16 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") - var currentSchema: String? { nil } + var currentSchema: String? { + guard flavor.isStarRocks else { return nil } + return activeDatabaseName.nilIfEmpty + } + + var currentDatabase: String? { + guard flavor.isStarRocks else { return nil } + return starrocksScope.catalog + } + var serverVersion: String? { sessionLock.withLock { _serverVersion } } /// The banner and the flavor together, taken under one lock. `connect()` writes both in the @@ -113,8 +129,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 +159,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 +189,12 @@ 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._activeDatabase = config.database - self._flavor = Self.initialFlavor(for: config) + self._flavor = flavor + 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 @@ -199,11 +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 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: activeDatabaseName, + database: entersStarRocksScope ? "" : activeDatabaseName, sslConfig: sslConfig, enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", tunnelSecuresTransport: config.additionalFields["tunnelSecuresTransport"] == "true", @@ -234,6 +256,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return true }) try await conn.completeConnect(deadline: connectDeadline) + if entersStarRocksScope { + let scope = starrocksScope + if !scope.isServerDefault { + _ = try await conn.executeConnectQuery(scope.enterStatement, deadline: connectDeadline) + } + } } catch { conn.disconnect() throw error @@ -737,6 +765,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 +813,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 +861,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 +884,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 +892,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,6 +907,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + guard !flavor.isStarRocks else { + return try await starrocksTableStatus(table: table, schema: schema)?.rowCount.map { Int($0) } + } 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) } @@ -916,7 +948,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() @@ -944,7 +976,9 @@ 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`. + 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 @@ -957,6 +991,9 @@ 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 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 { return PluginTableMetadata(tableName: table) @@ -994,10 +1031,18 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Database Operations 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] { @@ -1045,6 +1090,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// it alone let the idle timer fire seconds after a switch and charge the next click a full /// reconnect. func switchDatabase(to database: String) async throws { + guard !flavor.isStarRocks else { + return try await enterStarRocks(starrocksScope.entering(catalog: database)) + } _ = try await executeWithReconnect( query: "USE \(quoteIdentifier(database))", isRetry: false, @@ -1056,6 +1104,21 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + func switchSchema(to schema: String) async throws { + guard flavor.isStarRocks else { return } + try await enterStarRocks(StarRocksCatalog.Scope(catalog: starrocksScope.catalog, database: schema)) + } + + // 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 /// The statement goes in as driver setup, not as use. It is a `SET SESSION`, so counting it @@ -1316,10 +1379,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..ab3e92be0f 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 up 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,11 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { return true } + var isStarRocks: Bool { self == .starrocks } + + // No indexes, foreign keys, triggers, routines, events or partitions to read the MySQL way. + var hasMySQLObjectCatalogs: Bool { !isDatabend && !isStarRocks } + var tidbVersion: MySQLEngineVersion? { guard case .tidb(let version) = self else { return nil } return version @@ -110,6 +120,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 is the catalogs, and none of them is a system one. + return [] } } @@ -118,13 +131,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 +146,14 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { var dropsIdleSessionOnKillQuery: Bool { isTiDB } - var preparesOnServer: Bool { !isDatabend } + // Iceberg VARIANT arrives as a charset 63 string of UTF-8 text; VARBINARY arrives as a BLOB and stays bytes. + var labelsTextResultsAsBinary: Bool { isStarRocks } + + // A StarRocks frontend before 3.2 refuses `COM_STMT_PREPARE`. + 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 +162,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 +179,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 +216,9 @@ nonisolated internal enum MySQLFlavorResolution { variant == MySQLServerFlavor.databendVariant && !MySQLServerFlavor.fromBanner(banner).isDatabend } + // 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? { 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/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/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..54574494ae --- /dev/null +++ b/apple/Plugins/MySQLDriverPlugin/StarRocksCatalog.swift @@ -0,0 +1,122 @@ +// +// StarRocksCatalog.swift +// MySQLDriverPlugin +// + +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" + + static let showCatalogs = "SHOW CATALOGS" + + static let showDatabases = "SHOW DATABASES" + + struct Scope: Equatable, Sendable { + let catalog: String + let database: String + + init(catalog: String, database: String) { + self.catalog = catalog + self.database = database + } + + // `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[.. Scope { + Scope(catalog: target, database: target == catalog ? database : "") + } + } + + 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))' + """ + } + + // `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 + 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 + ) + } + } + + // `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 """ + 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 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]] { + 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 } + let isNullable = row[safe: 3]?.asText == "YES" + columns[table, default: []].append(PluginColumnInfo( + name: name, + dataType: dataType.uppercased(), + isNullable: isNullable, + isPrimaryKey: rowIdentifyingKeys.contains(row[safe: 4]?.asText ?? ""), + defaultValue: mysqlColumnDefault( + .bare(row[safe: 5]?.asText), extra: nil, dataType: dataType, isNullable: isNullable + ), + comment: row[safe: 6]?.asText?.nilIfEmpty + )) + } + return columns + } +} 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..f9424ed5f5 --- /dev/null +++ b/apple/Plugins/MySQLDriverTests/StarRocksCatalogTests.swift @@ -0,0 +1,229 @@ +// +// StarRocksCatalogTests.swift +// MySQLDriverTests +// + +import Foundation +import TableProPluginKit +import Testing + +struct StarRocksCatalogTests { + @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 configuredScope(text: String, catalog: String, database: String) { + #expect(StarRocksCatalog.Scope(configuredDatabase: text) == StarRocksCatalog.Scope(catalog: catalog, database: database)) + } + + // 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`") + } + + // 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("ice"), .text("Iceberg")], + [.text("")], + [], + ] + #expect(StarRocksCatalog.names(from: rows) == ["default_catalog", "ice"]) + } + + @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) + #expect(MySQLServerFlavor.mysql.hasMySQLObjectCatalogs) + #expect(flavor.systemDatabaseNames.isEmpty) + #expect(!flavor.reportsSessionStatusFlags) + #expect(!flavor.preparesOnServer) + #expect(flavor.maintenanceOperations.isEmpty) + } + + // 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 + ) + 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") + func begins() { + #expect(MySQLServerFlavor.starrocks.beginTransactionStatement(mode: .readWrite) == "BEGIN") + #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"), + (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() { + #expect(MySQLPluginDriver.initialFlavor(for: Self.config(database: "")) == .starrocks) + } + + @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") + + 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 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.activeDatabaseName == "a.b") + #expect(driver.currentDatabase == nil) + #expect(driver.currentSchema == nil) + } + + // `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", "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 == "INT(11)") + #expect(columns["pk"]?.first?.comment == nil) + #expect(columns["uniq"]?.first?.isPrimaryKey == true) + #expect(columns["agg"]?.first?.isPrimaryKey == true) + #expect(columns["dup"]?.first?.isPrimaryKey == false) + } + + // 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")], + ]) + #expect(columns["dup"]?.map(\.defaultValue) == ["'abc'", "0", "CURRENT_TIMESTAMP", "NULL", nil]) + #expect(columns["cm"]?.first?.comment == "the id") + } + + @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 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: "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") + + 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) + } + + 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(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(""), + ] + } + + 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/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/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..1de2232110 --- /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 a609186154..493b5ebe46 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/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 7383aeeec9..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,15 +458,18 @@ 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 + if browsesSchemas { + session.browseSchema = schema } } + if persist, browsesSchemas { + appSettingsStorage.saveLastSchema(schema, for: connectionId) + } } if persist { 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/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+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/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index e8d90a048d..644bf840e1 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/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 4546c66163..3ceaf12ad9 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -99295,6 +99295,9 @@ } } } + }, + "Lakehouse analytics, MySQL protocol" : { + }, "Language %@" : { "localizations" : { @@ -198842,6 +198845,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/Database/DatabaseManagerTests.swift b/apple/TableProTests/Core/Database/DatabaseManagerTests.swift index a34d9957ed..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)") } } @@ -363,4 +366,54 @@ struct DatabaseManagerDatabaseSwitchTests { #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == adapter.currentSchema) } + + // `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) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + var session = ConnectionSession(connection: connection, driver: adapter) + session.browseSchema = "idb" + DatabaseManager.shared.injectSession(session, 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: 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") + 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") + } + + @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/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 0158bf2b91..5f20ffb7ca 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", "Neo4j" ] 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) { diff --git a/apple/changelog.d/3939.added.md b/apple/changelog.d/3939.added.md new file mode 100644 index 0000000000..13786ef870 --- /dev/null +++ b/apple/changelog.d/3939.added.md @@ -0,0 +1 @@ +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 9bff6db292..009af4d549 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1206,6 +1206,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: @@ -1328,6 +1339,7 @@ aggregateTargets: - ParquetExport - SnowflakeDriverPlugin - SpannerDriverPlugin + - StarRocksDriverPlugin - SurrealDBDriverPlugin - TeradataDriver - TrinoDriverPlugin