Skip to content

Commit d5c5dad

Browse files
authored
Merge pull request #582 from TableProApp/feat/ios-filter-bar
feat: filter bar for iOS data browser
2 parents 2b5f2bb + 1169412 commit d5c5dad

File tree

3 files changed

+268
-5
lines changed

3 files changed

+268
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- iOS: connection groups and tags
1313
- iOS: Quick Connect Home Screen widget
1414
- iOS: page-based pagination for data browser
15+
- iOS: filter bar with 16 operators, AND/OR logic
1516

1617
## [0.27.4] - 2026-04-05
1718

TableProMobile/TableProMobile/Helpers/SQLBuilder.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import Foundation
77
import TableProModels
8+
import TableProPluginKit
9+
import TableProQuery
810

911
enum SQLBuilder {
1012
static func quoteIdentifier(_ name: String, for type: DatabaseType) -> String {
@@ -78,4 +80,62 @@ enum SQLBuilder {
7880
}.joined(separator: ", ")
7981
return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))"
8082
}
83+
84+
static func buildFilteredSelect(
85+
table: String, type: DatabaseType,
86+
filters: [TableFilter], logicMode: FilterLogicMode,
87+
limit: Int, offset: Int
88+
) -> String {
89+
let dialect = dialectDescriptor(for: type)
90+
let generator = FilterSQLGenerator(dialect: dialect)
91+
let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode)
92+
let quoted = quoteIdentifier(table, for: type)
93+
if whereClause.isEmpty {
94+
return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)"
95+
}
96+
return "SELECT * FROM \(quoted) \(whereClause) LIMIT \(limit) OFFSET \(offset)"
97+
}
98+
99+
static func buildFilteredCount(
100+
table: String, type: DatabaseType,
101+
filters: [TableFilter], logicMode: FilterLogicMode
102+
) -> String {
103+
let dialect = dialectDescriptor(for: type)
104+
let generator = FilterSQLGenerator(dialect: dialect)
105+
let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode)
106+
let quoted = quoteIdentifier(table, for: type)
107+
if whereClause.isEmpty {
108+
return "SELECT COUNT(*) FROM \(quoted)"
109+
}
110+
return "SELECT COUNT(*) FROM \(quoted) \(whereClause)"
111+
}
112+
113+
private static func dialectDescriptor(for type: DatabaseType) -> SQLDialectDescriptor {
114+
switch type {
115+
case .mysql, .mariadb:
116+
return SQLDialectDescriptor(
117+
identifierQuote: "`",
118+
keywords: [],
119+
functions: [],
120+
dataTypes: [],
121+
likeEscapeStyle: .implicit,
122+
requiresBackslashEscaping: true
123+
)
124+
case .postgresql, .redshift:
125+
return SQLDialectDescriptor(
126+
identifierQuote: "\"",
127+
keywords: [],
128+
functions: [],
129+
dataTypes: [],
130+
likeEscapeStyle: .explicit
131+
)
132+
default:
133+
return SQLDialectDescriptor(
134+
identifierQuote: "\"",
135+
keywords: [],
136+
functions: [],
137+
dataTypes: []
138+
)
139+
}
140+
}
81141
}

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 207 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import os
77
import SwiftUI
88
import TableProDatabase
99
import TableProModels
10+
import TableProQuery
1011

