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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Full-text search across all columns in iOS data browser
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)

## [0.30.1] - 2026-04-10
Expand Down
114 changes: 114 additions & 0 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,120 @@ 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)"
case .mssql:
castExpr = "CAST(\(quotedCol) AS NVARCHAR(MAX))"
case .clickhouse:
castExpr = "toString(\(quotedCol))"
default:
castExpr = "CAST(\(quotedCol) AS TEXT)"
}
let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE"
return "\(castExpr) \(likeOp) '%\(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
103 changes: 91 additions & 12 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ 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 searchTask: Task<Void, Never>?
@State private var filters: [TableFilter] = []
@State private var filterLogicMode: FilterLogicMode = .and
@State private var showFilterSheet = false
Expand Down Expand Up @@ -57,6 +60,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,11 +99,9 @@ struct DataBrowserView: View {
}

var body: some View {
content
.navigationTitle(table.name)
.navigationBarTitleDisplayMode(.inline)
searchableContent
.toolbar { topToolbar }
.toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar)
.toolbar(rows.isEmpty && !hasActiveSearch && !hasActiveFilters ? .hidden : .visible, for: .bottomBar)
.toolbar { paginationToolbar }
.task { await loadData(isInitial: true) }
.sheet(isPresented: $showInsertSheet) { insertSheet }
Expand Down Expand Up @@ -163,6 +172,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 +201,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 @@ -220,7 +251,7 @@ struct DataBrowserView: View {
ForEach(ExportFormat.allCases) { format in
Button(format.rawValue) {
let text = ClipboardExporter.exportRow(
columns: columns, row: rows[index],
columns: columns, row: row,
format: format, tableName: table.name
)
ClipboardExporter.copyToClipboard(text)
Expand All @@ -230,17 +261,17 @@ struct DataBrowserView: View {
if !foreignKeys.isEmpty {
let rowFKs = foreignKeys.filter { fk in
guard let colIndex = columns.firstIndex(where: { $0.name == fk.column }),
colIndex < rows[index].count,
rows[index][colIndex] != nil else { return false }
colIndex < row.count,
row[colIndex] != nil else { return false }
return true
}
if !rowFKs.isEmpty {
Divider()
ForEach(rowFKs, id: \.name) { fk in
Button {
if let colIndex = columns.firstIndex(where: { $0.name == fk.column }),
colIndex < rows[index].count,
let value = rows[index][colIndex] {
colIndex < row.count,
let value = row[colIndex] {
fkPreviewItem = FKPreviewItem(fk: fk, value: value)
}
} label: {
Expand All @@ -253,7 +284,7 @@ struct DataBrowserView: View {
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if !isView && hasPrimaryKeys && !connection.safeModeLevel.blocksWrites {
Button(role: .destructive) {
deleteTarget = primaryKeyValues(for: rows[index])
deleteTarget = primaryKeyValues(for: row)
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
Expand Down Expand Up @@ -290,6 +321,7 @@ struct DataBrowserView: View {
}
} label: {
Image(systemName: "square.and.arrow.up")
.accessibilityLabel(Text("Export"))
}
.disabled(rows.isEmpty)
}
Expand All @@ -314,6 +346,7 @@ struct DataBrowserView: View {
Image(systemName: sortState.isSorting
? "arrow.up.arrow.down.circle.fill"
: "arrow.up.arrow.down.circle")
.accessibilityLabel(Text("Sort"))
}
.disabled(columns.isEmpty)
}
Expand All @@ -322,6 +355,7 @@ struct DataBrowserView: View {
Image(systemName: hasActiveFilters
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.accessibilityLabel(Text("Filter"))
}
.badge(activeFilterCount)
}
Expand All @@ -330,12 +364,14 @@ struct DataBrowserView: View {
StructureView(table: table, session: session, databaseType: connection.type)
} label: {
Image(systemName: "info.circle")
.accessibilityLabel(Text("Table Structure"))
}
}
if !isView && !connection.safeModeLevel.blocksWrites {
ToolbarItem(placement: .primaryAction) {
Button { showInsertSheet = true } label: {
Image(systemName: "plus")
.accessibilityLabel(Text("Insert Row"))
}
}
}
Expand Down Expand Up @@ -421,7 +457,20 @@ struct DataBrowserView: View {

do {
let query: String
if hasActiveFilters {
if hasActiveSearch {
let searchableColumns = columns.filter { col in
let upper = col.typeName.uppercased()
return !upper.contains("BLOB") && !upper.contains("BYTEA") && !upper.contains("BINARY")
&& !upper.contains("VARBINARY") && !upper.contains("IMAGE")
}
query = SQLBuilder.buildSearchSelect(
table: table.name, type: connection.type,
searchText: activeSearchText, searchColumns: searchableColumns,
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 +521,18 @@ struct DataBrowserView: View {
private func fetchTotalRows(session: ConnectionSession) async {
do {
let countQuery: String
if hasActiveFilters {
if hasActiveSearch {
let searchableColumns = columns.filter { col in
let upper = col.typeName.uppercased()
return !upper.contains("BLOB") && !upper.contains("BYTEA") && !upper.contains("BINARY")
&& !upper.contains("VARBINARY") && !upper.contains("IMAGE")
}
countQuery = SQLBuilder.buildSearchCount(
table: table.name, type: connection.type,
searchText: activeSearchText, searchColumns: searchableColumns,
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 +620,24 @@ 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
searchTask?.cancel()
searchTask = Task { await loadData() }
}

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

private func applyFilters() {
pagination.currentPage = 0
pagination.totalRows = nil
Expand Down Expand Up @@ -781,5 +859,6 @@ private struct RowCard: View {
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
}
Loading