diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4e173b3..28fe2283a 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: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions. - iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch). - iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another. +- Settings > Data Grid > Default row sort: opt in to sort tables by Primary key or First column on open. Defaults to No sorting (engine order). Click any column header to override. (#1284) ### Changed diff --git a/Plugins/TableProPluginKit/PluginDefaultSortProvider.swift b/Plugins/TableProPluginKit/PluginDefaultSortProvider.swift new file mode 100644 index 000000000..58cefbbf9 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginDefaultSortProvider.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum DefaultSortHint: Sendable, Equatable { + case useAppDefault + case suppress + case forceColumns([String]) +} + +public protocol PluginDefaultSortProvider: AnyObject, Sendable { + func defaultSortHint(forTable table: String) -> DefaultSortHint +} diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 7c9c76a72..ece446f7e 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -174,6 +174,15 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(at: idx) { $0.tableContext.primaryKeyColumns = resolvedPKs } } + applyDefaultSortIfPending( + tabId: tabId, + tabIndex: idx, + tableName: tableName, + columns: columns, + resolvedPKs: resolvedPKs, + connectionType: conn.type + ) + if parent.tabManager.selectedTabId == tabId { parent.changeManager.configureForTable( tableName: tableName ?? "", @@ -199,6 +208,48 @@ extension QueryExecutionCoordinator { } } + private func applyDefaultSortIfPending( + tabId: UUID, + tabIndex: Int, + tableName: String?, + columns: [String], + resolvedPKs: [String], + connectionType: DatabaseType + ) { + guard tabIndex < parent.tabManager.tabs.count else { return } + let tab = parent.tabManager.tabs[tabIndex] + guard !tab.execution.didEvaluateDefaultSort, + tab.tabType == .table, + !tab.sortState.isSorting, + !columns.isEmpty, + let tableName, !tableName.isEmpty, + parent.tabManager.selectedTabId == tabId else { + return + } + + let behavior = AppSettingsManager.shared.dataGrid.defaultSortBehavior + let hint = PluginManager.shared.defaultSortHint(for: connectionType, table: tableName) + let resolved = DefaultSortResolver.resolveSortState( + behavior: behavior, + pluginHint: hint, + primaryKeyColumns: resolvedPKs, + allColumns: columns + ) + + guard resolved.isSorting else { + parent.tabManager.mutate(at: tabIndex) { $0.execution.didEvaluateDefaultSort = true } + return + } + + parent.tabManager.mutate(at: tabIndex) { tab in + tab.execution.didEvaluateDefaultSort = true + tab.sortState = resolved + tab.pagination.reset() + } + parent.filterCoordinator.rebuildTableQuery(at: tabIndex) + parent.runQuery() + } + func launchPhase2Work( tableName: String, tabId: UUID, diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 66794d8c5..5d3873d9e 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -727,6 +727,12 @@ final class PluginManager { return provider.diagnose(error: error) } + func defaultSortHint(for type: DatabaseType, table: String) -> DefaultSortHint { + guard let driver = driverPlugins[type.pluginTypeId] else { return .useAppDefault } + guard let provider = driver as? PluginDefaultSortProvider else { return .useAppDefault } + return provider.defaultSortHint(forTable: table) + } + func replaceExistingPlugin(bundleId: String) { guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return } unregisterCapabilities(pluginId: bundleId) diff --git a/TablePro/Core/Services/Query/DefaultSortResolver.swift b/TablePro/Core/Services/Query/DefaultSortResolver.swift new file mode 100644 index 000000000..d4b148a03 --- /dev/null +++ b/TablePro/Core/Services/Query/DefaultSortResolver.swift @@ -0,0 +1,36 @@ +import Foundation +import TableProPluginKit + +enum DefaultSortResolver { + static func resolveSortState( + behavior: DefaultSortBehavior, + pluginHint: DefaultSortHint, + primaryKeyColumns: [String], + allColumns: [String] + ) -> SortState { + let names: [String] + switch pluginHint { + case .suppress: + return SortState() + case .forceColumns(let cols): + names = cols + case .useAppDefault: + switch behavior { + case .none: + return SortState() + case .primaryKey: + names = primaryKeyColumns + case .firstColumn: + names = allColumns.first.map { [$0] } ?? [] + } + } + + var columnsOut: [SortColumn] = [] + for name in names { + guard let index = allColumns.firstIndex(of: name) else { continue } + columnsOut.append(SortColumn(columnIndex: index, direction: .ascending)) + } + guard !columnsOut.isEmpty else { return SortState() } + return SortState(columns: columnsOut, source: .defaultSort) + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index d9eacf564..841a6514a 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -132,6 +132,10 @@ struct QueryTab: Identifiable, Equatable { } } + var hasUserActiveSort: Bool { + sortState.isSorting && sortState.source == .user + } + func toPersistedTab() -> PersistedTab { let persistedQuery: String if (content.query as NSString).length > TabQueryContent.maxPersistableQuerySize { diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 6ac3fef61..a757e8681 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -279,6 +279,7 @@ final class QueryTabManager { tab.execution.statusMessage = nil tab.execution.errorMessage = nil tab.execution.lastExecutedAt = nil + tab.execution.didEvaluateDefaultSort = false tab.display.resultsViewMode = .data tab.sortState = SortState() tab.selectedRowIndices = [] diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 53b56f528..dca911d13 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -75,11 +75,20 @@ struct SortColumn: Equatable { var direction: SortDirection } +enum SortSource: Equatable { + case user + case defaultSort +} + /// Tracks sorting state for a table (supports multi-column sort) struct SortState: Equatable { var columns: [SortColumn] = [] + var source: SortSource = .user - init() {} + init(columns: [SortColumn] = [], source: SortSource = .user) { + self.columns = columns + self.source = source + } var isSorting: Bool { !columns.isEmpty } @@ -232,6 +241,7 @@ struct TabExecutionState: Equatable { var errorMessage: String? var rowsAffected: Int = 0 var lastExecutedAt: Date? + var didEvaluateDefaultSort: Bool = false static func == (lhs: TabExecutionState, rhs: TabExecutionState) -> Bool { lhs.isExecuting == rhs.isExecuting diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 35520ad54..28d3f35dc 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -126,6 +126,22 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable { } } +enum DefaultSortBehavior: String, Codable, CaseIterable, Identifiable, Equatable { + case none + case primaryKey + case firstColumn + + var id: String { rawValue } + + var displayName: String { + switch self { + case .none: return String(localized: "No sorting (engine order)") + case .primaryKey: return String(localized: "Primary key") + case .firstColumn: return String(localized: "First column") + } + } +} + /// Data grid settings struct DataGridSettings: Codable, Equatable { var rowHeight: DataGridRowHeight @@ -139,6 +155,7 @@ struct DataGridSettings: Codable, Equatable { var countRowsIfEstimateLessThan: Int var queryResultRowCap: Int var truncateQueryResults: Bool + var defaultSortBehavior: DefaultSortBehavior static let `default` = DataGridSettings( rowHeight: .normal, @@ -151,7 +168,8 @@ struct DataGridSettings: Codable, Equatable { enableSmartValueDetection: true, countRowsIfEstimateLessThan: 100_000, queryResultRowCap: 10_000, - truncateQueryResults: true + truncateQueryResults: true, + defaultSortBehavior: .none ) init( @@ -165,7 +183,8 @@ struct DataGridSettings: Codable, Equatable { enableSmartValueDetection: Bool = true, countRowsIfEstimateLessThan: Int = 100_000, queryResultRowCap: Int = 10_000, - truncateQueryResults: Bool = true + truncateQueryResults: Bool = true, + defaultSortBehavior: DefaultSortBehavior = .none ) { self.rowHeight = rowHeight self.dateFormat = dateFormat @@ -178,6 +197,7 @@ struct DataGridSettings: Codable, Equatable { self.countRowsIfEstimateLessThan = countRowsIfEstimateLessThan self.queryResultRowCap = queryResultRowCap self.truncateQueryResults = truncateQueryResults + self.defaultSortBehavior = defaultSortBehavior } init(from decoder: Decoder) throws { @@ -193,6 +213,7 @@ struct DataGridSettings: Codable, Equatable { countRowsIfEstimateLessThan = try container.decodeIfPresent(Int.self, forKey: .countRowsIfEstimateLessThan) ?? 100_000 queryResultRowCap = try container.decodeIfPresent(Int.self, forKey: .queryResultRowCap) ?? 10_000 truncateQueryResults = try container.decodeIfPresent(Bool.self, forKey: .truncateQueryResults) ?? true + defaultSortBehavior = try container.decodeIfPresent(DefaultSortBehavior.self, forKey: .defaultSortBehavior) ?? .none } // MARK: - Validated Properties diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f54b531f1..a3c8416c2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6244,6 +6244,9 @@ } } } + }, + "Applied when opening a table. Click a column header to override." : { + }, "Apply" : { "localizations" : { @@ -11302,6 +11305,9 @@ } } } + }, + "Connection is read-only. Destructive operations are not permitted." : { + }, "Connection is read-only. Set safe mode to Confirm Writes or higher to allow this tool." : { @@ -15000,6 +15006,9 @@ } } } + }, + "Default row sort:" : { + }, "Default value" : { "extractionState" : "stale", @@ -21929,6 +21938,9 @@ } } } + }, + "First column" : { + }, "Fit to Window" : { "localizations" : { @@ -31388,6 +31400,9 @@ } } } + }, + "No sorting (engine order)" : { + }, "No SSL encryption" : { "localizations" : { @@ -35625,6 +35640,9 @@ } } } + }, + "Primary key" : { + }, "Primary Key" : { "localizations" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 15139a580..56b7af6d8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -156,7 +156,7 @@ extension MainContentCoordinator { // If current tab has unsaved changes, active filters, or sorting, open in a new native tab let hasActiveWork = changeManager.hasChanges || selectedTabFilterState.hasAppliedFilters - || (tabManager.selectedTab?.sortState.isSorting ?? false) + || (tabManager.selectedTab?.hasUserActiveSort ?? false) if hasActiveWork { let payload = EditorTabPayload( connectionId: connection.id, @@ -250,7 +250,7 @@ extension MainContentCoordinator { if tab.isPreview { return true } // Table tab with no active work if tab.tabType == .table && !changeManager.hasChanges - && !selectedTabFilterState.hasAppliedFilters && !tab.sortState.isSorting { + && !selectedTabFilterState.hasAppliedFilters && !tab.hasUserActiveSort { return true } // Empty/default query tab (no user content, no results, never executed) @@ -271,7 +271,7 @@ extension MainContentCoordinator { } ?? false let previewHasWork = changeManager.hasChanges || selectedTabFilterState.hasAppliedFilters - || selectedTab.sortState.isSorting + || selectedTab.hasUserActiveSort || hasUnsavedQuery if previewHasWork { promotePreviewTab() diff --git a/TablePro/Views/Settings/Sections/DataGridSection.swift b/TablePro/Views/Settings/Sections/DataGridSection.swift index be24f0fd8..889ae1ada 100644 --- a/TablePro/Views/Settings/Sections/DataGridSection.swift +++ b/TablePro/Views/Settings/Sections/DataGridSection.swift @@ -37,6 +37,13 @@ struct DataGridSection: View { Toggle("Show row numbers", isOn: $settings.showRowNumbers) Toggle("Auto-show inspector on row select", isOn: $settings.autoShowInspector) Toggle("Smart value detection", isOn: $settings.enableSmartValueDetection) + + Picker("Default row sort:", selection: $settings.defaultSortBehavior) { + ForEach(DefaultSortBehavior.allCases) { behavior in + Text(behavior.displayName).tag(behavior) + } + } + .help(String(localized: "Applied when opening a table. Click a column header to override.")) } Section("Pagination") { diff --git a/TableProTests/Models/Query/DefaultSortStateTests.swift b/TableProTests/Models/Query/DefaultSortStateTests.swift new file mode 100644 index 000000000..c4d0311e1 --- /dev/null +++ b/TableProTests/Models/Query/DefaultSortStateTests.swift @@ -0,0 +1,122 @@ +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("QueryTab.hasUserActiveSort") +@MainActor +struct QueryTabHasUserActiveSortTests { + @Test("Empty sortState is not user-active") + func emptyStateNotActive() { + var tab = QueryTab(tabType: .table) + tab.sortState = SortState() + #expect(!tab.hasUserActiveSort) + } + + @Test("Default-sourced sortState is not user-active") + func defaultSourceNotActive() { + var tab = QueryTab(tabType: .table) + tab.sortState = SortState( + columns: [SortColumn(columnIndex: 0, direction: .ascending)], + source: .defaultSort + ) + #expect(tab.sortState.isSorting) + #expect(!tab.hasUserActiveSort) + } + + @Test("User-sourced sortState with columns is user-active") + func userSourceIsActive() { + var tab = QueryTab(tabType: .table) + tab.sortState = SortState( + columns: [SortColumn(columnIndex: 1, direction: .descending)], + source: .user + ) + #expect(tab.hasUserActiveSort) + } + + @Test("User-cleared (empty + user source) is not active") + func userClearedNotActive() { + var tab = QueryTab(tabType: .table) + tab.sortState = SortState(columns: [], source: .user) + #expect(!tab.hasUserActiveSort) + } +} + +@Suite("QueryTabManager.replaceTabContent resets default-sort gate") +@MainActor +struct ReplaceTabContentDefaultSortResetTests { + @Test("replaceTabContent clears didEvaluateDefaultSort") + func replaceClearsGate() throws { + let manager = QueryTabManager() + try manager.addTableTab(tableName: "users") + guard let index = manager.selectedTabIndex else { + Issue.record("selectedTabIndex was nil after addTableTab") + return + } + manager.mutate(at: index) { $0.execution.didEvaluateDefaultSort = true } + #expect(manager.tabs[index].execution.didEvaluateDefaultSort) + + try manager.replaceTabContent(tableName: "orders") + + #expect(!manager.tabs[index].execution.didEvaluateDefaultSort) + } + + @Test("replaceTabContent clears sortState (back to .user default)") + func replaceClearsSortState() throws { + let manager = QueryTabManager() + try manager.addTableTab(tableName: "users") + guard let index = manager.selectedTabIndex else { + Issue.record("selectedTabIndex was nil after addTableTab") + return + } + manager.mutate(at: index) { tab in + tab.sortState = SortState( + columns: [SortColumn(columnIndex: 0, direction: .ascending)], + source: .defaultSort + ) + } + #expect(manager.tabs[index].sortState.isSorting) + + try manager.replaceTabContent(tableName: "orders") + + #expect(!manager.tabs[index].sortState.isSorting) + #expect(manager.tabs[index].sortState.source == .user) + } +} + +@Suite("DataGridSettings.defaultSortBehavior decoder") +struct DataGridSettingsDefaultSortDecoderTests { + @Test("Missing key falls back to .none for upgrading users") + func missingKeyFallsBackToNone() throws { + let legacyJSON = """ + { + "rowHeight": "normal", + "dateFormat": "yyyy-MM-dd HH:mm:ss", + "nullDisplay": "NULL", + "defaultPageSize": 1000, + "showAlternateRows": true, + "showRowNumbers": true, + "autoShowInspector": false, + "enableSmartValueDetection": true, + "countRowsIfEstimateLessThan": 100000, + "queryResultRowCap": 10000, + "truncateQueryResults": true + } + """.data(using: .utf8)! + + let settings = try JSONDecoder().decode(DataGridSettings.self, from: legacyJSON) + + #expect(settings.defaultSortBehavior == .none) + } + + @Test("Explicit primaryKey value round-trips") + func explicitPrimaryKeyRoundTrips() throws { + var settings = DataGridSettings.default + settings.defaultSortBehavior = .primaryKey + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(DataGridSettings.self, from: data) + + #expect(decoded.defaultSortBehavior == .primaryKey) + } +} diff --git a/TableProTests/Services/DefaultSortResolverTests.swift b/TableProTests/Services/DefaultSortResolverTests.swift new file mode 100644 index 000000000..9b672388f --- /dev/null +++ b/TableProTests/Services/DefaultSortResolverTests.swift @@ -0,0 +1,115 @@ +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("DefaultSortResolver") +struct DefaultSortResolverTests { + private let columns = ["id", "name", "created_at"] + + @Test("Primary key behavior with single PK returns PK column") + func singlePrimaryKey() { + let state = DefaultSortResolver.resolveSortState( + behavior: .primaryKey, pluginHint: .useAppDefault, + primaryKeyColumns: ["id"], allColumns: columns + ) + #expect(state.columns.count == 1) + #expect(state.columns.first?.columnIndex == 0) + #expect(state.columns.first?.direction == .ascending) + #expect(state.source == .defaultSort) + } + + @Test("Empty resolved state keeps default source (.user)") + func emptyStateStaysUserSource() { + let state = DefaultSortResolver.resolveSortState( + behavior: .none, pluginHint: .useAppDefault, + primaryKeyColumns: ["id"], allColumns: columns + ) + #expect(!state.isSorting) + #expect(state.source == .user) + } + + @Test("Primary key behavior with composite PK uses all PK columns in order") + func compositePrimaryKey() { + let state = DefaultSortResolver.resolveSortState( + behavior: .primaryKey, pluginHint: .useAppDefault, + primaryKeyColumns: ["id", "name"], allColumns: columns + ) + #expect(state.columns.map(\.columnIndex) == [0, 1]) + #expect(state.columns.allSatisfy { $0.direction == .ascending }) + } + + @Test("Primary key behavior with no PK returns empty sort state") + func noPrimaryKey() { + let state = DefaultSortResolver.resolveSortState( + behavior: .primaryKey, pluginHint: .useAppDefault, + primaryKeyColumns: [], allColumns: columns + ) + #expect(!state.isSorting) + } + + @Test("First column behavior returns column at index 0") + func firstColumn() { + let state = DefaultSortResolver.resolveSortState( + behavior: .firstColumn, pluginHint: .useAppDefault, + primaryKeyColumns: ["id"], allColumns: columns + ) + #expect(state.columns.count == 1) + #expect(state.columns.first?.columnIndex == 0) + } + + @Test("First column behavior with empty columns returns empty sort state") + func firstColumnEmpty() { + let state = DefaultSortResolver.resolveSortState( + behavior: .firstColumn, pluginHint: .useAppDefault, + primaryKeyColumns: [], allColumns: [] + ) + #expect(!state.isSorting) + } + + @Test("None behavior never sorts, even with PK present") + func noneBehavior() { + let state = DefaultSortResolver.resolveSortState( + behavior: .none, pluginHint: .useAppDefault, + primaryKeyColumns: ["id"], allColumns: columns + ) + #expect(!state.isSorting) + } + + @Test("Plugin .suppress hint overrides app behavior") + func suppressOverridesBehavior() { + let state = DefaultSortResolver.resolveSortState( + behavior: .primaryKey, pluginHint: .suppress, + primaryKeyColumns: ["id"], allColumns: columns + ) + #expect(!state.isSorting) + } + + @Test("Plugin .forceColumns hint overrides app behavior") + func forceColumnsOverridesBehavior() { + let state = DefaultSortResolver.resolveSortState( + behavior: .none, pluginHint: .forceColumns(["created_at"]), + primaryKeyColumns: [], allColumns: columns + ) + #expect(state.columns.count == 1) + #expect(state.columns.first?.columnIndex == 2) + } + + @Test("Force columns filters out names not present in allColumns") + func forceColumnsFiltersUnknown() { + let state = DefaultSortResolver.resolveSortState( + behavior: .none, pluginHint: .forceColumns(["created_at", "missing"]), + primaryKeyColumns: [], allColumns: columns + ) + #expect(state.columns.map(\.columnIndex) == [2]) + } + + @Test("Primary key filters out PK names not present in current columns") + func pkNotInColumns() { + let state = DefaultSortResolver.resolveSortState( + behavior: .primaryKey, pluginHint: .useAppDefault, + primaryKeyColumns: ["hidden_pk"], allColumns: columns + ) + #expect(!state.isSorting) + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 563461388..effa7da8c 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -166,7 +166,7 @@ Activate, deactivate, or view your license under **Settings** > **Account**. Val ## Data Grid -Data grid font, row height, date format, NULL display, page size, and row count estimation live on the **Editor** tab. See [Data Grid](/features/data-grid) for usage and behavior. +Data grid font, row height, date format, NULL display, page size, row count estimation, and default row sort live on the **Editor** tab. See [Data Grid](/features/data-grid) for usage and behavior. ## Settings Storage diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index fdb39d05c..c273b0b17 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -93,6 +93,24 @@ All database drivers are `.tableplugin` bundles loaded at runtime. This keeps th Built-in plugins ship inside the app bundle. Registry plugins are downloaded on demand from the [plugin registry](/development/plugin-registry). +### Opt-in Plugin Protocols + +Plugins can adopt additional protocols beyond `PluginDatabaseDriver` to expose extra capabilities. These are runtime-cast (`as?`) by `PluginManager`, so existing plugins that do not conform keep working without an ABI bump. + +| Protocol | Purpose | Lookup | +|----------|---------|--------| +| `PluginDiagnosticProvider` | Provide a user-facing diagnostic for driver errors | `PluginManager.diagnose(error:for:)` | +| `PluginProcedureFunctionSupport` | Expose stored procedures and functions in the structure tab | `PluginDriverAdapter` | +| `PluginDefaultSortProvider` | Override the app default-sort behavior for opened tables | `PluginManager.defaultSortHint(for:table:)` | + +A `PluginDefaultSortProvider` returns one of: + +- `.useAppDefault` (default if the plugin does not conform): apply the user setting (Primary key, First column, or No sorting). +- `.suppress`: never auto-sort tables opened with this plugin (useful for NoSQL engines where ORDER BY is not meaningful). +- `.forceColumns([String])`: ignore the user setting and sort by the supplied column names (for example MongoDB could force `_id`). + +The hint receives the table name, so plugins can vary the answer per table. + ## Key Components ### DatabaseManager diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index ed6e6b1b1..dfa401fb2 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -47,6 +47,12 @@ Click a column header to sort: - **Second click**: Sort descending (Z-A, 9-0) - **Third click**: Remove sort +By default tables open in engine order (no `ORDER BY`). To always sort by primary key or first column on open, set Settings > Data Grid > Default row sort. Options are **No sorting** (default), **Primary key**, and **First column**. Tables without a primary key fall back to engine order even when Primary key is selected. Click a column header at any time to replace the default sort. + + +If the resolved sort column is not orderable on the server (for example a `BLOB`, `JSON`, or spatial column with First column selected), the query fails. Switch the setting back to No sorting, or click a different column header. + + Sort applies to the full result. TablePro re-runs the query with `ORDER BY` appended; if your query already has an `ORDER BY`, it is replaced. {/* Screenshot: Column header with sort indicator */}