1112
struct DataBrowserView: View {
1213
let connection: DatabaseConnection
@@ -29,6 +30,9 @@ struct DataBrowserView: View {
2930
@State private var showOperationError = false
3031
@State private var showGoToPage = false
3132
@State private var goToPageInput = ""
33+
@State private var filters: [TableFilter] = []
34+
@State private var filterLogicMode: FilterLogicMode = .and
35+
@State private var showFilterSheet = false
3236

3337
private var isView: Bool {
3438
table.type == .view || table.type == .materializedView
@@ -48,6 +52,10 @@ struct DataBrowserView: View {
4852
return "\(start)\(end)"
4953
}
5054

55+
private var hasActiveFilters: Bool {
56+
filters.contains { $0.isEnabled && $0.isValid }
57+
}
58+
5159
var body: some View {
5260
content
5361
.navigationTitle(table.name)
@@ -57,6 +65,15 @@ struct DataBrowserView: View {
5765
.toolbar { paginationToolbar }
5866
.task { await loadData(isInitial: true) }
5967
.sheet(isPresented: $showInsertSheet) { insertSheet }
68+
.sheet(isPresented: $showFilterSheet) {
69+
FilterSheetView(
70+
filters: $filters,
71+
logicMode: $filterLogicMode,
72+
columns: columns,
73+
onApply: { applyFilters() },
74+
onClear: { clearFilters() }
75+
)
76+
}
6077
.confirmationDialog("Delete Row", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
6178
Button("Delete", role: .destructive) {
6279
if let pkValues = deleteTarget {
@@ -111,6 +128,10 @@ struct DataBrowserView: View {
111128
}
112129
}
113130

131+
private var activeFilterCount: Int {
132+
filters.filter { $0.isEnabled && $0.isValid }.count
133+
}
134+
114135
private var rowList: some View {
115136
List {
116137
ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
@@ -156,6 +177,14 @@ struct DataBrowserView: View {
156177

157178
@ToolbarContentBuilder
158179
private var topToolbar: some ToolbarContent {
180+
ToolbarItem(placement: .topBarTrailing) {
181+
Button { showFilterSheet = true } label: {
182+
Image(systemName: hasActiveFilters
183+
? "line.3.horizontal.decrease.circle.fill"
184+
: "line.3.horizontal.decrease.circle")
185+
}
186+
.badge(activeFilterCount)
187+
}
159188
ToolbarItem(placement: .topBarTrailing) {
160189
NavigationLink {
161190
StructureView(table: table, session: session, databaseType: connection.type)
@@ -251,10 +280,19 @@ struct DataBrowserView: View {
251280
appError = nil
252281

253282
do {
254-
let query = SQLBuilder.buildSelect(
255-
table: table.name, type: connection.type,
256-
limit: pagination.pageSize, offset: pagination.currentOffset
257-
)
283+
let query: String
284+
if hasActiveFilters {
285+
query = SQLBuilder.buildFilteredSelect(
286+
table: table.name, type: connection.type,
287+
filters: filters, logicMode: filterLogicMode,
288+
limit: pagination.pageSize, offset: pagination.currentOffset
289+
)
290+
} else {
291+
query = SQLBuilder.buildSelect(
292+
table: table.name, type: connection.type,
293+
limit: pagination.pageSize, offset: pagination.currentOffset
294+
)
295+
}
258296
let result = try await session.driver.execute(query: query)
259297
columns = result.columns
260298
rows = result.rows
@@ -276,7 +314,15 @@ struct DataBrowserView: View {
276314

277315
private func fetchTotalRows(session: ConnectionSession) async {
278316
do {
279-
let countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type)
317+
let countQuery: String
318+
if hasActiveFilters {
319+
countQuery = SQLBuilder.buildFilteredCount(
320+
table: table.name, type: connection.type,
321+
filters: filters, logicMode: filterLogicMode
322+
)
323+
} else {
324+
countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type)
325+
}
280326
let countResult = try await session.driver.execute(query: countQuery)
281327
if let firstRow = countResult.rows.first, let firstCol = firstRow.first {
282328
pagination.totalRows = Int(firstCol ?? "0")
@@ -349,6 +395,162 @@ struct DataBrowserView: View {
349395
return (column: col.name, value: value)
350396
}
351397
}
398+
399+
private func applyFilters() {
400+
pagination.currentPage = 0
401+
pagination.totalRows = nil
402+
Task { await loadData() }
403+
}
404+
405+
private func clearFilters() {
406+
filters.removeAll()
407+
pagination.currentPage = 0
408+
pagination.totalRows = nil
409+
Task { await loadData() }
410+
}
411+
}
412+
413+
// MARK: - Filter Sheet
414+
415+
private struct FilterSheetView: View {
416+
@Environment(\.dismiss) private var dismiss
417+
@Binding var filters: [TableFilter]
418+
@Binding var logicMode: FilterLogicMode
419+
let columns: [ColumnInfo]
420+
let onApply: () -> Void
421+
let onClear: () -> Void
422+
423+
@State private var draft: [TableFilter] = []
424+
@State private var draftLogicMode: FilterLogicMode = .and
425+
426+
private var hasValidFilters: Bool {
427+
draft.contains { $0.isEnabled && $0.isValid }
428+
}
429+
430+
private func bindingForFilter(_ id: UUID) -> Binding<TableFilter>? {
431+
guard let index = draft.firstIndex(where: { $0.id == id }) else { return nil }
432+
return $draft[index]
433+
}
434+
435+
var body: some View {
436+
NavigationStack {
437+
Form {
438+
if draft.count > 1 {
439+
Section {
440+
Picker("Logic", selection: $draftLogicMode) {
441+
Text("AND").tag(FilterLogicMode.and)
442+
Text("OR").tag(FilterLogicMode.or)
443+
}
444+
.pickerStyle(.segmented)
445+
}
446+
}
447+
448+
ForEach(draft) { filter in
449+
if let binding = bindingForFilter(filter.id) {
450+
Section {
451+
Picker("Column", selection: binding.columnName) {
452+
ForEach(columns, id: \.name) { col in
453+
Text(col.name).tag(col.name)
454+
}
455+
}
456+
457+
Picker("Operator", selection: binding.filterOperator) {
458+
ForEach(FilterOperator.allCases, id: \.self) { op in
459+
Text(op.displayName).tag(op)
460+
}
461+
}
462+
463+
if filter.filterOperator.needsValue {
464+
TextField("Value", text: binding.value)
465+
.textInputAutocapitalization(.never)
466+
.autocorrectionDisabled()
467+
}
468+
469+
if filter.filterOperator == .between {
470+
TextField("Second value", text: binding.secondValue)
471+
.textInputAutocapitalization(.never)
472+
.autocorrectionDisabled()
473+
}
474+
}
475+
}
476+
}
477+
.onDelete { indexSet in
478+
draft.remove(atOffsets: indexSet)
479+
}
480+
481+
Section {
482+
Button {
483+
draft.append(TableFilter(columnName: columns.first?.name ?? ""))
484+
} label: {
485+
Label("Add Filter", systemImage: "plus.circle")
486+
}
487+
}
488+
489+
if !draft.isEmpty {
490+
Section {
491+
Button("Clear All Filters", role: .destructive) {
492+
filters.removeAll()
493+
logicMode = .and
494+
onClear()
495+
dismiss()
496+
}
497+
}
498+
}
499+
}
500+
.navigationTitle("Filters")
501+
.navigationBarTitleDisplayMode(.inline)
502+
.toolbar {
503+
ToolbarItem(placement: .cancellationAction) {
504+
Button("Cancel") { dismiss() }
505+
}
506+
ToolbarItem(placement: .confirmationAction) {
507+
Button("Apply") {
508+
filters = draft
509+
logicMode = draftLogicMode
510+
onApply()
511+
dismiss()
512+
}
513+
.disabled(!hasValidFilters)
514+
}
515+
}
516+
.onAppear {
517+
draft = filters
518+
draftLogicMode = logicMode
519+
}
520+
}
521+
}
522+
}
523+
524+
// MARK: - Filter Operator Display
525+
526+
extension FilterOperator {
527+
var displayName: String {
528+
switch self {
529+
case .equal: return "equals"
530+
case .notEqual: return "not equals"
531+
case .greaterThan: return "greater than"
532+
case .greaterThanOrEqual: return ""
533+
case .lessThan: return "less than"
534+
case .lessThanOrEqual: return ""
535+
case .like: return "like"
536+
case .notLike: return "not like"
537+
case .isNull: return "is null"
538+
case .isNotNull: return "is not null"
539+
case .in: return "in"
540+
case .notIn: return "not in"
541+
case .between: return "between"
542+
case .contains: return "contains"
543+
case .startsWith: return "starts with"
544+
case .endsWith: return "ends with"
545+
}
546+
}
547+
548+
var needsValue: Bool {
549+
switch self {
550+
case .isNull, .isNotNull: return false
551+
default: return true
552+
}
553+
}
352554
}
353555

354556
// MARK: - Row Card

0 commit comments

Comments
 (0)