diff --git a/CHANGELOG.md b/CHANGELOG.md index 39cf078f5..fb1c7307d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Tables in the sidebar now load automatically after a slow connect. SQL Server connections previously showed "No Tables" until the user manually picked a schema; the same race could affect other engines on slow networks. The post-connect listener now triggers the schema load once the driver is bound. +- SQL Server `switchDatabase` now actually switches the database (`USE `) instead of being routed through a schema switch. Switching from a saved tab pointing at a different database used to overwrite the current schema with the database name and leave the table list empty until the user manually re-picked a schema. +- SQL Server cell edits now save without "Conversion failed when converting date and/or time from character string." Tables with primary keys use a PK-only WHERE clause (no longer including every column), and DATETIME / DATETIME2 / SMALLDATETIME values round-trip as ISO 8601 instead of FreeTDS's `MMM d yyyy h:mm:ss:fffAM` format that SQL Server's parser rejects. +- SQL Server INSERTs skip IDENTITY columns automatically. Adding a new row no longer fails with "Cannot insert explicit value for identity column ... when IDENTITY_INSERT is set to OFF". The server allocates the value and TablePro omits the column from the INSERT. +- Toolbar database/schema chip reflects the correct unit from the moment a connection is established. SQL Server, PostgreSQL, Oracle, and BigQuery connections show the active schema; MySQL, SQLite, Redis, and other database-grouped engines show the active database. +- SQL Server connections use the server-reported default schema (`SELECT SCHEMA_NAME()`) rather than a hardcoded `dbo`, so users with a non-default schema in `sys.database_principals` see their tables on connect. The connection form's Schema field still acts as an explicit override. - Holding Cmd+Return at safe-mode level `.silent` no longer stacks two confirmation sheets and runs the dangerous query twice. The `.silent` branch in `QueryExecutionCoordinator.dispatchStatements` and `dispatchParameterizedStatements` now sets the same `isShowingSafeModePrompt` re-entry flag synchronously that the `requiresConfirmation` branch already used; the flag is cleared in a `defer` inside the spawned `Task`. - LSP `cancelRequest` no longer leaks a pending continuation when the underlying transport is mid-shutdown. The previous `try? writeMessage(data)` swallowed the failure, leaving the local handler stuck waiting for a response the LSP server would never produce. The new path logs the failure and resolves the pending entry with `CancellationError`, so AI inline-suggestion / Copilot LSP teardown no longer leaks completion handlers across the lifetime of the LSP process. - Plugin auto-update no longer drops new rejection entries that arrive from concurrent operations (e.g., a manual install failure during the auto-update loop). `PluginManager.autoUpdateRejectedPlugins` previously snapshotted `rejectedPlugins` at entry, looped through awaits that could mutate it, then assigned the stale snapshot back at the end. The fix replaces only the entries it processed and preserves any concurrent additions. diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 27aceaf18..77839af67 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -531,6 +531,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 3ad9b895b..7aee87502 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesExportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 61a3d4cca..63b750ec3 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 74efbef95..050560051 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -601,6 +601,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 40734ecdf..2cf0c69ff 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift index 345ec45ac..e5fe99867 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift @@ -484,6 +484,7 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift index b12f6af5a..38ab046be 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift @@ -429,6 +429,7 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index dd92d759d..5c529ec32 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesExportFormatIds json diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index c240500f7..88af07655 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 770025d6e..e3ab15d17 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -540,10 +540,10 @@ private final class FreeTDSConnection: @unchecked Sendable { let converted = buf.withUnsafeMutableBufferPointer { bufPtr in dbconvert(proc, srcType, ptr, srcLen, Int32(SYBCHAR), bufPtr.baseAddress, bufSize) } - if converted > 0 { - return String(bytes: buf.prefix(Int(converted)), encoding: .utf8) - } - return nil + guard converted > 0, + let raw = String(bytes: buf.prefix(Int(converted)), encoding: .utf8) + else { return nil } + return MSSQLDatetimeFormatter.reformat(raw, srcType: srcType) ?? raw } } @@ -572,6 +572,128 @@ private final class FreeTDSConnection: @unchecked Sendable { } } +// MARK: - Datetime Reformatting + +/// Reformats FreeTDS msdblib datetime output into ISO 8601 so values round-trip +/// through SQL Server's implicit string-to-datetime conversion. +/// +/// FreeTDS dbconvert(... SYBCHAR) emits legacy datetime values as +/// "MMM d yyyy h:mm[:ss[:fffffff]]AM/PM" (msdblib mode). SQL Server's parser +/// rejects that format on subsequent UPDATE/WHERE binding. ISO 8601 +/// (yyyy-MM-dd HH:mm:ss[.fffffff]) parses everywhere and preserves the original +/// fractional digits exactly without Foundation.Date precision loss. +internal enum MSSQLDatetimeFormatter { + /// Reformats a FreeTDS-emitted column value when the source type is one of + /// SQL Server's datetime variants. Returns nil for non-datetime types so the + /// caller falls back to the raw FreeTDS string. + static func reformat(_ raw: String, srcType: Int32) -> String? { + switch srcType { + case Int32(SYBDATETIME), Int32(SYBDATETIME4), Int32(SYBDATETIMN): + break + case 40, 41, 42: + // SYBMSDATE (40), SYBMSTIME (41), SYBMSDATETIME2 (42) from TDS 7.3+. + // Constants are not declared in the CFreeTDS stub header; matched + // by raw value. SYBMSDATETIMEOFFSET (43) is intentionally excluded + // because the offset suffix format is not verified. + break + default: + return nil + } + return parse(raw) + } + + /// Returns ISO 8601 if the input is recognized, nil otherwise. Already-ISO + /// inputs pass through verbatim. Public so tests can exercise it directly. + static func parse(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if isAlreadyISO(trimmed) { + return trimmed + } + return parseLegacyAMPM(trimmed) + } + + /// FreeTDS emits "yyyy-MM-dd ..." for some TDS 7.3+ types. Detect the prefix + /// and pass through, since the rest of the value is already SQL Server parseable. + static func isAlreadyISO(_ s: String) -> Bool { + let chars = Array(s) + guard chars.count >= 10 else { return false } + return chars[0].isASCIIDigit && chars[1].isASCIIDigit + && chars[2].isASCIIDigit && chars[3].isASCIIDigit + && chars[4] == "-" + && chars[5].isASCIIDigit && chars[6].isASCIIDigit + && chars[7] == "-" + && chars[8].isASCIIDigit && chars[9].isASCIIDigit + } + + /// Parses "MMM d yyyy h:mm[:ss[:fff[fffff]]] AM|PM" (msdblib 12-hour) or the + /// 24-hour variant without an AM/PM marker. Returns ISO 8601 with fractional + /// digits preserved verbatim. + private static func parseLegacyAMPM(_ raw: String) -> String? { + let scanner = Scanner(string: raw) + scanner.charactersToBeSkipped = nil + _ = scanner.scanCharacters(from: .whitespaces) + + guard let monthToken = scanner.scanCharacters(from: .letters), + monthToken.count >= 3, + let month = monthNamesByPrefix[String(monthToken.prefix(3))] + else { return nil } + + _ = scanner.scanCharacters(from: .whitespaces) + guard let day = scanner.scanInt(), (1...31).contains(day) else { return nil } + _ = scanner.scanCharacters(from: .whitespaces) + guard let year = scanner.scanInt(), (1...9999).contains(year) else { return nil } + _ = scanner.scanCharacters(from: .whitespaces) + guard var hour = scanner.scanInt() else { return nil } + + var minute = 0 + var second = 0 + var fractional = "" + + if scanner.scanString(":") != nil { + guard let m = scanner.scanInt(), (0...59).contains(m) else { return nil } + minute = m + } + if scanner.scanString(":") != nil { + guard let s = scanner.scanInt(), (0...59).contains(s) else { return nil } + second = s + } + if scanner.scanString(":") != nil || scanner.scanString(".") != nil { + fractional = scanner.scanCharacters(from: .decimalDigits) ?? "" + } + + _ = scanner.scanCharacters(from: .whitespaces) + let ampm = scanner.scanCharacters(from: .letters)?.uppercased() + + if let ampm { + guard ampm == "AM" || ampm == "PM" else { return nil } + guard (1...12).contains(hour) else { return nil } + if ampm == "PM", hour < 12 { + hour += 12 + } else if ampm == "AM", hour == 12 { + hour = 0 + } + } else { + guard (0...23).contains(hour) else { return nil } + } + + var iso = String(format: "%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second) + if !fractional.isEmpty { + iso += "." + fractional + } + return iso + } + + private static let monthNamesByPrefix: [String: Int] = [ + "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, + "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12 + ] +} + +private extension Character { + var isASCIIDigit: Bool { isASCII && isNumber } +} + // MARK: - MSSQL Plugin Driver final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { @@ -580,6 +702,13 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var _currentSchema: String private var _serverVersion: String? + /// IDENTITY columns observed during `fetchColumns`, keyed by table name. + /// `generateMssqlInsert` reads this to skip IDENTITY columns: SQL Server + /// rejects explicit values for IDENTITY columns unless IDENTITY_INSERT is ON, + /// and the value the user typed is server-allocated anyway. + private var identityColumnsByTable: [String: Set] = [:] + private let identityCacheLock = NSLock() + private static let logger = Logger(subsystem: "com.TablePro", category: "MSSQLPluginDriver") var currentSchema: String? { _currentSchema } @@ -641,6 +770,20 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) try await conn.connect() self.freeTDSConn = conn + + if let result = try? await conn.executeQuery("SELECT SCHEMA_NAME()"), + let serverSchema = result.rows.first?.first ?? nil, + !serverSchema.isEmpty { + _currentSchema = serverSchema + } else { + Self.logger.warning("SELECT SCHEMA_NAME() returned no value; keeping \(self._currentSchema, privacy: .public)") + } + + let formSchema = config.additionalFields["mssqlSchema"] + if let formSchema, !formSchema.isEmpty, formSchema != _currentSchema { + _currentSchema = formSchema + } + if let result = try? await conn.executeQuery("SELECT @@VERSION"), let versionStr = result.rows.first?.first ?? nil { _serverVersion = String(versionStr.prefix(50)) @@ -685,6 +828,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, @@ -704,7 +848,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } case .update: - if let stmt = generateMssqlUpdate(table: table, columns: columns, change: change) { + if let stmt = generateMssqlUpdate( + table: table, columns: columns, + primaryKeyColumns: primaryKeyColumns, change: change + ) { statements.append(stmt) } case .delete: @@ -715,7 +862,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if !deleteChanges.isEmpty { for change in deleteChanges { - if let stmt = generateMssqlDelete(table: table, columns: columns, change: change) { + if let stmt = generateMssqlDelete( + table: table, columns: columns, + primaryKeyColumns: primaryKeyColumns, change: change + ) { statements.append(stmt) } } @@ -731,11 +881,17 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) -> (statement: String, parameters: [String?])? { var nonDefaultColumns: [String] = [] var parameters: [String?] = [] + let identityColumns = cachedIdentityColumns(for: table) for (index, value) in values.enumerated() { if value == "__DEFAULT__" { continue } guard index < columns.count else { continue } - nonDefaultColumns.append("[\(columns[index].replacingOccurrences(of: "]", with: "]]"))]") + let columnName = columns[index] + // SQL Server IDENTITY columns are server-allocated. INSERTs that include + // an explicit value fail unless `SET IDENTITY_INSERT ON` was issued, + // so always omit them and let the server assign the next value. + if identityColumns.contains(columnName) { continue } + nonDefaultColumns.append("[\(columnName.replacingOccurrences(of: "]", with: "]]"))]") parameters.append(value) } @@ -751,9 +907,11 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func generateMssqlUpdate( table: String, columns: [String], + primaryKeyColumns: [String], change: PluginRowChange ) -> (statement: String, parameters: [String?])? { guard !change.cellChanges.isEmpty else { return nil } + guard let originalRow = change.originalRow else { return nil } let escapedTable = "[\(table.replacingOccurrences(of: "]", with: "]]"))]" var parameters: [String?] = [] @@ -764,15 +922,15 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "\(col) = ?" }.joined(separator: ", ") - // Check if we have original row data to identify by PK or all columns - guard let originalRow = change.originalRow else { return nil } + let whereColumns: [String] = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns - // Use all columns as WHERE clause for safety var conditions: [String] = [] - for (index, columnName) in columns.enumerated() { - guard index < originalRow.count else { continue } - let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" - if let value = originalRow[index] { + for whereColumn in whereColumns { + guard let columnIndex = columns.firstIndex(of: whereColumn), + columnIndex < originalRow.count + else { continue } + let col = "[\(whereColumn.replacingOccurrences(of: "]", with: "]]"))]" + if let value = originalRow[columnIndex] { parameters.append(value) conditions.append("\(col) = ?") } else { @@ -783,15 +941,15 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - - // Without a reliable PK, use UPDATE TOP (1) for safety - let sql = "UPDATE TOP (1) \(escapedTable) SET \(setClauses) WHERE \(whereClause)" + let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : "" + let sql = "UPDATE \(topClause)\(escapedTable) SET \(setClauses) WHERE \(whereClause)" return (statement: sql, parameters: parameters) } private func generateMssqlDelete( table: String, columns: [String], + primaryKeyColumns: [String], change: PluginRowChange ) -> (statement: String, parameters: [String?])? { guard let originalRow = change.originalRow else { return nil } @@ -800,10 +958,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var parameters: [String?] = [] var conditions: [String] = [] - for (index, columnName) in columns.enumerated() { - guard index < originalRow.count else { continue } - let col = "[\(columnName.replacingOccurrences(of: "]", with: "]]"))]" - if let value = originalRow[index] { + let whereColumns: [String] = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns + + for whereColumn in whereColumns { + guard let columnIndex = columns.firstIndex(of: whereColumn), + columnIndex < originalRow.count + else { continue } + let col = "[\(whereColumn.replacingOccurrences(of: "]", with: "]]"))]" + if let value = originalRow[columnIndex] { parameters.append(value) conditions.append("\(col) = ?") } else { @@ -814,7 +976,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard !conditions.isEmpty else { return nil } let whereClause = conditions.joined(separator: " AND ") - let sql = "DELETE TOP (1) FROM \(escapedTable) WHERE \(whereClause)" + let topClause = primaryKeyColumns.isEmpty ? "TOP (1) " : "" + let sql = "DELETE \(topClause)FROM \(escapedTable) WHERE \(whereClause)" return (statement: sql, parameters: parameters) } @@ -932,7 +1095,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY c.ORDINAL_POSITION """ let result = try await execute(query: sql) - return result.rows.compactMap { row -> PluginColumnInfo? in + var identityColumns: Set = [] + let columns: [PluginColumnInfo] = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = row[safe: 0] ?? nil else { return nil } let dataType = row[safe: 1] ?? nil let charLen = row[safe: 2] ?? nil @@ -943,6 +1107,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let isIdentity = (row[safe: 7] ?? nil) == "1" let isPk = (row[safe: 8] ?? nil) == "1" + if isIdentity { + identityColumns.insert(name) + } + let baseType = (dataType ?? "nvarchar").lowercased() let fixedSizeTypes: Set = [ "int", "bigint", "smallint", "tinyint", "bit", @@ -972,6 +1140,27 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { extra: isIdentity ? "IDENTITY" : nil ) } + identityCacheLock.lock() + identityColumnsByTable[table] = identityColumns + identityCacheLock.unlock() + return columns + } + + /// Snapshot of IDENTITY columns observed by the most recent `fetchColumns` for the table. + /// Returns an empty set when `fetchColumns` hasn't run for this table yet, so callers + /// fall through to including every typed value (matching pre-cache behavior). + internal func cachedIdentityColumns(for table: String) -> Set { + identityCacheLock.lock() + defer { identityCacheLock.unlock() } + return identityColumnsByTable[table] ?? [] + } + + /// Test seam: pre-populate the cache so generateMssqlInsert can be exercised + /// without going through a live `fetchColumns` round-trip. + internal func setIdentityColumnsForTesting(_ columns: Set, table: String) { + identityCacheLock.lock() + identityColumnsByTable[table] = columns + identityCacheLock.unlock() } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index dcc7bf2aa..185c6a6a5 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -536,6 +536,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 4ae7abfb7..a34463e0d 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index ccbd8506b..2c31dda46 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 10 + 11 diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 943524939..56637c59c 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -673,6 +673,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, @@ -975,6 +976,13 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { _currentSchema = schema } + /// Oracle has no real database concept; "switch database" is a schema switch. + /// Aliases to keep `coordinator.switchDatabase` working from tab restore paths + /// without relying on a manager-side kludge. + func switchDatabase(to database: String) async throws { + try await switchSchema(to: database) + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index cf261ba72..0aa6b403a 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 99ccaff62..dbeb08edb 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 10 + 11 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index b4437d06c..8bcad78bb 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -596,6 +596,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index e180d6ce7..e19badb55 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index dd09866b7..ec8910617 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index e06d54179..cc17fcc86 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index a4676d25a..3da660cce 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -87,7 +87,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? // Statement generation (optional, for NoSQL plugins) - func generateStatements(table: String, columns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? + func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? // Database switching (SQL Server USE, ClickHouse database switch, etc.) func switchDatabase(to database: String) async throws @@ -245,7 +245,7 @@ public extension PluginDatabaseDriver { func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } - func generateStatements(table: String, columns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } + func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [String?])]? { nil } func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { nil } diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index 5bad50fa2..a8baff145 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 10 + 11 TableProProvidesExportFormatIds xlsx diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 1ae101981..5149392b4 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -402,6 +402,7 @@ final class DataChangeManager: ChangeManaging { if let statements = pluginDriver.generateStatements( table: tableName, columns: columns, + primaryKeyColumns: primaryKeyColumns, changes: pluginChanges, insertedRowData: insertedRowData, deletedRowIndices: deletedRowIndices, diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 5a556972c..159a7f9cb 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -427,7 +427,7 @@ extension QueryExecutionCoordinator { DatabaseManager.shared.updateSession(parent.connectionId) { session in session.currentSchema = schema } - parent.toolbarState.databaseName = schema + parent.toolbarState.currentSchema = schema await parent.refreshTables() } catch { helpersLogger.warning("Failed to restore schema '\(schema, privacy: .public)': \(error.localizedDescription, privacy: .public)") diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index ee6d39caa..c0d526721 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -251,14 +251,6 @@ extension DatabaseManager { appSettingsStorage.saveLastSchema(nil, for: connectionId) await SchemaService.shared.invalidate(connectionId: connectionId) await reconnectSession(connectionId) - } else if pm?.capabilities.supportsSchemaSwitching == true, - let schemaDriver = driver as? SchemaSwitchable { - try await schemaDriver.switchSchema(to: database) - updateSession(connectionId) { session in - session.currentSchema = database - } - appSettingsStorage.saveLastSchema(database, for: connectionId) - return } else if let adapter = driver as? PluginDriverAdapter { try await adapter.switchDatabase(to: database) let grouping = pm?.schema.databaseGroupingStrategy ?? .byDatabase diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e1db5f5e2..f2c5fa929 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -20,13 +20,14 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { func pluginGenerateStatements( table: String, columns: [String], + primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [String?]], deletedRowIndices: Set, insertedRowIndices: Set ) -> [(statement: String, parameters: [String?])]? { pluginDriver.generateStatements( - table: table, columns: columns, changes: changes, + table: table, columns: columns, primaryKeyColumns: primaryKeyColumns, changes: changes, insertedRowData: insertedRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 02888271e..73384eed7 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -13,7 +13,7 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() - static let currentPluginKitVersion = 10 + static let currentPluginKitVersion = 11 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 33956d6d2..638720fdc 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -79,7 +79,7 @@ enum SessionStateFactory { if connection.type.pluginTypeId == "Redis" { let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 - toolbarSt.databaseName = String(dbIndex) + toolbarSt.currentDatabase = String(dbIndex) } let activeDatabaseName = DatabaseManager.shared.activeDatabaseName(for: connection) diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index cdc3195bf..f84b57315 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -124,8 +124,18 @@ final class ConnectionToolbarState { /// Connection name for display var connectionName: String = "" - /// Current database name - var databaseName: String = "" + /// Active database (always meaningful). For schema-grouped engines like SQL Server, + /// this is the SQL Server database (e.g. "Sales"); the active schema lives in + /// `currentSchema` and is what the toolbar chip shows. + var currentDatabase: String = "" + + /// Active schema for engines whose grouping strategy is `.bySchema`. Nil for + /// `.byDatabase` and `.flat` engines, where the database is the primary unit. + var currentSchema: String? + + /// How the engine groups data. Drives whether `chipText` returns `currentSchema` + /// (for schema-grouped engines) or `currentDatabase`. + var databaseGroupingStrategy: GroupingStrategy = .byDatabase /// Custom display color for the connection (uses database type color if not set) var displayColor: Color = .init(nsColor: .systemOrange) @@ -219,6 +229,22 @@ final class ConnectionToolbarState { return databaseType.rawValue } + /// Text shown in the toolbar's database/schema chip. For `.bySchema` engines + /// (SQL Server, PostgreSQL, Oracle, BigQuery), this is the active schema; for + /// `.byDatabase` and `.flat` engines, it is the active database. Falls back to + /// `currentDatabase` when a schema-grouped engine has not yet resolved its schema. + var chipText: String { + switch databaseGroupingStrategy { + case .bySchema: + if let schema = currentSchema, !schema.isEmpty { + return schema + } + return currentDatabase + case .byDatabase, .flat: + return currentDatabase + } + } + /// Tooltip text for the status indicator var statusTooltip: String { var parts: [String] = [connectionState.description] @@ -254,22 +280,30 @@ final class ConnectionToolbarState { displayColor = connection.displayColor tagId = connection.tagId safeModeLevel = connection.safeModeLevel - syncDatabaseName(for: connection) + databaseGroupingStrategy = PluginManager.shared.databaseGroupingStrategy(for: connection.type) + syncFromSession(for: connection) } - /// Resolve `databaseName` from the active session, falling back to the connection's configured value. - func syncDatabaseName(for connection: DatabaseConnection) { - let resolved: String + /// Resolve `currentDatabase` and `currentSchema` from the active session, falling + /// back to the connection's configured database for `currentDatabase`. The chip + /// updates automatically via the `chipText` computed property. + func syncFromSession(for connection: DatabaseConnection) { + let resolvedDatabase: String if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { - resolved = (connection.database as NSString).lastPathComponent + resolvedDatabase = (connection.database as NSString).lastPathComponent } else if let session = DatabaseManager.shared.session(for: connection.id), let database = session.currentDatabase { - resolved = database + resolvedDatabase = database } else { - resolved = connection.database + resolvedDatabase = connection.database + } + if currentDatabase != resolvedDatabase { + currentDatabase = resolvedDatabase } - if databaseName != resolved { - databaseName = resolved + + let resolvedSchema = DatabaseManager.shared.session(for: connection.id)?.currentSchema + if currentSchema != resolvedSchema { + currentSchema = resolvedSchema } } @@ -293,7 +327,9 @@ final class ConnectionToolbarState { databaseType = .mysql databaseVersion = nil connectionName = "" - databaseName = "" + currentDatabase = "" + currentSchema = nil + databaseGroupingStrategy = .byDatabase displayColor = databaseType.themeColor connectionState = .disconnected isExecuting = false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 48d0bd4f6..839376a80 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -408,8 +408,8 @@ extension MainContentCoordinator { /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { clearFilterState() - let previousDatabase = toolbarState.databaseName - toolbarState.databaseName = database + let previousDatabase = toolbarState.currentDatabase + toolbarState.currentDatabase = database do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) @@ -423,7 +423,7 @@ extension MainContentCoordinator { await refreshTables() } catch { - toolbarState.databaseName = previousDatabase + toolbarState.currentDatabase = previousDatabase navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( @@ -451,8 +451,8 @@ extension MainContentCoordinator { } clearFilterState() - let previousSchema = toolbarState.databaseName - toolbarState.databaseName = schema + let previousSchema = toolbarState.currentSchema + toolbarState.currentSchema = schema do { try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) @@ -466,7 +466,7 @@ extension MainContentCoordinator { await refreshTables() } catch { - toolbarState.databaseName = previousSchema + toolbarState.currentSchema = previousSchema await refreshTables() navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)") @@ -506,7 +506,7 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connId) { session in session.currentDatabase = database } - toolbarState.databaseName = database + toolbarState.currentDatabase = database executeTableTabQueryDirectly() let separator = connection.additionalFields["redisSeparator"] ?? ":" @@ -536,7 +536,7 @@ extension MainContentCoordinator { sidebarViewModel?.redisKeyTreeViewModel = vm let connId = connectionId - let database = toolbarState.databaseName + let database = toolbarState.currentDatabase let separator = connection.additionalFields["redisSeparator"] ?? ":" Task { await vm.loadKeys(connectionId: connId, database: database, separator: separator) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 61a5a0e03..bf3f48372 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -52,7 +52,7 @@ extension MainContentView { if mappedState != toolbarState.connectionState { toolbarState.connectionState = mappedState } - toolbarState.syncDatabaseName(for: connection) + toolbarState.syncFromSession(for: connection) } private func mapSessionStatus(_ status: ConnectionStatus) -> ToolbarConnectionState { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 63aeacfb6..67adc7d76 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -167,7 +167,7 @@ final class MainContentCoordinator { @ObservationIgnored private var changeManagerUpdateTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? - @ObservationIgnored private var pluginDriverCancellable: AnyCancellable? + @ObservationIgnored private var postConnectCancellable: AnyCancellable? @ObservationIgnored private var externalFileModCancellable: AnyCancellable? var fileConflictRequest: FileConflictRequest? @@ -437,14 +437,15 @@ final class MainContentCoordinator { registerForPersistence() setupPluginDriver() startFileWatcherIfNeeded() - // Retry when driver becomes available (connection may still be in progress) if changeManager.pluginDriver == nil { - pluginDriverCancellable = services.appEvents.databaseDidConnect + postConnectCancellable = services.appEvents.databaseDidConnect .receive(on: RunLoop.main) .sink { [weak self] payload in guard let self, payload.connectionId == self.connection.id else { return } - Task { + Task { @MainActor in self.setupPluginDriver() + await self.loadSchemaIfNeeded() + self.postConnectCancellable = nil } } } @@ -492,9 +493,6 @@ final class MainContentCoordinator { let pluginDriver = driver.queryBuildingPluginDriver queryBuilder.setPluginDriver(pluginDriver) changeManager.pluginDriver = pluginDriver - if pluginDriver != nil { - pluginDriverCancellable = nil - } } func markTeardownScheduled() { @@ -561,7 +559,7 @@ final class MainContentCoordinator { NotificationCenter.default.removeObserver(observer) terminationObserver = nil } - pluginDriverCancellable = nil + postConnectCancellable = nil externalFileModCancellable = nil fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index fa6d9f27a..c75db55d8 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -7,12 +7,14 @@ // import SwiftUI +import TableProPluginKit /// Main connection status display for the toolbar center struct ConnectionStatusView: View { let databaseType: DatabaseType let databaseVersion: String? - let databaseName: String + let chipText: String + let databaseGroupingStrategy: GroupingStrategy let connectionName: String let displayColor: Color var safeModeLevel: SafeModeLevel = .silent @@ -24,11 +26,11 @@ struct ConnectionStatusView: View { HStack(spacing: 10) { connectionIdentitySection - if !databaseName.isEmpty { + if !chipText.isEmpty { Divider() .frame(height: 12) - databaseNameSection + chipSection } } } @@ -55,30 +57,28 @@ struct ConnectionStatusView: View { } @ViewBuilder - private var databaseNameSection: some View { + private var chipSection: some View { if !PluginManager.shared.supportsDatabaseSwitching(for: databaseType) { - databaseNameLabel - .help("Database: \(databaseName)") + chipLabel + .help(staticChipTooltip) } else { Button { onSwitchDatabase?() } label: { - databaseNameLabel + chipLabel } .buttonStyle(.plain) - .help(safeModeLevel == .readOnly - ? String(format: String(localized: "Current database: %@ (read only, ⌘K to switch)"), databaseName) - : String(format: String(localized: "Current database: %@ (⌘K to switch)"), databaseName)) + .help(switchableChipTooltip) } } - private var databaseNameLabel: some View { + private var chipLabel: some View { HStack(spacing: 4) { Image(systemName: "cylinder") .imageScale(.small) .foregroundStyle(ThemeEngine.shared.colors.toolbar.secondaryTextSwiftUI) - Text(databaseName) + Text(chipText) .font(.callout.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) @@ -86,6 +86,34 @@ struct ConnectionStatusView: View { } } + private var chipKindLabel: String { + switch databaseGroupingStrategy { + case .bySchema: return String(localized: "Schema") + case .byDatabase, .flat: return String(localized: "Database") + } + } + + private var staticChipTooltip: String { + String(format: String(localized: "%@: %@"), chipKindLabel, chipText) + } + + private var switchableChipTooltip: String { + let switchVerb: String = switch databaseGroupingStrategy { + case .bySchema: String(localized: "switch schema") + case .byDatabase, .flat: String(localized: "switch database") + } + if safeModeLevel == .readOnly { + return String( + format: String(localized: "Current %@: %@ (read only, ⌘K to %@)"), + chipKindLabel.lowercased(), chipText, switchVerb + ) + } + return String( + format: String(localized: "Current %@: %@ (⌘K to %@)"), + chipKindLabel.lowercased(), chipText, switchVerb + ) + } + // MARK: - Computed Properties private var formattedDatabaseInfo: String { @@ -110,7 +138,8 @@ struct ConnectionStatusView: View { ConnectionStatusView( databaseType: .mariadb, databaseVersion: "11.1.2", - databaseName: "production_db", + chipText: "production_db", + databaseGroupingStrategy: .byDatabase, connectionName: "Production Database", displayColor: .cyan ) @@ -122,7 +151,8 @@ struct ConnectionStatusView: View { ConnectionStatusView( databaseType: .mysql, databaseVersion: "8.0.35", - databaseName: "dev_db", + chipText: "dev_db", + databaseGroupingStrategy: .byDatabase, connectionName: "Development", displayColor: .orange ) @@ -134,7 +164,8 @@ struct ConnectionStatusView: View { ConnectionStatusView( databaseType: .postgresql, databaseVersion: "16.1", - databaseName: "analytics", + chipText: "public", + databaseGroupingStrategy: .bySchema, connectionName: "Analytics DB", displayColor: .blue ) @@ -147,7 +178,8 @@ struct ConnectionStatusView: View { ConnectionStatusView( databaseType: .mysql, databaseVersion: "9.5.0", - databaseName: "", + chipText: "", + databaseGroupingStrategy: .byDatabase, connectionName: "Local", displayColor: .green ) diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 4e045781b..1003786f8 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -32,7 +32,8 @@ struct ToolbarPrincipalContent: View { ConnectionStatusView( databaseType: state.databaseType, databaseVersion: state.databaseVersion, - databaseName: state.databaseName, + chipText: state.chipText, + databaseGroupingStrategy: state.databaseGroupingStrategy, connectionName: state.connectionName, displayColor: state.displayColor, safeModeLevel: state.safeModeLevel, diff --git a/TableProTests/Models/ConnectionToolbarStateTests.swift b/TableProTests/Models/ConnectionToolbarStateTests.swift new file mode 100644 index 000000000..e5a45d5fc --- /dev/null +++ b/TableProTests/Models/ConnectionToolbarStateTests.swift @@ -0,0 +1,96 @@ +// +// ConnectionToolbarStateTests.swift +// TableProTests +// +// Tests for the toolbar chip's grouping-aware text resolution. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("ConnectionToolbarState") +struct ConnectionToolbarStateTests { + // MARK: - chipText + + @Test("chipText returns currentDatabase when grouping is byDatabase") + func chipTextByDatabase() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .byDatabase + state.currentDatabase = "myappdb" + state.currentSchema = "ignored" + + #expect(state.chipText == "myappdb") + } + + @Test("chipText returns currentSchema when grouping is bySchema and schema is set") + func chipTextBySchemaWithSchema() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .bySchema + state.currentDatabase = "Sales" + state.currentSchema = "dbo" + + #expect(state.chipText == "dbo") + } + + @Test("chipText falls back to currentDatabase when grouping is bySchema and schema is nil") + func chipTextBySchemaWithNilSchema() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .bySchema + state.currentDatabase = "Sales" + state.currentSchema = nil + + #expect(state.chipText == "Sales") + } + + @Test("chipText falls back to currentDatabase when grouping is bySchema and schema is empty") + func chipTextBySchemaWithEmptySchema() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .bySchema + state.currentDatabase = "Sales" + state.currentSchema = "" + + #expect(state.chipText == "Sales") + } + + @Test("chipText returns currentDatabase when grouping is flat (Redis, MongoDB)") + func chipTextFlat() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .flat + state.currentDatabase = "0" + state.currentSchema = "ignored" + + #expect(state.chipText == "0") + } + + // MARK: - reset + + @Test("reset clears database, schema, and grouping strategy") + func resetClearsAllChipFields() { + let state = ConnectionToolbarState() + state.databaseGroupingStrategy = .bySchema + state.currentDatabase = "Sales" + state.currentSchema = "dbo" + + state.reset() + + #expect(state.currentDatabase == "") + #expect(state.currentSchema == nil) + #expect(state.databaseGroupingStrategy == .byDatabase) + #expect(state.chipText == "") + } + + // MARK: - syncFromSession + + @Test("syncFromSession resolves currentDatabase from connection when no session exists") + func syncFromSessionFallsBackToConnectionDatabase() { + let connection = TestFixtures.makeConnection(database: "Production", type: .postgresql) + let state = ConnectionToolbarState() + + state.syncFromSession(for: connection) + + #expect(state.currentDatabase == "Production") + } +} diff --git a/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift b/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift new file mode 100644 index 000000000..2161109e5 --- /dev/null +++ b/TableProTests/Plugins/MSSQLDatetimeFormatterTests.swift @@ -0,0 +1,195 @@ +// +// MSSQLDatetimeFormatterTests.swift +// TableProTests +// +// Pins the FreeTDS msdblib → ISO 8601 datetime conversion. +// Covers all formats observed from FreeTDS dbconvert(... SYBCHAR) output: +// legacy DATETIME (3-digit fractional), DATETIME2 (7-digit fractional), +// SMALLDATETIME (no seconds), already-ISO passthrough, and AM/PM boundary cases. +// + +import Foundation +@testable import MSSQLDriver +import Testing + +@Suite("MSSQLDatetimeFormatter") +struct MSSQLDatetimeFormatterTests { + // MARK: - Legacy AM/PM format from FreeTDS msdblib + + @Test("DATETIME2 with 7-digit fractional reformats to ISO with all digits preserved") + func datetime2SevenDigitFractional() { + let result = MSSQLDatetimeFormatter.parse("May 10 2026 7:58:53:2960999AM") + #expect(result == "2026-05-10 07:58:53.2960999") + } + + @Test("DATETIME with 3-digit fractional reformats to ISO with milliseconds preserved") + func datetimeThreeDigitFractional() { + let result = MSSQLDatetimeFormatter.parse("Jan 5 2024 11:30:00:123PM") + #expect(result == "2024-01-05 23:30:00.123") + } + + @Test("SMALLDATETIME without seconds defaults seconds to 00") + func smallDatetimeNoSeconds() { + let result = MSSQLDatetimeFormatter.parse("Mar 15 2025 3:45PM") + #expect(result == "2025-03-15 15:45:00") + } + + @Test("DATETIME without fractional yields ISO without fractional suffix") + func datetimeNoFractional() { + let result = MSSQLDatetimeFormatter.parse("Dec 1 2023 9:00:00AM") + #expect(result == "2023-12-01 09:00:00") + } + + // MARK: - AM/PM boundary cases + + @Test("12 AM converts to 00 (midnight)") + func twelveAMisMidnight() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 12:30:00AM") + #expect(result == "2025-06-01 00:30:00") + } + + @Test("12 PM stays at 12 (noon)") + func twelvePMisNoon() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 12:30:00PM") + #expect(result == "2025-06-01 12:30:00") + } + + @Test("1 AM stays at 01") + func oneAMisOne() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 1:00:00AM") + #expect(result == "2025-06-01 01:00:00") + } + + @Test("11 PM converts to 23") + func elevenPMisTwentyThree() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 11:00:00PM") + #expect(result == "2025-06-01 23:00:00") + } + + // MARK: - Validation rejects malformed input + + @Test("Hour 13 with AM marker is rejected (12-hour values must be 1...12)") + func thirteenAMrejected() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 13:00:00AM") + #expect(result == nil) + } + + @Test("Hour 0 with AM marker is rejected (12-hour values must be 1...12)") + func zeroAMrejected() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 0:00:00AM") + #expect(result == nil) + } + + @Test("Unknown month abbreviation is rejected") + func unknownMonthRejected() { + let result = MSSQLDatetimeFormatter.parse("Foo 1 2025 12:00:00PM") + #expect(result == nil) + } + + @Test("Day 32 is rejected") + func dayOutOfRangeRejected() { + let result = MSSQLDatetimeFormatter.parse("Jan 32 2025 12:00:00PM") + #expect(result == nil) + } + + @Test("Year 0 is rejected") + func yearZeroRejected() { + let result = MSSQLDatetimeFormatter.parse("Jan 1 0 12:00:00PM") + #expect(result == nil) + } + + @Test("Year 10000 is rejected (out of ISO 8601 range)") + func yearTooLargeRejected() { + let result = MSSQLDatetimeFormatter.parse("Jan 1 10000 12:00:00PM") + #expect(result == nil) + } + + @Test("Minute 60 is rejected") + func minuteOutOfRangeRejected() { + let result = MSSQLDatetimeFormatter.parse("Jan 1 2025 12:60:00PM") + #expect(result == nil) + } + + @Test("Empty string returns nil") + func emptyStringRejected() { + let result = MSSQLDatetimeFormatter.parse("") + #expect(result == nil) + } + + @Test("Whitespace-only string returns nil") + func whitespaceRejected() { + let result = MSSQLDatetimeFormatter.parse(" ") + #expect(result == nil) + } + + // MARK: - ISO passthrough + + @Test("Already-ISO date passes through unchanged") + func isoDatePassthrough() { + let result = MSSQLDatetimeFormatter.parse("2026-05-10") + #expect(result == "2026-05-10") + } + + @Test("Already-ISO datetime passes through unchanged") + func isoDatetimePassthrough() { + let result = MSSQLDatetimeFormatter.parse("2026-05-10 14:30:00") + #expect(result == "2026-05-10 14:30:00") + } + + @Test("ISO datetime with fractional passes through unchanged") + func isoDatetimeWithFractionalPassthrough() { + let result = MSSQLDatetimeFormatter.parse("2026-05-10 14:30:00.1234567") + #expect(result == "2026-05-10 14:30:00.1234567") + } + + // MARK: - 24-hour input without AM/PM marker + + @Test("24-hour input without AM/PM accepts hour 23") + func twentyFourHourAccepted() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 23:30:00") + #expect(result == "2025-06-01 23:30:00") + } + + @Test("24-hour input rejects hour 24") + func twentyFourHourRejectsTwentyFour() { + let result = MSSQLDatetimeFormatter.parse("Jun 1 2025 24:00:00") + #expect(result == nil) + } + + // MARK: - reformat() type dispatch + + @Test("reformat returns nil for non-datetime types") + func reformatRejectsNonDatetimeTypes() { + // SYBINT4 = 56 + #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 56) == nil) + } + + @Test("reformat returns ISO for legacy DATETIME type (SYBDATETIME=61)") + func reformatDatetimeType() { + #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 61) == "2025-01-01 12:00:00") + } + + @Test("reformat returns ISO for SMALLDATETIME type (SYBDATETIME4=58)") + func reformatSmallDatetimeType() { + #expect(MSSQLDatetimeFormatter.reformat("Mar 15 2025 3:45PM", srcType: 58) == "2025-03-15 15:45:00") + } + + @Test("reformat returns ISO for nullable DATETIME (SYBDATETIMN=111)") + func reformatNullableDatetimeType() { + #expect(MSSQLDatetimeFormatter.reformat("Jan 1 2025 12:00:00PM", srcType: 111) == "2025-01-01 12:00:00") + } + + @Test("reformat returns ISO for SYBMSDATETIME2 (raw constant 42)") + func reformatMSDatetime2Type() { + let result = MSSQLDatetimeFormatter.reformat("May 10 2026 7:58:53:2960999AM", srcType: 42) + #expect(result == "2026-05-10 07:58:53.2960999") + } + + @Test("reformat returns nil for unverified DATETIMEOFFSET (raw constant 43)") + func reformatDatetimeOffsetExcluded() { + // SYBMSDATETIMEOFFSET (43) is intentionally not handled until the offset + // suffix format is verified end-to-end. + let result = MSSQLDatetimeFormatter.reformat("May 10 2026 7:58:53:2960999 +05:30AM", srcType: 43) + #expect(result == nil) + } +} diff --git a/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift b/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift new file mode 100644 index 000000000..cb6fbe72b --- /dev/null +++ b/TableProTests/Plugins/MSSQLPluginDriverDMLTests.swift @@ -0,0 +1,325 @@ +// +// MSSQLPluginDriverDMLTests.swift +// TableProTests +// +// Pins the MSSQL plugin's UPDATE/DELETE statement generation to the PK-aware +// contract introduced with PluginKit ABI 11. When the framework passes a +// non-empty `primaryKeyColumns`, WHERE filters by PK only (and `TOP (1)` is +// omitted because the PK uniquely identifies one row). When the framework +// passes an empty array, WHERE falls back to all columns plus `TOP (1)`. +// + +import Foundation +@testable import MSSQLDriver +import TableProPluginKit +import Testing + +@Suite("MSSQLPluginDriver DML") +struct MSSQLPluginDriverDMLTests { + private func makeDriver() -> MSSQLPluginDriver { + MSSQLPluginDriver(config: DriverConnectionConfig( + host: "localhost", + port: 1433, + username: "SA", + password: "irrelevant", + database: "Sales" + )) + } + + private func makeUpdateChange( + oldCustomerId: String = "2", + newCustomerId: String = "3", + originalRow: [String?] = ["2", "2", "19.99", "May 10 2026 7:58:53:2960999AM"] + ) -> PluginRowChange { + PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(1, "CustomerId", oldCustomerId, newCustomerId)], + originalRow: originalRow + ) + } + + // MARK: - UPDATE with PK + + @Test("UPDATE with primary key uses PK-only WHERE and drops TOP (1)") + func updateWithPrimaryKeyUsesPKOnly() { + let driver = makeDriver() + let columns = ["Id", "CustomerId", "Total", "PlacedAt"] + let change = makeUpdateChange() + + let result = driver.generateStatements( + table: "Orders", + columns: columns, + primaryKeyColumns: ["Id"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(result?.count == 1) + let sql = result?.first?.statement ?? "" + #expect(sql == "UPDATE [Orders] SET [CustomerId] = ? WHERE [Id] = ?") + // Two parameters: new value plus PK lookup. + #expect(result?.first?.parameters == ["3", "2"]) + } + + @Test("UPDATE without primary key falls back to all-columns WHERE plus TOP (1)") + func updateWithoutPrimaryKeyUsesAllColumns() { + let driver = makeDriver() + let columns = ["Number", "Amount"] + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(1, "Amount", "100.00", "150.00")], + originalRow: ["INV-0001", "100.00"] + ) + + let result = driver.generateStatements( + table: "Invoices", + columns: columns, + primaryKeyColumns: [], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "UPDATE TOP (1) [Invoices] SET [Amount] = ? WHERE [Number] = ? AND [Amount] = ?") + #expect(result?.first?.parameters == ["150.00", "INV-0001", "100.00"]) + } + + @Test("UPDATE with composite primary key emits both PK columns in WHERE") + func updateWithCompositePrimaryKey() { + let driver = makeDriver() + let columns = ["TenantId", "OrderId", "Status"] + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(2, "Status", "PENDING", "SHIPPED")], + originalRow: ["T-1", "O-100", "PENDING"] + ) + + let result = driver.generateStatements( + table: "Orders", + columns: columns, + primaryKeyColumns: ["TenantId", "OrderId"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "UPDATE [Orders] SET [Status] = ? WHERE [TenantId] = ? AND [OrderId] = ?") + #expect(result?.first?.parameters == ["SHIPPED", "T-1", "O-100"]) + } + + @Test("UPDATE with NULL original-row PK column emits IS NULL") + func updateWithNullPKValueUsesIsNull() { + let driver = makeDriver() + let columns = ["Code", "Label"] + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(1, "Label", "old", "new")], + originalRow: [nil, "old"] + ) + + let result = driver.generateStatements( + table: "Tags", + columns: columns, + primaryKeyColumns: ["Code"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "UPDATE [Tags] SET [Label] = ? WHERE [Code] IS NULL") + #expect(result?.first?.parameters == ["new"]) + } + + @Test("Identifier with closing bracket is escaped as ]]") + func identifierBracketEscaping() { + let driver = makeDriver() + let columns = ["weird]col", "Id"] + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(0, "weird]col", "a", "b")], + originalRow: ["a", "1"] + ) + + let result = driver.generateStatements( + table: "weird]table", + columns: columns, + primaryKeyColumns: ["Id"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql.contains("[weird]]table]")) + #expect(sql.contains("[weird]]col]")) + } + + // MARK: - DELETE with PK + + @Test("DELETE with primary key uses PK-only WHERE and drops TOP (1)") + func deleteWithPrimaryKeyUsesPKOnly() { + let driver = makeDriver() + let columns = ["Id", "CustomerId"] + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: ["2", "2"] + ) + + let result = driver.generateStatements( + table: "Orders", + columns: columns, + primaryKeyColumns: ["Id"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [0], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "DELETE FROM [Orders] WHERE [Id] = ?") + #expect(result?.first?.parameters == ["2"]) + } + + @Test("DELETE without primary key uses all-columns WHERE plus TOP (1)") + func deleteWithoutPrimaryKeyUsesAllColumns() { + let driver = makeDriver() + let columns = ["Number", "Amount"] + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: ["INV-0001", "100.00"] + ) + + let result = driver.generateStatements( + table: "Invoices", + columns: columns, + primaryKeyColumns: [], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [0], + insertedRowIndices: [] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "DELETE TOP (1) FROM [Invoices] WHERE [Number] = ? AND [Amount] = ?") + #expect(result?.first?.parameters == ["INV-0001", "100.00"]) + } + + // MARK: - INSERT skips IDENTITY columns + + @Test("INSERT skips IDENTITY columns when the cache has observed them") + func insertSkipsIdentityColumn() { + let driver = makeDriver() + driver.setIdentityColumnsForTesting(["Id"], table: "Customers") + + let columns = ["Id", "Name", "City", "CreatedAt"] + let insertChange = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + let insertedValues: [String?] = ["4", "Acme", "Hanoi", "2026-05-10 07:58:53.2840598"] + + let result = driver.generateStatements( + table: "Customers", + columns: columns, + primaryKeyColumns: ["Id"], + changes: [insertChange], + insertedRowData: [0: insertedValues], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "INSERT INTO [Customers] ([Name], [City], [CreatedAt]) VALUES (?, ?, ?)") + #expect(result?.first?.parameters == ["Acme", "Hanoi", "2026-05-10 07:58:53.2840598"]) + } + + @Test("INSERT includes all columns when no IDENTITY columns are cached") + func insertIncludesAllWithoutIdentityCache() { + let driver = makeDriver() + // Note: no setIdentityColumnsForTesting call; the cache is empty for this table. + let columns = ["Number", "Amount"] + let insertChange = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + let insertedValues: [String?] = ["INV-9999", "42.00"] + + let result = driver.generateStatements( + table: "Invoices", + columns: columns, + primaryKeyColumns: [], + changes: [insertChange], + insertedRowData: [0: insertedValues], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "INSERT INTO [Invoices] ([Number], [Amount]) VALUES (?, ?)") + #expect(result?.first?.parameters == ["INV-9999", "42.00"]) + } + + @Test("INSERT skips multiple IDENTITY columns and the __DEFAULT__ sentinel") + func insertSkipsIdentityAndDefaults() { + let driver = makeDriver() + driver.setIdentityColumnsForTesting(["Id", "RowVersion"], table: "Audit") + + let columns = ["Id", "RowVersion", "Action", "CreatedAt"] + let insertChange = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + let insertedValues: [String?] = ["1", "X", "DELETE", "__DEFAULT__"] + + let result = driver.generateStatements( + table: "Audit", + columns: columns, + primaryKeyColumns: ["Id"], + changes: [insertChange], + insertedRowData: [0: insertedValues], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let sql = result?.first?.statement ?? "" + #expect(sql == "INSERT INTO [Audit] ([Action]) VALUES (?)") + #expect(result?.first?.parameters == ["DELETE"]) + } + + @Test("cachedIdentityColumns returns empty set for unobserved table") + func cachedIdentityColumnsEmptyByDefault() { + let driver = makeDriver() + #expect(driver.cachedIdentityColumns(for: "NeverFetched") == []) + } + + @Test("cachedIdentityColumns returns the seeded set after seeding") + func cachedIdentityColumnsRoundTrip() { + let driver = makeDriver() + driver.setIdentityColumnsForTesting(["Id", "Version"], table: "T") + #expect(driver.cachedIdentityColumns(for: "T") == ["Id", "Version"]) + } +}