From 582178933561cba198a88cfd72f04b6a8f7cd69a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:04:49 +0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20filter=20bar=20for=20iOS=20data=20b?= =?UTF-8?q?rowser=20=E2=80=94=20column,=20operator,=20value=20with=20AND/O?= =?UTF-8?q?R=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProMobile/Helpers/SQLBuilder.swift | 60 ++++++ .../Views/DataBrowserView.swift | 175 +++++++++++++++++- 2 files changed, 230 insertions(+), 5 deletions(-) diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 2c7fb1859..1ad24d93b 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -5,6 +5,8 @@ import Foundation import TableProModels +import TableProPluginKit +import TableProQuery enum SQLBuilder { static func quoteIdentifier(_ name: String, for type: DatabaseType) -> String { @@ -78,4 +80,62 @@ enum SQLBuilder { }.joined(separator: ", ") return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))" } + + static func buildFilteredSelect( + table: String, type: DatabaseType, + filters: [TableFilter], logicMode: FilterLogicMode, + limit: Int, offset: Int + ) -> String { + let dialect = dialectDescriptor(for: type) + let generator = FilterSQLGenerator(dialect: dialect) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let quoted = quoteIdentifier(table, for: type) + if whereClause.isEmpty { + return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" + } + return "SELECT * FROM \(quoted) \(whereClause) LIMIT \(limit) OFFSET \(offset)" + } + + static func buildFilteredCount( + table: String, type: DatabaseType, + filters: [TableFilter], logicMode: FilterLogicMode + ) -> String { + let dialect = dialectDescriptor(for: type) + let generator = FilterSQLGenerator(dialect: dialect) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + let quoted = quoteIdentifier(table, for: type) + if whereClause.isEmpty { + return "SELECT COUNT(*) FROM \(quoted)" + } + return "SELECT COUNT(*) FROM \(quoted) \(whereClause)" + } + + private static func dialectDescriptor(for type: DatabaseType) -> SQLDialectDescriptor { + switch type { + case .mysql, .mariadb: + return SQLDialectDescriptor( + identifierQuote: "`", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .implicit, + requiresBackslashEscaping: true + ) + case .postgresql, .redshift: + return SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .explicit + ) + default: + return SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [] + ) + } + } } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 867f20e15..b4658968f 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -7,6 +7,7 @@ import os import SwiftUI import TableProDatabase import TableProModels +import TableProQuery struct DataBrowserView: View { let connection: DatabaseConnection @@ -29,6 +30,9 @@ struct DataBrowserView: View { @State private var showOperationError = false @State private var showGoToPage = false @State private var goToPageInput = "" + @State private var filters: [TableFilter] = [] + @State private var filterLogicMode: FilterLogicMode = .and + @State private var showFilterBar = false private var isView: Bool { table.type == .view || table.type == .materializedView @@ -48,6 +52,10 @@ struct DataBrowserView: View { return "\(start)–\(end)" } + private var hasActiveFilters: Bool { + filters.contains { $0.isEnabled && $0.isValid } + } + var body: some View { content .navigationTitle(table.name) @@ -113,6 +121,48 @@ struct DataBrowserView: View { private var rowList: some View { List { + if showFilterBar { + Section { + if filters.count > 1 { + Picker("Logic", selection: $filterLogicMode) { + Text("AND").tag(FilterLogicMode.and) + Text("OR").tag(FilterLogicMode.or) + } + .pickerStyle(.segmented) + } + + ForEach($filters) { $filter in + FilterRowView( + filter: $filter, + columns: columns, + onDelete: { filters.removeAll { $0.id == filter.id } } + ) + } + + HStack { + Button { + filters.append(TableFilter(columnName: columns.first?.name ?? "")) + } label: { + Label("Add Filter", systemImage: "plus.circle") + } + Spacer() + Button("Apply") { + applyFilters() + } + .buttonStyle(.borderedProminent) + .disabled(!hasActiveFilters) + } + + if hasActiveFilters { + Button("Clear Filters", role: .destructive) { + clearFilters() + } + } + } header: { + Text("Filters") + } + } + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in NavigationLink { RowDetailView( @@ -156,6 +206,13 @@ struct DataBrowserView: View { @ToolbarContentBuilder private var topToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { withAnimation { showFilterBar.toggle() } } label: { + Image(systemName: hasActiveFilters + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + } ToolbarItem(placement: .topBarTrailing) { NavigationLink { StructureView(table: table, session: session, databaseType: connection.type) @@ -251,10 +308,19 @@ struct DataBrowserView: View { appError = nil do { - let query = SQLBuilder.buildSelect( - table: table.name, type: connection.type, - limit: pagination.pageSize, offset: pagination.currentOffset - ) + let query: String + if hasActiveFilters { + query = SQLBuilder.buildFilteredSelect( + table: table.name, type: connection.type, + filters: filters, logicMode: filterLogicMode, + limit: pagination.pageSize, offset: pagination.currentOffset + ) + } else { + query = SQLBuilder.buildSelect( + table: table.name, type: connection.type, + limit: pagination.pageSize, offset: pagination.currentOffset + ) + } let result = try await session.driver.execute(query: query) columns = result.columns rows = result.rows @@ -276,7 +342,15 @@ struct DataBrowserView: View { private func fetchTotalRows(session: ConnectionSession) async { do { - let countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type) + let countQuery: String + if hasActiveFilters { + countQuery = SQLBuilder.buildFilteredCount( + table: table.name, type: connection.type, + filters: filters, logicMode: filterLogicMode + ) + } else { + countQuery = SQLBuilder.buildCount(table: table.name, type: connection.type) + } let countResult = try await session.driver.execute(query: countQuery) if let firstRow = countResult.rows.first, let firstCol = firstRow.first { pagination.totalRows = Int(firstCol ?? "0") @@ -349,6 +423,97 @@ struct DataBrowserView: View { return (column: col.name, value: value) } } + + private func applyFilters() { + pagination.currentPage = 0 + pagination.totalRows = nil + Task { await loadData() } + } + + private func clearFilters() { + filters.removeAll() + pagination.currentPage = 0 + pagination.totalRows = nil + Task { await loadData() } + } +} + +// MARK: - Filter Row + +private struct FilterRowView: View { + @Binding var filter: TableFilter + let columns: [ColumnInfo] + let onDelete: () -> Void + + var body: some View { + VStack(spacing: 8) { + HStack { + Picker("Column", selection: $filter.columnName) { + ForEach(columns, id: \.name) { col in + Text(col.name).tag(col.name) + } + } + .pickerStyle(.menu) + + Button(role: .destructive) { onDelete() } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + + Picker("Operator", selection: $filter.filterOperator) { + ForEach(FilterOperator.allCases, id: \.self) { op in + Text(op.displayName).tag(op) + } + } + .pickerStyle(.menu) + + if filter.filterOperator.needsValue { + TextField("Value", text: $filter.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + if filter.filterOperator == .between { + TextField("Second value", text: $filter.secondValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } +} + +// MARK: - Filter Operator Display + +extension FilterOperator { + var displayName: String { + switch self { + case .equal: return "equals" + case .notEqual: return "not equals" + case .greaterThan: return "greater than" + case .greaterThanOrEqual: return "≥" + case .lessThan: return "less than" + case .lessThanOrEqual: return "≤" + case .like: return "like" + case .notLike: return "not like" + case .isNull: return "is null" + case .isNotNull: return "is not null" + case .in: return "in" + case .notIn: return "not in" + case .between: return "between" + case .contains: return "contains" + case .startsWith: return "starts with" + case .endsWith: return "ends with" + } + } + + var needsValue: Bool { + switch self { + case .isNull, .isNotNull: return false + default: return true + } + } } // MARK: - Row Card From 4c4b9fda1f88ae4de48d2c8b976040c1e3a8d021 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:20:33 +0700 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20move=20filters=20to=20native=20?= =?UTF-8?q?Form=20sheet=20=E2=80=94=20remove=20inline=20list=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DataBrowserView.swift | 161 ++++++++++-------- 1 file changed, 88 insertions(+), 73 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index b4658968f..a652a4d9a 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -32,7 +32,7 @@ struct DataBrowserView: View { @State private var goToPageInput = "" @State private var filters: [TableFilter] = [] @State private var filterLogicMode: FilterLogicMode = .and - @State private var showFilterBar = false + @State private var showFilterSheet = false private var isView: Bool { table.type == .view || table.type == .materializedView @@ -65,6 +65,15 @@ struct DataBrowserView: View { .toolbar { paginationToolbar } .task { await loadData(isInitial: true) } .sheet(isPresented: $showInsertSheet) { insertSheet } + .sheet(isPresented: $showFilterSheet) { + FilterSheetView( + filters: $filters, + logicMode: $filterLogicMode, + columns: columns, + onApply: { applyFilters() }, + onClear: { clearFilters() } + ) + } .confirmationDialog("Delete Row", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { Button("Delete", role: .destructive) { if let pkValues = deleteTarget { @@ -119,50 +128,12 @@ struct DataBrowserView: View { } } + private var activeFilterCount: Int { + filters.filter { $0.isEnabled && $0.isValid }.count + } + private var rowList: some View { List { - if showFilterBar { - Section { - if filters.count > 1 { - Picker("Logic", selection: $filterLogicMode) { - Text("AND").tag(FilterLogicMode.and) - Text("OR").tag(FilterLogicMode.or) - } - .pickerStyle(.segmented) - } - - ForEach($filters) { $filter in - FilterRowView( - filter: $filter, - columns: columns, - onDelete: { filters.removeAll { $0.id == filter.id } } - ) - } - - HStack { - Button { - filters.append(TableFilter(columnName: columns.first?.name ?? "")) - } label: { - Label("Add Filter", systemImage: "plus.circle") - } - Spacer() - Button("Apply") { - applyFilters() - } - .buttonStyle(.borderedProminent) - .disabled(!hasActiveFilters) - } - - if hasActiveFilters { - Button("Clear Filters", role: .destructive) { - clearFilters() - } - } - } header: { - Text("Filters") - } - } - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in NavigationLink { RowDetailView( @@ -207,11 +178,12 @@ struct DataBrowserView: View { @ToolbarContentBuilder private var topToolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { - Button { withAnimation { showFilterBar.toggle() } } label: { + Button { showFilterSheet = true } label: { Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } + .badge(activeFilterCount) } ToolbarItem(placement: .topBarTrailing) { NavigationLink { @@ -438,47 +410,90 @@ struct DataBrowserView: View { } } -// MARK: - Filter Row +// MARK: - Filter Sheet -private struct FilterRowView: View { - @Binding var filter: TableFilter +private struct FilterSheetView: View { + @Environment(\.dismiss) private var dismiss + @Binding var filters: [TableFilter] + @Binding var logicMode: FilterLogicMode let columns: [ColumnInfo] - let onDelete: () -> Void + let onApply: () -> Void + let onClear: () -> Void var body: some View { - VStack(spacing: 8) { - HStack { - Picker("Column", selection: $filter.columnName) { - ForEach(columns, id: \.name) { col in - Text(col.name).tag(col.name) + NavigationStack { + Form { + if filters.count > 1 { + Section { + Picker("Logic", selection: $logicMode) { + Text("AND").tag(FilterLogicMode.and) + Text("OR").tag(FilterLogicMode.or) + } + .pickerStyle(.segmented) } } - .pickerStyle(.menu) - Button(role: .destructive) { onDelete() } label: { - Image(systemName: "minus.circle.fill") - .foregroundStyle(.red) + ForEach($filters) { $filter in + Section { + Picker("Column", selection: $filter.columnName) { + ForEach(columns, id: \.name) { col in + Text(col.name).tag(col.name) + } + } + + Picker("Operator", selection: $filter.filterOperator) { + ForEach(FilterOperator.allCases, id: \.self) { op in + Text(op.displayName).tag(op) + } + } + + if filter.filterOperator.needsValue { + TextField("Value", text: $filter.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + if filter.filterOperator == .between { + TextField("Second value", text: $filter.secondValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } + .onDelete { indexSet in + filters.remove(atOffsets: indexSet) } - .buttonStyle(.plain) - } - Picker("Operator", selection: $filter.filterOperator) { - ForEach(FilterOperator.allCases, id: \.self) { op in - Text(op.displayName).tag(op) + Section { + Button { + filters.append(TableFilter(columnName: columns.first?.name ?? "")) + } label: { + Label("Add Filter", systemImage: "plus.circle") + } } - } - .pickerStyle(.menu) - if filter.filterOperator.needsValue { - TextField("Value", text: $filter.value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + if !filters.isEmpty { + Section { + Button("Clear All Filters", role: .destructive) { + onClear() + dismiss() + } + } + } } - - if filter.filterOperator == .between { - TextField("Second value", text: $filter.secondValue) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + onApply() + dismiss() + } + .disabled(!filters.contains { $0.isEnabled && $0.isValid }) + } } } } From 95aadc39558d8a5c170efdca6f7c5a7e02700fa6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:23:49 +0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20filter=20sheet=20crash=20=E2=80=94?= =?UTF-8?q?=20replace=20ForEach($filters)=20with=20safe=20manual=20binding?= =?UTF-8?q?=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DataBrowserView.swift | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index a652a4d9a..b75c81d72 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -420,6 +420,11 @@ private struct FilterSheetView: View { let onApply: () -> Void let onClear: () -> Void + private func bindingForFilter(_ id: UUID) -> Binding? { + guard let index = filters.firstIndex(where: { $0.id == id }) else { return nil } + return $filters[index] + } + var body: some View { NavigationStack { Form { @@ -433,30 +438,32 @@ private struct FilterSheetView: View { } } - ForEach($filters) { $filter in - Section { - Picker("Column", selection: $filter.columnName) { - ForEach(columns, id: \.name) { col in - Text(col.name).tag(col.name) + ForEach(filters) { filter in + if let binding = bindingForFilter(filter.id) { + Section { + Picker("Column", selection: binding.columnName) { + ForEach(columns, id: \.name) { col in + Text(col.name).tag(col.name) + } } - } - Picker("Operator", selection: $filter.filterOperator) { - ForEach(FilterOperator.allCases, id: \.self) { op in - Text(op.displayName).tag(op) + Picker("Operator", selection: binding.filterOperator) { + ForEach(FilterOperator.allCases, id: \.self) { op in + Text(op.displayName).tag(op) + } } - } - if filter.filterOperator.needsValue { - TextField("Value", text: $filter.value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } + if filter.filterOperator.needsValue { + TextField("Value", text: binding.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } - if filter.filterOperator == .between { - TextField("Second value", text: $filter.secondValue) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + if filter.filterOperator == .between { + TextField("Second value", text: binding.secondValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } } } } From 1169412849879f319edc7b213a2c107863100458 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 21:30:19 +0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20filter=20sheet=20cancel=20reverts=20?= =?UTF-8?q?changes=20=E2=80=94=20use=20local=20draft=20state,=20add=20CHAN?= =?UTF-8?q?GELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Views/DataBrowserView.swift | 33 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4488d315c..886bfd959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: connection groups and tags - iOS: Quick Connect Home Screen widget - iOS: page-based pagination for data browser +- iOS: filter bar with 16 operators, AND/OR logic ## [0.27.4] - 2026-04-05 diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index b75c81d72..9c802869e 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -420,17 +420,24 @@ private struct FilterSheetView: View { let onApply: () -> Void let onClear: () -> Void + @State private var draft: [TableFilter] = [] + @State private var draftLogicMode: FilterLogicMode = .and + + private var hasValidFilters: Bool { + draft.contains { $0.isEnabled && $0.isValid } + } + private func bindingForFilter(_ id: UUID) -> Binding? { - guard let index = filters.firstIndex(where: { $0.id == id }) else { return nil } - return $filters[index] + guard let index = draft.firstIndex(where: { $0.id == id }) else { return nil } + return $draft[index] } var body: some View { NavigationStack { Form { - if filters.count > 1 { + if draft.count > 1 { Section { - Picker("Logic", selection: $logicMode) { + Picker("Logic", selection: $draftLogicMode) { Text("AND").tag(FilterLogicMode.and) Text("OR").tag(FilterLogicMode.or) } @@ -438,7 +445,7 @@ private struct FilterSheetView: View { } } - ForEach(filters) { filter in + ForEach(draft) { filter in if let binding = bindingForFilter(filter.id) { Section { Picker("Column", selection: binding.columnName) { @@ -468,20 +475,22 @@ private struct FilterSheetView: View { } } .onDelete { indexSet in - filters.remove(atOffsets: indexSet) + draft.remove(atOffsets: indexSet) } Section { Button { - filters.append(TableFilter(columnName: columns.first?.name ?? "")) + draft.append(TableFilter(columnName: columns.first?.name ?? "")) } label: { Label("Add Filter", systemImage: "plus.circle") } } - if !filters.isEmpty { + if !draft.isEmpty { Section { Button("Clear All Filters", role: .destructive) { + filters.removeAll() + logicMode = .and onClear() dismiss() } @@ -496,12 +505,18 @@ private struct FilterSheetView: View { } ToolbarItem(placement: .confirmationAction) { Button("Apply") { + filters = draft + logicMode = draftLogicMode onApply() dismiss() } - .disabled(!filters.contains { $0.isEnabled && $0.isValid }) + .disabled(!hasValidFilters) } } + .onAppear { + draft = filters + draftLogicMode = logicMode + } } } }