Skip to content

Commit 5821789

Browse files
committed
feat: filter bar for iOS data browser — column, operator, value with AND/OR logic
1 parent 2b5f2bb commit 5821789

File tree

2 files changed

+230
-5
lines changed

2 files changed

+230
-5
lines changed

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: 170 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 showFilterBar = 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)
@@ -113,6 +121,48 @@ struct DataBrowserView: View {
113121

114122
private var rowList: some View {
115123
List {
124+
if showFilterBar {
125+
Section {
126+
if filters.count > 1 {
127+
Picker("Logic", selection: $filterLogicMode) {
128+
Text("AND").tag(FilterLogicMode.and)
129+
Text("OR").tag(FilterLogicMode.or)
130+
}
131+
.pickerStyle(.segmented)
132+
}
133+
134+
ForEach($filters) { $filter in
135+
FilterRowView(
136+
filter: $filter,
137+
columns: columns,
138+
onDelete: { filters.removeAll { $0.id == filter.id } }
139+
)
140+
}
141+
142+
HStack {
143+
Button {
144+
filters.append(TableFilter(columnName: columns.first?.name ?? ""))
145+
} label: {
146+
Label("Add Filter", systemImage: "plus.circle")
147+
}
148+
Spacer()
149+
Button("Apply") {
150+
applyFilters()
151+
}
152+
.buttonStyle(.borderedProminent)
153+
.disabled(!hasActiveFilters)
154+
}
155+
156+
if hasActiveFilters {
157+
Button("Clear Filters", role: .destructive) {
158+
clearFilters()
159+
}
160+
}
161+
} header: {
162+
Text("Filters")
163+
}
164+
}
165+
116166
ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
117167
NavigationLink {
118168
RowDetailView(
@@ -156,6 +206,13 @@ struct DataBrowserView: View {
156206

157207
@ToolbarContentBuilder
158208
private var topToolbar: some ToolbarContent {
209+
ToolbarItem(placement: .topBarTrailing) {
210+
Button { withAnimation { showFilterBar.toggle() } } label: {
211+
Image(systemName: hasActiveFilters
212+
? "line.3.horizontal.decrease.circle.fill"
213+
: "line.3.horizontal.decrease.circle")
214+
}
215+
}
159216
ToolbarItem(placement: .topBarTrailing) {
160217
NavigationLink {
161218
StructureView(table: table, session: session, databaseType: connection.type)
@@ -251,10 +308,19 @@ struct DataBrowserView: View {
251308
appError = nil
252309

253310
do {
254-
let query = SQLBuilder.buildSelect(
255-
table: table.name, type: connection.type,
256-
limit: pagination.pageSize, offset: pagination.currentOffset
257-
)
311+
let query: String
312+
if hasActiveFilters {
313+
query = SQLBuilder.buildFilteredSelect(
314+
table: table.name, type: connection.type,
315+
filters: filters, logicMode: filterLogicMode,
316+
limit: pagination.pageSize, offset: pagination.currentOffset
317+
)
318+
} else {
319+
query = SQLBuilder.buildSelect(
320+
table: table.name, type: connection.type,
321+
limit: pagination.pageSize, offset: pagination.currentOffset
322+
)
323+
}
258324
let result = try await session.driver.execute(query: query)
259325
columns = result.columns
260326
rows = result.rows
@@ -276,7 +342,15 @@ struct DataBrowserView: View {
276342

277343
private func fetchTotalRows(session: ConnectionSession) async {
278344
do {
279-
let countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type)
345+
let countQuery: String
346+
if hasActiveFilters {
347+
countQuery = SQLBuilder.buildFilteredCount(
348+
table: table.name, type: connection.type,
349+
filters: filters, logicMode: filterLogicMode
350+
)
351+
} else {
352+
countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type)
353+
}
280354
let countResult = try await session.driver.execute(query: countQuery)
281355
if let firstRow = countResult.rows.first, let firstCol = firstRow.first {
282356
pagination.totalRows = Int(firstCol ?? "0")
@@ -349,6 +423,97 @@ struct DataBrowserView: View {
349423
return (column: col.name, value: value)
350424
}
351425
}
426+
427+
private func applyFilters() {
428+
pagination.currentPage = 0
429+
pagination.totalRows = nil
430+
Task { await loadData() }
431+
}
432+
433+
private func clearFilters() {
434+
filters.removeAll()
435+
pagination.currentPage = 0
436+
pagination.totalRows = nil
437+
Task { await loadData() }
438+
}
439+
}
440+
441+
// MARK: - Filter Row
442+
443+
private struct FilterRowView: View {
444+
@Binding var filter: TableFilter
445+
let columns: [ColumnInfo]
446+
let onDelete: () -> Void
447+
448+
var body: some View {
449+
VStack(spacing: 8) {
450+
HStack {
451+
Picker("Column", selection: $filter.columnName) {
452+
ForEach(columns, id: \.name) { col in
453+
Text(col.name).tag(col.name)
454+
}
455+
}
456+
.pickerStyle(.menu)
457+
458+
Button(role: .destructive) { onDelete() } label: {
459+
Image(systemName: "minus.circle.fill")
460+
.foregroundStyle(.red)
461+
}
462+
.buttonStyle(.plain)
463+
}
464+
465+
Picker("Operator", selection: $filter.filterOperator) {
466+
ForEach(FilterOperator.allCases, id: \.self) { op in
467+
Text(op.displayName).tag(op)
468+
}
469+
}
470+
.pickerStyle(.menu)
471+
472+
if filter.filterOperator.needsValue {
473+
TextField("Value", text: $filter.value)
474+
.textInputAutocapitalization(.never)
475+
.autocorrectionDisabled()
476+
}
477+
478+
if filter.filterOperator == .between {
479+
TextField("Second value", text: $filter.secondValue)
480+
.textInputAutocapitalization(.never)
481+
.autocorrectionDisabled()
482+
}
483+
}
484+
}
485+
}
486+
487+
// MARK: - Filter Operator Display
488+
489+
extension FilterOperator {
490+
var displayName: String {
491+
switch self {
492+
case .equal: return "equals"
493+
case .notEqual: return "not equals"
494+
case .greaterThan: return "greater than"
495+
case .greaterThanOrEqual: return ""
496+
case .lessThan: return "less than"
497+
case .lessThanOrEqual: return ""
498+
case .like: return "like"
499+
case .notLike: return "not like"
500+
case .isNull: return "is null"
501+
case .isNotNull: return "is not null"
502+
case .in: return "in"
503+
case .notIn: return "not in"
504+
case .between: return "between"
505+
case .contains: return "contains"
506+
case .startsWith: return "starts with"
507+
case .endsWith: return "ends with"
508+
}
509+
}
510+
511+
var needsValue: Bool {
512+
switch self {
513+
case .isNull, .isNotNull: return false
514+
default: return true
515+
}
516+
}
352517
}
353518

354519
// MARK: - Row Card

0 commit comments

Comments
 (0)