Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Full-text search across all columns in iOS data browser

## [0.30.0] - 2026-04-10

### Added
Expand Down
109 changes: 109 additions & 0 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,115 @@ enum SQLBuilder {
return "SELECT COUNT(*) FROM \(quoted) \(whereClause)"
}

// MARK: - Search

static func buildSearchSelect(
table: String, type: DatabaseType,
searchText: String, searchColumns: [ColumnInfo],
filters: [TableFilter] = [], logicMode: FilterLogicMode = .and,
sortState: SortState = SortState(),
limit: Int, offset: Int
) -> String {
let quoted = quoteIdentifier(table, for: type)
let whereClause = buildSearchWhereClause(
searchText: searchText, searchColumns: searchColumns,
filters: filters, logicMode: logicMode, type: type
)
var sql = "SELECT * FROM \(quoted)"
if !whereClause.isEmpty { sql += " \(whereClause)" }
let orderBy = buildOrderByClause(sortState, for: type)
if !orderBy.isEmpty { sql += " \(orderBy)" }
sql += " LIMIT \(limit) OFFSET \(offset)"
return sql
}

static func buildSearchCount(
table: String, type: DatabaseType,
searchText: String, searchColumns: [ColumnInfo],
filters: [TableFilter] = [], logicMode: FilterLogicMode = .and
) -> String {
let quoted = quoteIdentifier(table, for: type)
let whereClause = buildSearchWhereClause(
searchText: searchText, searchColumns: searchColumns,
filters: filters, logicMode: logicMode, type: type
)
var sql = "SELECT COUNT(*) FROM \(quoted)"
if !whereClause.isEmpty { sql += " \(whereClause)" }
return sql
}

private static func buildSearchWhereClause(
searchText: String, searchColumns: [ColumnInfo],
filters: [TableFilter], logicMode: FilterLogicMode,
type: DatabaseType
) -> String {
var whereParts: [String] = []

let searchClause = buildSearchClause(searchText: searchText, columns: searchColumns, type: type)
if !searchClause.isEmpty {
whereParts.append(searchClause)
}

if let filterConditions = filterConditions(filters: filters, logicMode: logicMode, type: type) {
whereParts.append("(\(filterConditions))")
}

guard !whereParts.isEmpty else { return "" }
return "WHERE " + whereParts.joined(separator: " AND ")
}

private static func filterConditions(
filters: [TableFilter], logicMode: FilterLogicMode, type: DatabaseType
) -> String? {
let dialect = dialectDescriptor(for: type)
let generator = FilterSQLGenerator(dialect: dialect)
let clause = generator.generateWhereClause(from: filters, logicMode: logicMode)
guard !clause.isEmpty else { return nil }
let wherePrefix = "WHERE "
return clause.hasPrefix(wherePrefix)
? String(clause.dropFirst(wherePrefix.count))
: clause
}

private static func buildSearchClause(
searchText: String, columns: [ColumnInfo], type: DatabaseType
) -> String {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !columns.isEmpty else { return "" }

let dialect = dialectDescriptor(for: type)
let pattern = escapeLikePattern(trimmed, dialect: dialect)
let likeEscape: String = dialect.likeEscapeStyle == .explicit ? " ESCAPE '\\'" : ""

let conditions = columns.map { col -> String in
let quotedCol = quoteIdentifier(col.name, for: type)
let castExpr: String
switch type {
case .mysql, .mariadb:
castExpr = "CAST(\(quotedCol) AS CHAR)"
case .postgresql, .redshift:
castExpr = "CAST(\(quotedCol) AS TEXT)"
default:
castExpr = quotedCol
}
return "\(castExpr) LIKE '%\(pattern)%'\(likeEscape)"
}

return "(\(conditions.joined(separator: " OR ")))"
}

private static func escapeLikePattern(_ value: String, dialect: SQLDialectDescriptor) -> String {
var result = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
if dialect.requiresBackslashEscaping {
result = result.replacingOccurrences(of: "\\", with: "\\\\")
}
result = result.replacingOccurrences(of: "%", with: "\\%")
result = result.replacingOccurrences(of: "_", with: "\\_")
return result
}

