From 65205d8db21ce4fcc8407caa1354605ac497a314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 11:25:51 +0700 Subject: [PATCH 1/2] feat(ios): add full-text search across all columns in data browser --- CHANGELOG.md | 4 + .../TableProMobile/Helpers/SQLBuilder.swift | 109 ++++++++++++++++++ .../Views/DataBrowserView.swift | 70 ++++++++++- 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80353386..89f0ec7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index add8ebf6..c57b07df 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -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 diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 344b151f..6dfa63dc 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -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 @@ -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 } } @@ -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 } @@ -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 @@ -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") @@ -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, @@ -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 @@ -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 From c4c66420c3ca7beb18cb4bb033fa4f5be61d4db2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 11 Apr 2026 17:03:56 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20iOS=20data=20search=20=E2=80=94=20cr?= =?UTF-8?q?ash,=20CAST,=20BLOB=20filter,=20ILIKE,=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../TableProMobile/Helpers/SQLBuilder.swift | 9 +++- .../Views/DataBrowserView.swift | 41 ++++++++++++++----- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index c57b07df..2c1c27eb 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -231,10 +231,15 @@ enum SQLBuilder { 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 = quotedCol + castExpr = "CAST(\(quotedCol) AS TEXT)" } - return "\(castExpr) LIKE '%\(pattern)%'\(likeEscape)" + let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE" + return "\(castExpr) \(likeOp) '%\(pattern)%'\(likeEscape)" } return "(\(conditions.joined(separator: " OR ")))" diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 6dfa63dc..f9c97395 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -32,6 +32,7 @@ struct DataBrowserView: View { @State private var goToPageInput = "" @State private var searchText = "" @State private var activeSearchText = "" + @State private var searchTask: Task? @State private var filters: [TableFilter] = [] @State private var filterLogicMode: FilterLogicMode = .and @State private var showFilterSheet = false @@ -100,7 +101,7 @@ struct DataBrowserView: View { var body: some View { 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 } @@ -250,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) @@ -260,8 +261,8 @@ 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 { @@ -269,8 +270,8 @@ struct DataBrowserView: View { 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: { @@ -283,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") @@ -320,6 +321,7 @@ struct DataBrowserView: View { } } label: { Image(systemName: "square.and.arrow.up") + .accessibilityLabel(Text("Export")) } .disabled(rows.isEmpty) } @@ -344,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) } @@ -352,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) } @@ -360,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")) } } } @@ -452,9 +458,14 @@ struct DataBrowserView: View { do { let query: String 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: columns, + searchText: activeSearchText, searchColumns: searchableColumns, filters: filters, logicMode: filterLogicMode, sortState: sortState, limit: pagination.pageSize, offset: pagination.currentOffset @@ -511,9 +522,14 @@ struct DataBrowserView: View { do { let countQuery: String 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: columns, + searchText: activeSearchText, searchColumns: searchableColumns, filters: filters, logicMode: filterLogicMode ) } else if hasActiveFilters { @@ -609,7 +625,8 @@ struct DataBrowserView: View { guard hasActiveSearch, !columns.isEmpty else { return } pagination.currentPage = 0 pagination.totalRows = nil - Task { await loadData() } + searchTask?.cancel() + searchTask = Task { await loadData() } } private func clearSearch() { @@ -617,7 +634,8 @@ struct DataBrowserView: View { activeSearchText = "" pagination.currentPage = 0 pagination.totalRows = nil - Task { await loadData() } + searchTask?.cancel() + searchTask = Task { await loadData() } } private func applyFilters() { @@ -841,5 +859,6 @@ private struct RowCard: View { } } .padding(.vertical, 2) + .accessibilityElement(children: .combine) } }