From 0f9fc90226e01785a3fa7e2604a8575e0ea7e619 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 29 Sep 2026 18:18:59 +0700 Subject: [PATCH 1/3] fix(datagrid): sort Redis, etcd and Kafka results in place instead of re-running the command with ORDER BY --- CHANGELOG.md | 1 + ...PluginMetadataRegistry+KafkaDefaults.swift | 3 +- ...ginMetadataRegistry+RegistryDefaults.swift | 6 +- ...tadataRegistryCuratedCapabilityTests.swift | 9 +++ .../MainContentCoordinatorSortTests.swift | 64 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40eb3555b..ea992013c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS row editor saving the placeholder of a long text or binary value over the full value. - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - **Local only** connections taking edits and deletions made on another device. +- Column header sort on Redis, etcd and Kafka re-running the last command with `ORDER BY` appended. ## [0.76.1] - 2026-09-29 diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+KafkaDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+KafkaDefaults.swift index a7e948054..ae7898c30 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+KafkaDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+KafkaDefaults.swift @@ -42,7 +42,8 @@ extension PluginMetadataRegistry { supportsDropSchema: false, defaultSSLMode: .verifyIdentity, supportsOpportunisticTLS: false, - verifiesServerWithSystemTrust: true + verifiesServerWithSystemTrust: true, + supportsColumnSort: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index a62921807..863b7f12c 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -144,7 +144,8 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: false, - supportsOpportunisticTLS: false + supportsOpportunisticTLS: false, + supportsColumnSort: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -908,7 +909,8 @@ extension PluginMetadataRegistry { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: false, - supportsOpportunisticTLS: false + supportsOpportunisticTLS: false, + supportsColumnSort: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index 6305fa609..c44fc98d0 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -262,6 +262,15 @@ struct PluginMetadataRegistryCuratedCapabilityTests { #expect(built.capabilities.pagination == .leadingRowsOnly(maximumRows: nil)) } + @Test("Redis, etcd and Kafka offer no column sort, since their commands take no ORDER BY", arguments: [ + "Redis", "etcd", "Kafka" + ]) + func commandLanguagesWithoutOrderByOfferNoColumnSort(typeId: String) throws { + let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId)) + + #expect(snapshot.capabilities.supportsColumnSort == false) + } + @Test("ScyllaDB declares the same query limits as Cassandra on its own curated entry") func scyllaDBDeclaresTheCassandraQueryLimits() { let registry = PluginMetadataRegistry.shared diff --git a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift index e70f25e95..a9cd5acd4 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift @@ -456,6 +456,56 @@ struct MainContentCoordinatorSortTests { #expect(sorted == ["a", "b", "c"]) } + @Test( + "A Redis, etcd or Kafka result is sorted in place, never re-run with an ORDER BY its language lacks", + arguments: CommandWithoutOrderBy.all + ) + func commandWithoutOrderBySortsInPlace(command: CommandWithoutOrderBy) async throws { + let connection = TestFixtures.makeConnection(type: command.type) + let driver = StatementRecordingDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { + coordinator.cancelAllQueryTasks() + coordinator.teardown() + DatabaseManager.shared.removeSession(for: connection.id) + } + var tab = QueryTab(title: "Query", query: command.text, tabType: .query) + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + let rows = TableRows.from( + queryRows: [["b"], ["c"], ["a"]].map { row in row.map(PluginCellValue.fromOptional) }, + columns: [command.column], + columnTypes: [.text(rawType: nil)] + ) + let shown = ResultSet(label: "Result", tableRows: rows) + shown.baseQuery = command.text + tabManager.mutate(tabId: tab.id) { $0.display.replaceUnpinnedResults(with: [shown]) } + coordinator.setActiveTableRows(rows, for: tab.id) + + coordinator.handleSortStateChanged(sortState([(0, .ascending)])) + for _ in 0 ..< 500 { + if !coordinator.isShowingSafeModePrompt, !coordinator.tabExecution.isExecuting(tab.id) { break } + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(driver.sentSQL.isEmpty) + let idx = try #require(tabManager.tabs.firstIndex { $0.id == tab.id }) + #expect(tabManager.tabs[idx].content.query == command.text) + #expect(tabManager.tabs[idx].sortState.columns == [SortColumn(columnIndex: 0, direction: .ascending)]) + let sorted = coordinator.tabSessionRegistry.tableRows(for: tab.id).rows.map { $0[0].asText } + #expect(sorted == ["a", "b", "c"]) + } + @Test("Table tab keeps the rows-per-page LIMIT through ascending, descending, and cleared sort") func tableTabSortPreservesPageSize() { let (coordinator, tabManager, tabId) = makeTableCoordinator(pageSize: 10) @@ -509,6 +559,20 @@ enum InFlightRunEnding: Sendable { } } +struct CommandWithoutOrderBy: Sendable, CustomTestStringConvertible { + let type: DatabaseType + let text: String + let column: String + + var testDescription: String { type.rawValue } + + static let all = [ + CommandWithoutOrderBy(type: .redis, text: "LPUSH mylist a", column: "length"), + CommandWithoutOrderBy(type: .etcd, text: "put k v", column: "Key"), + CommandWithoutOrderBy(type: .kafka, text: "CONSUME \"orders\" FROM NEWEST LIMIT 1", column: "Offset") + ] +} + /// One statement as it reached the driver, with the values bound to it. private struct SentStatement: Equatable { let sql: String From 597d9e07d8fd2d949abf6c7862022e4e587fc102 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 19:30:04 +0700 Subject: [PATCH 2/3] fix(ci): repair sync decoding and SurrealDB compilation --- CHANGELOG.md | 1 + .../TableProSync/SyncRecordMapper.swift | 14 ++----------- .../SyncRecordMapperTests.swift | 21 +++++++++++++++++++ .../SurrealValue+Display.swift | 6 ++++-- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 242143344..1b574db6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Unknown Safe Mode levels synced to iOS allowing writes without confirmation. - Save disabled for Kafka connections set to Verify Identity without a CA file. - Crash on `inf` or `nan`, text like `007` stored as a number, and blobs stored as text on remote libSQL. - External Clients level and Cloudflare, Cloud SQL, SOCKS and Tunnel Command settings reset by an iCloud sync. diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 53a5a6fb1..cd1d9c03f 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -187,22 +187,12 @@ public enum SyncRecordMapper { } private static func storedSafeModeLevel(in fields: SyncRecordFields) -> SafeModeLevel { - safeModeLevel( - fromWire: fields[.safeModeLevel] as? String, + SafeModeLevel( + wireValue: fields[.safeModeLevel] as? String, isReadOnly: (fields[.isReadOnly] as? Int64 ?? 0) != 0 ) } - private static func safeModeLevel(fromWire raw: String?, isReadOnly: Bool) -> SafeModeLevel { - guard let raw else { return isReadOnly ? .readOnly : .off } - if let level = SafeModeLevel(rawValue: raw) { return level } - switch raw { - case "silent": return .off - case "alert", "alertFull", "safeMode", "safeModeFull": return .confirmWrites - default: return isReadOnly ? .readOnly : .off - } - } - // MARK: - Update Existing CKRecord (preserves macOS-only fields) public static func updateRecord(_ record: CKRecord, with connection: DatabaseConnection) { diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift index 0463cdcad..2fa2afdfb 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -86,4 +86,25 @@ struct SyncRecordMapperTests { let decoded = try #require(SyncRecordMapper.toConnection(record)) #expect(decoded.safeModeLevel == .confirmWrites) } + + @Test("An unrecognized wire value keeps the legacy read-only restriction") + func unknownWireValuePreservesReadOnly() throws { + let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel", isReadOnly: true) + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.safeModeLevel == .readOnly) + } + + @Test("A rename preserves an unrecognized wire value and requires confirmation") + func renamePreservesUnknownWireValue() throws { + let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel") + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.name = "Renamed" + + SyncRecordMapper.updateRecord(record, with: connection) + + #expect(record["safeModeLevel"] as? String == "someFutureLevel") + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.name == "Renamed") + #expect(decoded.safeModeLevel == .confirmWrites) + } } diff --git a/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift b/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift index b9806d19a..271358ffa 100644 --- a/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift +++ b/Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift @@ -53,8 +53,10 @@ public extension SurrealValue { } } - var jsonText: String { - JSONTruncation.truncate(Self.jsonFragment(self), maxLength: Self.maxSerializedLength) + private func jsonText(_ length: SurrealTextLength) -> String { + let text = Self.jsonFragment(self) + guard length == .display else { return text } + return JSONTruncation.truncate(text, maxLength: Self.maxSerializedLength) } var typeName: String { From 4fd4c3da047893f2099db74daea954ae78f39bbc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 20:00:00 +0700 Subject: [PATCH 3/3] test(plugins): request display text in shortened value fixtures --- TableProTests/Plugins/ElasticsearchDriverTests.swift | 2 +- TableProTests/Plugins/TypesenseDriverTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProTests/Plugins/ElasticsearchDriverTests.swift b/TableProTests/Plugins/ElasticsearchDriverTests.swift index 5825be849..c7c1b5138 100644 --- a/TableProTests/Plugins/ElasticsearchDriverTests.swift +++ b/TableProTests/Plugins/ElasticsearchDriverTests.swift @@ -1202,7 +1202,7 @@ struct ElasticsearchStatementGeneratorTests { } private func displayed(_ value: Any) -> String { - ElasticsearchMappingFlattener.cell(value).asText ?? "" + ElasticsearchMappingFlattener.cell(value, length: .display).asText ?? "" } private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { diff --git a/TableProTests/Plugins/TypesenseDriverTests.swift b/TableProTests/Plugins/TypesenseDriverTests.swift index 4b7d6a779..1c50b0828 100644 --- a/TableProTests/Plugins/TypesenseDriverTests.swift +++ b/TableProTests/Plugins/TypesenseDriverTests.swift @@ -964,7 +964,7 @@ struct TypesenseStatementGeneratorTests { } private func shortenedAuthors() -> String { - TypesenseSchema.cell((0..<1_500).map { "author-\($0)" }).asText ?? "" + TypesenseSchema.cell((0..<1_500).map { "author-\($0)" }, length: .display).asText ?? "" } private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { @@ -1025,7 +1025,7 @@ struct TypesenseStatementGeneratorTests { @Test("A complete array written over one shortened for display is refused, since it may be the shown part closed") func updateRefusesACompleteArrayWrittenOverAShortenedOne() throws { - let shownPart = TypesenseSchema.cell((0..<600).map { "author-\($0)" }).asText ?? "" + let shownPart = TypesenseSchema.cell((0..<600).map { "author-\($0)" }, length: .display).asText ?? "" try #require(!shownPart.hasSuffix("...")) #expect(throws: shortenedRefusal("authors")) { try updateRequest(authorsEdit(from: shortenedAuthors(), to: .text(shownPart)))