private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String {
guard sortState.isSorting else { return "" }
let clauses = sortState.columns.map { col in
Expand Down
70 changes: 65 additions & 5 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct DataBrowserView: View {
@State private var showOperationError = false
@State private var showGoToPage = false
@State private var goToPageInput = ""
@State private var searchText = ""
@State private var activeSearchText = ""
@State private var filters: [TableFilter] = []
@State private var filterLogicMode: FilterLogicMode = .and
@State private var showFilterSheet = false
Expand Down Expand Up @@ -57,6 +59,14 @@ struct DataBrowserView: View {
return "\(start)–\(end)"
}

private var hasActiveSearch: Bool {
!activeSearchText.isEmpty
}

private var isRedis: Bool {
connection.type == .redis
}

private var hasActiveFilters: Bool {
filters.contains { $0.isEnabled && $0.isValid }
}
Expand Down Expand Up @@ -88,9 +98,7 @@ struct DataBrowserView: View {
}

var body: some View {
content
.navigationTitle(table.name)
.navigationBarTitleDisplayMode(.inline)
searchableContent
.toolbar { topToolbar }
.toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar)
.toolbar { paginationToolbar }
Expand Down Expand Up @@ -163,6 +171,26 @@ struct DataBrowserView: View {
}
}

@ViewBuilder
private var searchableContent: some View {
if isRedis {
content
.navigationTitle(table.name)
.navigationBarTitleDisplayMode(.inline)
} else {
content
.navigationTitle(table.name)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchText, prompt: "Search all columns")
.onSubmit(of: .search) { applySearch() }
.onChange(of: searchText) { oldValue, newValue in
if newValue.isEmpty, !oldValue.isEmpty, hasActiveSearch {
clearSearch()
}
}
}
}

// MARK: - Content

@ViewBuilder
Expand All @@ -172,6 +200,8 @@ struct DataBrowserView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let appError {
ErrorView(error: appError) { await loadData() }
} else if rows.isEmpty, hasActiveSearch {
ContentUnavailableView.search(text: activeSearchText)
} else if rows.isEmpty {
ContentUnavailableView {
Label("No Data", systemImage: "tray")
Expand Down Expand Up @@ -421,7 +451,15 @@ struct DataBrowserView: View {

do {
let query: String
if hasActiveFilters {
if hasActiveSearch {
query = SQLBuilder.buildSearchSelect(
table: table.name, type: connection.type,
searchText: activeSearchText, searchColumns: columns,
filters: filters, logicMode: filterLogicMode,
sortState: sortState,
limit: pagination.pageSize, offset: pagination.currentOffset
)
} else if hasActiveFilters {
query = SQLBuilder.buildFilteredSelect(
table: table.name, type: connection.type,
filters: filters, logicMode: filterLogicMode,
Expand Down Expand Up @@ -472,7 +510,13 @@ struct DataBrowserView: View {
private func fetchTotalRows(session: ConnectionSession) async {
do {
let countQuery: String
if hasActiveFilters {
if hasActiveSearch {
countQuery = SQLBuilder.buildSearchCount(
table: table.name, type: connection.type,
searchText: activeSearchText, searchColumns: columns,
filters: filters, logicMode: filterLogicMode
)
} else if hasActiveFilters {
countQuery = SQLBuilder.buildFilteredCount(
table: table.name, type: connection.type,
filters: filters, logicMode: filterLogicMode
Expand Down Expand Up @@ -560,6 +604,22 @@ struct DataBrowserView: View {
Task { await loadData() }
}

private func applySearch() {
activeSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard hasActiveSearch, !columns.isEmpty else { return }
pagination.currentPage = 0
pagination.totalRows = nil
Task { await loadData() }
}

private func clearSearch() {
searchText = ""
activeSearchText = ""
pagination.currentPage = 0
pagination.totalRows = nil
Task { await loadData() }
}

private func applyFilters() {
pagination.currentPage = 0
pagination.totalRows = nil
Expand Down
Loading