Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import Foundation
import TableProModels
import TableProPluginKit
import TableProQuery

enum SQLBuilder {
static func quoteIdentifier(_ name: String, for type: DatabaseType) -> String {
Expand Down Expand Up @@ -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: []
)
}
}
}
212 changes: 207 additions & 5 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import os
import SwiftUI
import TableProDatabase
import TableProModels
import TableProQuery

struct DataBrowserView: View {
let connection: DatabaseConnection
Expand All @@ -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 showFilterSheet = false

private var isView: Bool {
table.type == .view || table.type == .materializedView
Expand All @@ -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)
Expand All @@ -57,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 {
Expand Down Expand Up @@ -111,6 +128,10 @@ struct DataBrowserView: View {
}
}

private var activeFilterCount: Int {
filters.filter { $0.isEnabled && $0.isValid }.count
}

private var rowList: some View {
List {
ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
Expand Down Expand Up @@ -156,6 +177,14 @@ struct DataBrowserView: View {

@ToolbarContentBuilder
private var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button { showFilterSheet = true } label: {
Image(systemName: hasActiveFilters
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.badge(activeFilterCount)
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
StructureView(table: table, session: session, databaseType: connection.type)
Expand Down Expand Up @@ -251,10 +280,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
Expand All @@ -276,7 +314,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")
Expand Down Expand Up @@ -349,6 +395,162 @@ 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 Sheet

private struct FilterSheetView: View {
@Environment(\.dismiss) private var dismiss
@Binding var filters: [TableFilter]
@Binding var logicMode: FilterLogicMode
let columns: [ColumnInfo]
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<TableFilter>? {
guard let index = draft.firstIndex(where: { $0.id == id }) else { return nil }
return $draft[index]
}

var body: some View {
NavigationStack {
Form {
if draft.count > 1 {
Section {
Picker("Logic", selection: $draftLogicMode) {
Text("AND").tag(FilterLogicMode.and)
Text("OR").tag(FilterLogicMode.or)
}
.pickerStyle(.segmented)
}
}

ForEach(draft) { 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: binding.filterOperator) {
ForEach(FilterOperator.allCases, id: \.self) { op in
Text(op.displayName).tag(op)
}
}

if filter.filterOperator.needsValue {
TextField("Value", text: binding.value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}

if filter.filterOperator == .between {
TextField("Second value", text: binding.secondValue)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
}
}
.onDelete { indexSet in
draft.remove(atOffsets: indexSet)
}

Section {
Button {
draft.append(TableFilter(columnName: columns.first?.name ?? ""))
} label: {
Label("Add Filter", systemImage: "plus.circle")
}
}

if !draft.isEmpty {
Section {
Button("Clear All Filters", role: .destructive) {
filters.removeAll()
logicMode = .and
onClear()
dismiss()
}
}
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
filters = draft
logicMode = draftLogicMode
onApply()
dismiss()
}
.disabled(!hasValidFilters)
}
}
.onAppear {
draft = filters
draftLogicMode = logicMode
}
}
}
}

// 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
Expand Down
Loading