Skip to content

Commit f210139

Browse files
authored
feat(ios): add full-text search in data browser (#657)
* feat(ios): add full-text search across all columns in data browser * fix: iOS data search — crash, CAST, BLOB filter, ILIKE, accessibility - Fix stale rows[index] crash in context menu/swipe closures (use captured row value) - Add CAST for MSSQL (NVARCHAR(MAX)) and ClickHouse (toString), default CAST(AS TEXT) - Filter BLOB/BYTEA/BINARY columns from search (produce garbage results) - Cancel previous search task before starting new one - Use ILIKE for PostgreSQL case-insensitive search - Add VoiceOver accessibility to RowCard and toolbar buttons - Keep bottom toolbar visible during active search/filter with empty results
1 parent a7e0be6 commit f210139

3 files changed

Lines changed: 206 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

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

1415
## [0.30.1] - 2026-04-10

TableProMobile/TableProMobile/Helpers/SQLBuilder.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,120 @@ enum SQLBuilder {
143143
return "SELECT COUNT(*) FROM \(quoted) \(whereClause)"
144144
}
145145

146+
// MARK: - Search
147+
148+
static func buildSearchSelect(
149+
table: String, type: DatabaseType,
150+
searchText: String, searchColumns: [ColumnInfo],
151+
filters: [TableFilter] = [], logicMode: FilterLogicMode = .and,
152+
sortState: SortState = SortState(),
153+
limit: Int, offset: Int
154+
) -> String {
155+
let quoted = quoteIdentifier(table, for: type)
156+
let whereClause = buildSearchWhereClause(
157+
searchText: searchText, searchColumns: searchColumns,
158+
filters: filters, logicMode: logicMode, type: type
159+
)
160+
var sql = "SELECT * FROM \(quoted)"
161+
if !whereClause.isEmpty { sql += " \(whereClause)" }
162+
let orderBy = buildOrderByClause(sortState, for: type)
163+
if !orderBy.isEmpty { sql += " \(orderBy)" }
164+
sql += " LIMIT \(limit) OFFSET \(offset)"
165+
return sql
166+
}
167+
168+
static func buildSearchCount(
169+
table: String, type: DatabaseType,
170+
searchText: String, searchColumns: [ColumnInfo],
171+
filters: [TableFilter] = [], logicMode: FilterLogicMode = .and
172+
) -> String {
173+
let quoted = quoteIdentifier(table, for: type)
174+
let whereClause = buildSearchWhereClause(
175+
searchText: searchText, searchColumns: searchColumns,
176+
filters: filters, logicMode: logicMode, type: type
177+
)
178+
var sql = "SELECT COUNT(*) FROM \(quoted)"
179+
if !whereClause.isEmpty { sql += " \(whereClause)" }
180+
return sql
181+
}
182+
183+
private static func buildSearchWhereClause(
184+
searchText: String, searchColumns: [ColumnInfo],
185+
filters: [TableFilter], logicMode: FilterLogicMode,
186+
type: DatabaseType
187+
) -> String {
188+
var whereParts: [String] = []
189+
190+
let searchClause = buildSearchClause(searchText: searchText, columns: searchColumns, type: type)
191+
if !searchClause.isEmpty {
192+
whereParts.append(searchClause)
193+
}
194+
195+
if let filterConditions = filterConditions(filters: filters, logicMode: logicMode, type: type) {
196+
whereParts.append("(\(filterConditions))")
197+
}
198+
199+
guard !whereParts.isEmpty else { return "" }
200+
return "WHERE " + whereParts.joined(separator: " AND ")
201+
}
202+
203+
private static func filterConditions(
204+
filters: [TableFilter], logicMode: FilterLogicMode, type: DatabaseType
205+
) -> String? {
206+
let dialect = dialectDescriptor(for: type)
207+
let generator = FilterSQLGenerator(dialect: dialect)
208+
let clause = generator.generateWhereClause(from: filters, logicMode: logicMode)
209+
guard !clause.isEmpty else { return nil }
210+
let wherePrefix = "WHERE "
211+
return clause.hasPrefix(wherePrefix)
212+
? String(clause.dropFirst(wherePrefix.count))
213+
: clause
214+
}
215+
216+
private static func buildSearchClause(
217+
searchText: String, columns: [ColumnInfo], type: DatabaseType
218+
) -> String {
219+
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
220+
guard !trimmed.isEmpty, !columns.isEmpty else { return "" }
221+
222+
let dialect = dialectDescriptor(for: type)
223+
let pattern = escapeLikePattern(trimmed, dialect: dialect)
224+
let likeEscape: String = dialect.likeEscapeStyle == .explicit ? " ESCAPE '\\'" : ""
225+
226+
let conditions = columns.map { col -> String in
227+
let quotedCol = quoteIdentifier(col.name, for: type)
228+
let castExpr: String
229+
switch type {
230+
case .mysql, .mariadb:
231+
castExpr = "CAST(\(quotedCol) AS CHAR)"
232+
case .postgresql, .redshift:
233+
castExpr = "CAST(\(quotedCol) AS TEXT)"
234+
case .mssql:
235+
castExpr = "CAST(\(quotedCol) AS NVARCHAR(MAX))"
236+
case .clickhouse:
237+
castExpr = "toString(\(quotedCol))"
238+
default:
239+
castExpr = "CAST(\(quotedCol) AS TEXT)"
240+
}
241+
let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE"
242+
return "\(castExpr) \(likeOp) '%\(pattern)%'\(likeEscape)"
243+
}
244+
245+
return "(\(conditions.joined(separator: " OR ")))"
246+
}
247+
248+
private static func escapeLikePattern(_ value: String, dialect: SQLDialectDescriptor) -> String {
249+
var result = value
250+
.replacingOccurrences(of: "'", with: "''")
251+
.replacingOccurrences(of: "\0", with: "")
252+
if dialect.requiresBackslashEscaping {
253+
result = result.replacingOccurrences(of: "\\", with: "\\\\")
254+
}
255+
result = result.replacingOccurrences(of: "%", with: "\\%")
256+
result = result.replacingOccurrences(of: "_", with: "\\_")
257+
return result
258+
}
259+
146260
private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String {
147261
guard sortState.isSorting else { return "" }
148262
let clauses = sortState.columns.map { col in

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ struct DataBrowserView: View {
3030
@State private var showOperationError = false
3131
@State private var showGoToPage = false
3232
@State private var goToPageInput = ""
33+
@State private var searchText = ""
34+
@State private var activeSearchText = ""
35+
@State private var searchTask: Task<Void, Never>?
3336
@State private var filters: [TableFilter] = []
3437
@State private var filterLogicMode: FilterLogicMode = .and
3538
@State private var showFilterSheet = false
@@ -57,6 +60,14 @@ struct DataBrowserView: View {
5760
return "\(start)\(end)"
5861
}
5962

63+
private var hasActiveSearch: Bool {
64+
!activeSearchText.isEmpty
65+
}
66+
67+
private var isRedis: Bool {
68+
connection.type == .redis
69+
}
70+
6071
private var hasActiveFilters: Bool {
6172
filters.contains { $0.isEnabled && $0.isValid }
6273
}
@@ -88,11 +99,9 @@ struct DataBrowserView: View {
8899
}
89100

90101
var body: some View {
91-
content
92-
.navigationTitle(table.name)
93-
.navigationBarTitleDisplayMode(.inline)
102+
searchableContent
94103
.toolbar { topToolbar }
95-
.toolbar(rows.isEmpty ? .hidden : .visible, for: .bottomBar)
104+
.toolbar(rows.isEmpty && !hasActiveSearch && !hasActiveFilters ? .hidden : .visible, for: .bottomBar)
96105
.toolbar { paginationToolbar }
97106
.task { await loadData(isInitial: true) }
98107
.sheet(isPresented: $showInsertSheet) { insertSheet }
@@ -163,6 +172,26 @@ struct DataBrowserView: View {
163172
}
164173
}
165174

175+
@ViewBuilder
176+
private var searchableContent: some View {
177+
if isRedis {
178+
content
179+
.navigationTitle(table.name)
180+
.navigationBarTitleDisplayMode(.inline)
181+
} else {
182+
content
183+
.navigationTitle(table.name)
184+
.navigationBarTitleDisplayMode(.inline)
185+
.searchable(text: $searchText, prompt: "Search all columns")
186+
.onSubmit(of: .search) { applySearch() }
187+
.onChange(of: searchText) { oldValue, newValue in
188+
if newValue.isEmpty, !oldValue.isEmpty, hasActiveSearch {
189+
clearSearch()
190+
}
191+
}
192+
}
193+
}
194+
166195
// MARK: - Content
167196

168197
@ViewBuilder
@@ -172,6 +201,8 @@ struct DataBrowserView: View {
172201
.frame(maxWidth: .infinity, maxHeight: .infinity)
173202
} else if let appError {
174203
ErrorView(error: appError) { await loadData() }
204+
} else if rows.isEmpty, hasActiveSearch {
205+
ContentUnavailableView.search(text: activeSearchText)
175206
} else if rows.isEmpty {
176207
ContentUnavailableView {
177208
Label("No Data", systemImage: "tray")
@@ -220,7 +251,7 @@ struct DataBrowserView: View {
220251
ForEach(ExportFormat.allCases) { format in
221252
Button(format.rawValue) {
222253
let text = ClipboardExporter.exportRow(
223-
columns: columns, row: rows[index],
254+
columns: columns, row: row,
224255
format: format, tableName: table.name
225256
)
226257
ClipboardExporter.copyToClipboard(text)
@@ -230,17 +261,17 @@ struct DataBrowserView: View {
230261
if !foreignKeys.isEmpty {
231262
let rowFKs = foreignKeys.filter { fk in
232263
guard let colIndex = columns.firstIndex(where: { $0.name == fk.column }),
233-
colIndex < rows[index].count,
234-
rows[index][colIndex] != nil else { return false }
264+
colIndex < row.count,
265+
row[colIndex] != nil else { return false }
235266
return true
236267
}
237268
if !rowFKs.isEmpty {
238269
Divider()
239270
ForEach(rowFKs, id: \.name) { fk in
240271
Button {
241272
if let colIndex = columns.firstIndex(where: { $0.name == fk.column }),
242-
colIndex < rows[index].count,
243-
let value = rows[index][colIndex] {
273+
colIndex < row.count,
274+
let value = row[colIndex] {
244275
fkPreviewItem = FKPreviewItem(fk: fk, value: value)
245276
}
246277
} label: {
@@ -253,7 +284,7 @@ struct DataBrowserView: View {
253284
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
254285
if !isView && hasPrimaryKeys && !connection.safeModeLevel.blocksWrites {
255286
Button(role: .destructive) {
256-
deleteTarget = primaryKeyValues(for: rows[index])
287+
deleteTarget = primaryKeyValues(for: row)
257288
showDeleteConfirmation = true
258289
} label: {
259290
Label("Delete", systemImage: "trash")
@@ -290,6 +321,7 @@ struct DataBrowserView: View {
290321
}
291322
} label: {
292323
Image(systemName: "square.and.arrow.up")
324+
.accessibilityLabel(Text("Export"))
293325
}
294326
.disabled(rows.isEmpty)
295327
}
@@ -314,6 +346,7 @@ struct DataBrowserView: View {
314346
Image(systemName: sortState.isSorting
315347
? "arrow.up.arrow.down.circle.fill"
316348
: "arrow.up.arrow.down.circle")
349+
.accessibilityLabel(Text("Sort"))
317350
}
318351
.disabled(columns.isEmpty)
319352
}
@@ -322,6 +355,7 @@ struct DataBrowserView: View {
322355
Image(systemName: hasActiveFilters
323356
? "line.3.horizontal.decrease.circle.fill"
324357
: "line.3.horizontal.decrease.circle")
358+
.accessibilityLabel(Text("Filter"))
325359
}
326360
.badge(activeFilterCount)
327361
}
@@ -330,12 +364,14 @@ struct DataBrowserView: View {
330364
StructureView(table: table, session: session, databaseType: connection.type)
331365
} label: {
332366
Image(systemName: "info.circle")
367+
.accessibilityLabel(Text("Table Structure"))
333368
}
334369
}
335370
if !isView && !connection.safeModeLevel.blocksWrites {
336371
ToolbarItem(placement: .primaryAction) {
337372
Button { showInsertSheet = true } label: {
338373
Image(systemName: "plus")
374+
.accessibilityLabel(Text("Insert Row"))
339375
}
340376
}
341377
}
@@ -421,7 +457,20 @@ struct DataBrowserView: View {
421457

422458
do {
423459
let query: String
424-
if hasActiveFilters {
460+
if hasActiveSearch {
461+
let searchableColumns = columns.filter { col in
462+
let upper = col.typeName.uppercased()
463+
return !upper.contains("BLOB") && !upper.contains("BYTEA") && !upper.contains("BINARY")
464+
&& !upper.contains("VARBINARY") && !upper.contains("IMAGE")
465+
}
466+
query = SQLBuilder.buildSearchSelect(
467+
table: table.name, type: connection.type,
468+
searchText: activeSearchText, searchColumns: searchableColumns,
469+
filters: filters, logicMode: filterLogicMode,
470+
sortState: sortState,
471+
limit: pagination.pageSize, offset: pagination.currentOffset
472+
)
473+
} else if hasActiveFilters {
425474
query = SQLBuilder.buildFilteredSelect(
426475
table: table.name, type: connection.type,
427476
filters: filters, logicMode: filterLogicMode,
@@ -472,7 +521,18 @@ struct DataBrowserView: View {
472521
private func fetchTotalRows(session: ConnectionSession) async {
473522
do {
474523
let countQuery: String
475-
if hasActiveFilters {
524+
if hasActiveSearch {
525+
let searchableColumns = columns.filter { col in
526+
let upper = col.typeName.uppercased()
527+
return !upper.contains("BLOB") && !upper.contains("BYTEA") && !upper.contains("BINARY")
528+
&& !upper.contains("VARBINARY") && !upper.contains("IMAGE")
529+
}
530+
countQuery = SQLBuilder.buildSearchCount(
531+
table: table.name, type: connection.type,
532+
searchText: activeSearchText, searchColumns: searchableColumns,
533+
filters: filters, logicMode: filterLogicMode
534+
)
535+
} else if hasActiveFilters {
476536
countQuery = SQLBuilder.buildFilteredCount(
477537
table: table.name, type: connection.type,
478538
filters: filters, logicMode: filterLogicMode
@@ -560,6 +620,24 @@ struct DataBrowserView: View {
560620
Task { await loadData() }
561621
}
562622

623+
private func applySearch() {
624+
activeSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
625+
guard hasActiveSearch, !columns.isEmpty else { return }
626+
pagination.currentPage = 0
627+
pagination.totalRows = nil
628+
searchTask?.cancel()
629+
searchTask = Task { await loadData() }
630+
}
631+
632+
private func clearSearch() {
633+
searchText = ""
634+
activeSearchText = ""
635+
pagination.currentPage = 0
636+
pagination.totalRows = nil
637+
searchTask?.cancel()
638+
searchTask = Task { await loadData() }
639+
}
640+
563641
private func applyFilters() {
564642
pagination.currentPage = 0
565643
pagination.totalRows = nil
@@ -781,5 +859,6 @@ private struct RowCard: View {
781859
}
782860
}
783861
.padding(.vertical, 2)
862+
.accessibilityElement(children: .combine)
784863
}
785864
}

0 commit comments

Comments
 (0)