diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0ed130..8c97be3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index add8ebf6..2c1c27eb 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -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 diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 344b151f..f9c97395 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -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? @State private var filters: [TableFilter] = [] @State private var filterLogicMode: FilterLogicMode = .and @State private var showFilterSheet = false @@ -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 } } @@ -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 } @@ -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 @@ -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") @@ -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) @@ -230,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 { @@ -239,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: { @@ -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") @@ -290,6 +321,7 @@ struct DataBrowserView: View { } } label: { Image(systemName: "square.and.arrow.up") + .accessibilityLabel(Text("Export")) } .disabled(rows.isEmpty) } @@ -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) } @@ -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) } @@ -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")) } } } @@ -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, @@ -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 @@ -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 @@ -781,5 +859,6 @@ private struct RowCard: View { } } .padding(.vertical, 2) + .accessibilityElement(children: .combine) } }