Skip to content

Commit 9fc22b3

Browse files
authored
feat(datagrid): filter a table by a cell's value from the cell context menu (#3101)
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 044042e commit 9fc22b3

20 files changed

Lines changed: 938 additions & 16 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
- Tables from every schema in Open Quickly and the sidebar filter, and `schema.table` searches in both. (#3048)
5050
- Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524)
5151
- **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502)
52+
- **Filter** in a data grid cell's context menu, for narrowing a table to rows sharing that cell's value. (#3066)
5253
- Version history for saved queries, with **Restore This Version**. (#2505)
5354
- Git status letters, history and **Discard Changes…** for files in a linked SQL folder. (#2505)
5455
- Whether a materialized view can be refreshed concurrently, on its **Indexes** tab. (#2522)
@@ -182,6 +183,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
182183
- A dropped database or schema leaving every one of its tables' saved settings behind.
183184
- Favorites rows left behind by a dropped table, schema or database.
184185
- Column and operator pull-downs in **Highlight Rules** snapping back to their previous value, leaving every rule on **equals** and on the column it was created with. (#3015)
186+
- Lines of a multi-line value run together in the cell menu's **Highlight** titles.
185187
- Favorite queries missing from the sidebar Favorites tab on the first switch to it. (#3016)
186188
- Favorites list silently dropping rows during a burst of iCloud favorites updates.
187189
- A favorite moved out of **Global** still listed, and its keyword still expanding, in every other connection.

TablePro/Core/Coordinators/FilterCoordinator.swift

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,12 +541,79 @@ final class FilterCoordinator: ObservableObject {
541541
applyCommit(.solo(filter.id))
542542
}
543543

544+
/// Whether the selected tab's rows can be filtered from the grid: a table tab showing its rows,
545+
/// on an engine that filters by column rather than by a key pattern.
546+
var canFilterRows: Bool {
547+
guard let tab = parent.tabManager.selectedTab,
548+
tab.tabType == .table,
549+
tab.tableContext.tableName != nil,
550+
tab.display.resultsViewMode.showsRowFilters else { return false }
551+
return !usesBrowseSearch
552+
}
553+
554+
/// Narrows what the grid shows by one more condition, which a cell's Filter menu offers.
555+
func applyCellFilter(_ filter: TableFilter) {
556+
guard canFilterRows, filter.isValid,
557+
!Self.isRunning(filter, in: selectedTabFilterState) else { return }
558+
applyTransition { state in
559+
state = Self.cellFilterState(state, adding: filter)
560+
}
561+
}
562+
563+
/// Whether the rows on screen were already fetched with this condition, so adding it would
564+
/// change nothing but the page.
565+
static func isRunning(_ filter: TableFilter, in state: TabFilterState) -> Bool {
566+
guard state.executedFilters.contains(where: { $0.hasSameCondition(as: filter) }) else { return false }
567+
return state.filterLogicMode == .and || state.executedFilters.count == 1
568+
}
569+
570+
/// The filter state that shows what the grid showed, and only rows matching `filter` too.
571+
///
572+
/// What the grid showed is `executedFilters`, never `appliedFilters`: rows typed and never
573+
/// applied, and rows left in the panel by Clear, resolve as applied under `.all` without having
574+
/// run. So a row stays checked only when it is running, every other row is unchecked rather than
575+
/// removed, and the commit becomes `.all` over exactly the checked rows, which is also what the
576+
/// saved state restores. A row that already holds the condition is checked instead of repeated.
577+
///
578+
/// Under Match any, a condition can only be added to one running row or none, where the two
579+
/// modes agree and the mode becomes Match all. With two or more running rows the condition
580+
/// cannot join them, so it runs alone.
581+
static func cellFilterState(_ state: TabFilterState, adding filter: TableFilter) -> TabFilterState {
582+
let executedIDs = Set(state.executedFilters.map(\.id))
583+
let runningIDs = Set(state.filters.lazy.map(\.id).filter(executedIDs.contains))
584+
let keepsRunningRows = state.filterLogicMode == .and || runningIDs.count <= 1
585+
let keptIDs = keepsRunningRows ? runningIDs : []
586+
let existingID = state.filters.first { $0.hasSameCondition(as: filter) }?.id
587+
588+
var next = state
589+
next.filters = state.filters.map { row in
590+
var row = row
591+
row.isEnabled = keptIDs.contains(row.id) || row.id == existingID
592+
return row
593+
}
594+
if existingID == nil {
595+
var added = filter
596+
added.isEnabled = true
597+
next.filters.append(added)
598+
}
599+
if keepsRunningRows {
600+
next.filterLogicMode = .and
601+
}
602+
next.commit = .all
603+
next.isVisible = true
604+
return next
605+
}
606+
544607
/// Writes the commit, persists it and re-queries, all behind the discard guard.
545608
///
546609
/// Behind it, because `commit` is the record of what the rows on screen were fetched with.
547610
/// Setting it first and taking the guard afterwards left a declined apply reporting a filter
548611
/// the grid had never run, saved to disk, and re-run by the next page turn.
549612
private func applyCommit(_ commit: FilterCommit) {
613+
applyTransition { $0.commit = commit }
614+
}
615+
616+
private func applyTransition(_ transition: @escaping (inout TabFilterState) -> Void) {
550617
guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex,
551618
let tableName = tab.tableContext.tableName else { return }
552619

@@ -555,7 +622,7 @@ final class FilterCoordinator: ObservableObject {
555622
parent.confirmDiscardChangesIfNeeded(action: .filter) { [weak self] confirmed in
556623
guard let self, confirmed else { return }
557624
guard capturedTabIndex < parent.tabManager.tabs.count else { return }
558-
parent.tabManager.mutate(at: capturedTabIndex) { $0.filterState.commit = commit }
625+
mutateFilterState(at: capturedTabIndex, transition)
559626
commitFilters(
560627
parent.tabManager.tabs[capturedTabIndex].filterState.appliedFilters,
561628
logicMode: nil,

TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,41 @@ internal enum ColumnTypeSQLQuoting {
4141
static func isCharacterType(_ type: ColumnType?) -> Bool {
4242
guard case let .text(rawType)? = type else { return false }
4343
guard let rawType else { return true }
44-
let base = rawType.prefix { $0 != "(" }
45-
.trimmingCharacters(in: .whitespaces)
46-
.uppercased()
44+
let base = baseName(of: rawType)
4745
if characterBaseNames.contains(base) { return true }
4846
return base.contains("CHAR") || base.hasSuffix("TEXT")
4947
}
5048

49+
/// Whether `column = 'literal'` is a valid comparison for the column's own type. Large objects
50+
/// and XML have no equality operator on the engines that define them (Oracle ORA-00932, SQL
51+
/// Server error 402, PostgreSQL `operator does not exist: xml = unknown`), and neither does
52+
/// PostgreSQL's `json`.
53+
static func hasEqualityOperator(_ type: ColumnType) -> Bool {
54+
switch type {
55+
case .text(let rawType):
56+
guard let rawType else { return true }
57+
return !typesWithoutEquality.contains(baseName(of: rawType))
58+
case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .enumType, .set:
59+
return true
60+
case .blob, .json, .spatial, .array:
61+
return false
62+
}
63+
}
64+
65+
private static func baseName(of rawType: String) -> String {
66+
rawType.prefix { $0 != "(" }
67+
.trimmingCharacters(in: .whitespaces)
68+
.uppercased()
69+
}
70+
5171
private static let characterBaseNames: Set<String> = [
5272
"STRING", "FIXEDSTRING", "CLOB", "NCLOB", "NAME", "CITEXT"
5373
]
5474

75+
private static let typesWithoutEquality: Set<String> = [
76+
"CLOB", "NCLOB", "NTEXT", "LONG", "XML", "XMLTYPE"
77+
]
78+
5579
static func supportsEmptyStringComparison(_ type: ColumnType?) -> Bool {
5680
guard let type else { return true }
5781
return isKnownTextLike(type)

TablePro/Models/Database/TableFilter.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,17 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable {
222222
}
223223

224224
extension TableFilter {
225+
/// Whether both rows select the same rows, whatever their id, position or enabled flag.
226+
func hasSameCondition(as other: TableFilter) -> Bool {
227+
columnName == other.columnName
228+
&& filterOperator == other.filterOperator
229+
&& value == other.value
230+
&& secondValue == other.secondValue
231+
&& rawSQL == other.rawSQL
232+
&& isCaseSensitive == other.isCaseSensitive
233+
&& elementScope == other.elementScope
234+
}
235+
225236
/// The joined `value` stays as it was for drivers that still read a `BETWEEN` as one
226237
/// comma-separated string; `secondValue` carries the upper bound intact for those that don't,
227238
/// so a bound holding a comma is no longer mistaken for the separator.

TablePro/Models/Highlight/HighlightRuleDescription.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,14 @@ enum HighlightRuleDescription {
4747
)
4848
}
4949

50+
/// A limited value is shown on one line, so its line breaks become spaces: `NSMenu` lays a
51+
/// title's line break out as nothing at all, which ran the two lines together.
5052
static func truncated(_ value: String, to limit: Int?) -> String {
5153
guard let limit, limit > 0 else { return value }
5254
let source = value as NSString
53-
guard source.length > limit else { return value }
55+
guard source.length > limit else { return value.sanitizedForCellDisplay }
5456
let cut = source.rangeOfComposedCharacterSequence(at: limit).location
55-
return source.substring(to: cut) + "\u{2026}"
57+
return source.substring(to: cut).sanitizedForCellDisplay + "\u{2026}"
5658
}
5759

5860
private static func operatorText(_ filterOperator: FilterOperator) -> String {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// CellFilterMenuBuilder.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import TableProPluginKit
8+
9+
@MainActor
10+
enum CellFilterMenuBuilder {
11+
static let maxValueLength = 10_000
12+
13+
/// The conditions a cell can offer, each one matching the row it came from.
14+
///
15+
/// The value goes into the same filter a reader could type, so it is offered only where that
16+
/// filter matches it exactly. The SQL generators trim a value and read the word `NULL` as SQL
17+
/// NULL outside text columns, a column whose type is unresolved has its literals guessed at,
18+
/// and some types have no `=` at all, so any of those gets nothing rather than a filter that
19+
/// would drop the row the reader clicked.
20+
static func conditions(
21+
columnName: String,
22+
columnType: ColumnType?,
23+
value: PluginCellValue
24+
) -> [TableFilter] {
25+
switch value {
26+
case .null:
27+
return [.isNull, .isNotNull].map { filter(columnName, $0) }
28+
case .text(let text) where text.isEmpty:
29+
guard let columnType, ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return [] }
30+
return [.isEmpty, .isNotEmpty].map { filter(columnName, $0) }
31+
case .text(let text):
32+
guard let columnType, matchesExactly(text, columnType: columnType) else { return [] }
33+
return comparisonOperators(for: columnType).map { filter(columnName, $0, value: text) }
34+
case .bytes:
35+
return []
36+
}
37+
}
38+
39+
static func title(for filter: TableFilter) -> String {
40+
HighlightRuleDescription.condition(
41+
columnName: filter.columnName,
42+
filterOperator: filter.filterOperator,
43+
value: filter.value,
44+
secondValue: filter.secondValue,
45+
valueLimit: HighlightRuleDescription.menuValueLimit
46+
)
47+
}
48+
49+
static func menuItem(
50+
columnName: String,
51+
columnType: ColumnType?,
52+
value: PluginCellValue,
53+
apply: @escaping (TableFilter) -> Void
54+
) -> NSMenuItem? {
55+
let filters = conditions(columnName: columnName, columnType: columnType, value: value)
56+
guard !filters.isEmpty else { return nil }
57+
58+
let submenu = NSMenu()
59+
for filter in filters {
60+
submenu.addItem(ClosureMenuTarget.item(title: title(for: filter)) { apply(filter) })
61+
}
62+
63+
let item = NSMenuItem(title: String(localized: "Filter"), action: nil, keyEquivalent: "")
64+
item.image = NSImage(systemSymbolName: "line.3.horizontal.decrease.circle", accessibilityDescription: nil)
65+
item.submenu = submenu
66+
return item
67+
}
68+
69+
private static func matchesExactly(_ text: String, columnType: ColumnType) -> Bool {
70+
guard ColumnTypeSQLQuoting.hasEqualityOperator(columnType),
71+
(text as NSString).length <= maxValueLength,
72+
text == text.trimmingCharacters(in: .whitespaces) else { return false }
73+
return !HighlightCondition.readsAsNullLiteral(text, columnType: columnType)
74+
}
75+
76+
private static func comparisonOperators(for columnType: ColumnType) -> [FilterOperator] {
77+
switch columnType {
78+
case .integer, .decimal, .date, .timestamp, .datetime:
79+
return [.equal, .notEqual, .greaterThan, .lessThan]
80+
case .text, .boolean, .enumType, .set, .blob, .json, .spatial, .array:
81+
return [.equal, .notEqual]
82+
}
83+
}
84+
85+
private static func filter(_ columnName: String, _ filterOperator: FilterOperator, value: String = "") -> TableFilter {
86+
TableFilter(columnName: columnName, filterOperator: filterOperator, value: value)
87+
}
88+
}

TablePro/Views/Main/Child/DataTabGridDelegate.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,29 @@ final class DataTabGridDelegate: DataGridViewDelegate {
160160
return HighlightMenuBuilder.menuItem(for: context, actions: actions)
161161
}
162162

163+
/// Offered only for a value the server holds: a row the reader inserted, or a cell they edited,
164+
/// is not on the server yet, so a filter built from it cannot find the row it came from.
165+
func dataGridFilterMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? {
166+
guard let coordinator, coordinator.canFilterRows,
167+
let grid = tableViewCoordinator,
168+
let tab = coordinator.tabManager.selectedTab,
169+
let row = grid.displayRow(at: displayRow) else { return nil }
170+
let visualState = grid.visualState(for: displayRow)
171+
guard !visualState.isInserted, !visualState.isModified(columnIndex: dataColumn) else { return nil }
172+
let tableRows = grid.tableRowsProvider()
173+
let columns = tableRows.columns
174+
guard columns.indices.contains(dataColumn), dataColumn < row.values.count else { return nil }
175+
176+
let tabId = tab.id
177+
return CellFilterMenuBuilder.menuItem(
178+
columnName: columns[dataColumn],
179+
columnType: dataColumn < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumn] : nil,
180+
value: row.values[dataColumn]
181+
) { [weak coordinator] filter in
182+
coordinator?.applyCellFilter(filter, forTab: tabId)
183+
}
184+
}
185+
163186
func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? {
164187
guard coordinator != nil, let grid = tableViewCoordinator else { return nil }
165188
let columns = grid.tableRowsProvider().columns

TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ extension MainContentCoordinator {
7272
filterCoordinator.applySoloFilter(filter)
7373
}
7474

75+
var canFilterRows: Bool {
76+
filterCoordinator.canFilterRows
77+
}
78+
79+
func applyCellFilter(_ filter: TableFilter, forTab tabId: UUID) {
80+
guard tabManager.selectedTab?.id == tabId else { return }
81+
filterCoordinator.applyCellFilter(filter)
82+
}
83+
7584
func toggleFilterPanel() {
7685
filterCoordinator.toggleFilterPanel()
7786
}

TablePro/Views/Results/DataGridRowView.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -516,13 +516,7 @@ class DataGridRowView: NSTableRowView {
516516
menu.addItem(jsonViewItem)
517517
}
518518

519-
if dataColumnIndex >= 0,
520-
let highlightItem = coordinator.delegate?.dataGridHighlightMenuItem(
521-
forRow: rowIndex,
522-
dataColumn: dataColumnIndex
523-
) {
524-
menu.addItem(highlightItem)
525-
}
519+
addCellValueMenuItems(to: menu, dataColumnIndex: dataColumnIndex, delegate: coordinator.delegate)
526520

527521
let tableRows = coordinator.tableRowsProvider()
528522
addForeignKeyMenuItems(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows)
@@ -591,6 +585,20 @@ class DataGridRowView: NSTableRowView {
591585
return menu
592586
}
593587

588+
private func addCellValueMenuItems(
589+
to menu: NSMenu,
590+
dataColumnIndex: Int,
591+
delegate: (any DataGridViewDelegate)?
592+
) {
593+
guard dataColumnIndex >= 0, let delegate else { return }
594+
if let filterItem = delegate.dataGridFilterMenuItem(forRow: rowIndex, dataColumn: dataColumnIndex) {
595+
menu.addItem(filterItem)
596+
}
597+
if let highlightItem = delegate.dataGridHighlightMenuItem(forRow: rowIndex, dataColumn: dataColumnIndex) {
598+
menu.addItem(highlightItem)
599+
}
600+
}
601+
594602
private func buildSetValueMenu(dataColumnIndex: Int, tableRows: TableRows) -> NSMenu {
595603
let setValueMenu = NSMenu()
596604

TablePro/Views/Results/DataGridViewDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ protocol DataGridViewDelegate: AnyObject {
3232
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem]
3333
func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem]
3434
func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem?
35+
func dataGridFilterMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem?
3536
func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem?
3637
func dataGridVisualState(forRow row: Int) -> RowVisualState?
3738
func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView?
@@ -83,6 +84,7 @@ extension DataGridViewDelegate {
8384
func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] }
8485
func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { [] }
8586
func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { nil }
87+
func dataGridFilterMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { nil }
8688
func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { nil }
8789
func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil }
8890
func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil }

0 commit comments

Comments
 (0)