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] 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