diff --git a/CHANGELOG.md b/CHANGELOG.md index e2948e928..d84282dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off. - **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. - Import sheet ignoring a CSV or Excel option change until the next edit, then resetting the column mapping. - Import sheet showing an earlier table's columns after switching tables while the first was still loading. - CSV and JSON imports failing on every row, or writing a skipped field, when two fields differ only by case. 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 7f2de2380..165fcb6f5 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 99f7f4a97..ed0923716 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