diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 6a432515a8..09a83f2bf3 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -413,6 +413,10 @@ struct FilterSQLGenerator { return "regexp_matches(\(column), '\(escapedPattern)', 'i')" case .regexpLike: guard ignoresCase else { return "REGEXP_LIKE(\(column), '\(escapedPattern)')" } + // Trino's REGEXP_LIKE takes two arguments and reads case from a flag inside the pattern. + guard dialect.caseSensitivityStyle != .regexFlag else { + return "REGEXP_LIKE(\(column), '(?i)\(escapedPattern)')" + } return "REGEXP_LIKE(\(column), '\(escapedPattern)', 'i')" case .match: guard ignoresCase else { return "match(\(column), '\(escapedPattern)')" } @@ -440,9 +444,33 @@ struct FilterSQLGenerator { return .value(trimmed) } + if let temporal = oracleTemporalLiteral(trimmed, columnType: columnType) { + return .value(temporal) + } + return .value(quotedLiteral(escapeStringValue(trimmed))) } + // Oracle reads a quoted date through NLS_DATE_FORMAT, `DD-MON-RR` by default, and rejects + // `'2024-01-05'` with ORA-01861; its ANSI literals take exactly these shapes, the time with seconds. + private func oracleTemporalLiteral(_ value: String, columnType: ColumnType?) -> String? { + guard family == .oracle, let columnType, columnType.isDateType, !columnType.isTimeOnly else { return nil } + let characters = Array(value) + let isDigit: (Int) -> Bool = { characters[$0].isASCII && characters[$0].isNumber } + guard characters.count >= 10, [0, 1, 2, 3, 5, 6, 8, 9].allSatisfy(isDigit), + characters[4] == "-", characters[7] == "-" else { return nil } + if characters.count == 10 { return "DATE '\(value)'" } + guard characters.count >= 19, characters[10] == " " || characters[10] == "T", + [11, 12, 14, 15, 17, 18].allSatisfy(isDigit), characters[13] == ":", characters[16] == ":" + else { return nil } + let fraction = characters.dropFirst(19) + if !fraction.isEmpty { + guard fraction.first == ".", (2...10).contains(fraction.count), + (20 ..< characters.count).allSatisfy(isDigit) else { return nil } + } + return "TIMESTAMP '\(String(characters[0..<10])) \(String(characters[11...]))'" + } + /// PostgreSQL gives a quoted literal the column's own type, and a `real` never equals the numeric `0.1`. private func quotesNumbers(of columnType: ColumnType?) -> Bool { guard family == .postgres, case .decimal? = columnType else { return false } @@ -519,11 +547,15 @@ struct FilterSQLGenerator { .replacingOccurrences(of: "%", with: "\\\\%") .replacingOccurrences(of: "_", with: "\\\\_") } - guard value.contains("!") || value.contains("%") || value.contains("_") else { return value } - return value + // `[` opens a character class in a SQL Server LIKE pattern. + let escapesBracket = family == .mssql + guard value.contains("!") || value.contains("%") || value.contains("_") + || escapesBracket && value.contains("[") else { return value } + let escaped = value .replacingOccurrences(of: "!", with: "!!") .replacingOccurrences(of: "%", with: "!%") .replacingOccurrences(of: "_", with: "!_") + return escapesBracket ? escaped.replacingOccurrences(of: "[", with: "![") : escaped } } diff --git a/apple/TablePro/Core/DatabaseSearch/DatabaseSearchColumnPlanner.swift b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchColumnPlanner.swift new file mode 100644 index 0000000000..fb6071bb35 --- /dev/null +++ b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchColumnPlanner.swift @@ -0,0 +1,188 @@ +// +// DatabaseSearchColumnPlanner.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum DatabaseSearchColumnPlanner { + internal struct Column: Equatable, Sendable { + internal let name: String + internal let type: ColumnType + + internal init(name: String, type: ColumnType) { + self.name = name + self.type = type + } + } + + // One failing predicate fails the table's whole query, so a column joins only where the engine is known to take it. + internal struct Engine: Equatable, Sendable { + internal let family: SQLTypeFamily + internal let castsToText: Bool + + internal init(family: SQLTypeFamily, castsToText: Bool) { + self.family = family + self.castsToText = castsToText + } + + internal init(databaseType: DatabaseType, dialect: SQLDialectDescriptor) { + self.init(family: SQLTypeFamily.of(databaseType), castsToText: dialect.textCastTypeName != nil) + } + + // MySQL, SQLite and SQL Server match LIKE against any column natively; the others need the dialect's cast. + internal var patternReadsAnyColumn: Bool { + castsToText || [.mysql, .sqlite, .mssql].contains(family) + } + + // Trino rejects `date = '2024-01-05'`; an engine the app has no type family for may too. + internal var comparesQuotedTypedLiterals: Bool { + family != .generic + } + } + + private struct Match { + let filterOperator: FilterOperator + let isCaseSensitive: Bool + } + + internal static func filters( + for columns: [Column], + options: DatabaseSearchOptions, + engine: Engine + ) -> [TableFilter] { + let term = options.trimmedTerm + guard !term.isEmpty else { return [] } + return columns.compactMap { column in + guard let match = match(for: column.type, term: term, options: options, engine: engine) else { return nil } + return TableFilter( + columnName: column.name, + filterOperator: match.filterOperator, + value: term, + isCaseSensitive: match.isCaseSensitive + ) + } + } + + private static func match( + for type: ColumnType, + term: String, + options: DatabaseSearchOptions, + engine: Engine + ) -> Match? { + let textMatch = Match(filterOperator: options.matchMode.textOperator, isCaseSensitive: options.isCaseSensitive) + // A typed comparison keeps the column's own type and its index, so it never folds case. + let typedMatch = Match(filterOperator: .equal, isCaseSensitive: true) + let pattern = options.matchMode.usesPattern + + switch type { + case .text(let rawType): + if type.isUUIDType { + return engine.comparesQuotedTypedLiterals && UUID(uuidString: term) != nil ? typedMatch : nil + } + guard type.hasEmptyValue else { return nil } + let isCharacter = ColumnTypeSQLQuoting.isCharacterType(.text(rawType: unwrappedTypeName(rawType))) + if pattern { + guard isCharacter || engine.patternReadsAnyColumn else { return nil } + return patternRefusingTypes.contains(baseTypeName(rawType)) ? nil : textMatch + } + guard isCharacter, ColumnTypeSQLQuoting.hasEqualityOperator(type, family: engine.family) else { return nil } + return textMatch + case .enumType, .set: + if pattern { return engine.patternReadsAnyColumn ? textMatch : nil } + // An engine refuses `=` against a label its enum does not declare; a SET value is a list of them. + guard let labels = type.enumValues else { return nil } + let parts = type.isSetType + ? term.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + : [term] + let declared = parts.allSatisfy { part in labels.contains { matchesLabel($0, part, options) } } + return declared ? textMatch : nil + case .json: + return pattern && engine.patternReadsAnyColumn ? textMatch : nil + case .integer: + return RowValueCopyFormatter.isIntegerLiteral(term) ? typedMatch : nil + case .decimal: + return PluginNumericLiteral.isValid(term) ? typedMatch : nil + case .date: + guard engine.comparesQuotedTypedLiterals, !type.isTimeOnly else { return nil } + return DatabaseSearchTermParser.isDate(term) ? typedMatch : nil + case .timestamp, .datetime: + guard engine.comparesQuotedTypedLiterals, !type.isTimeOnly else { return nil } + return DatabaseSearchTermParser.isDateTime(term) ? typedMatch : nil + case .blob: + guard options.includesBinaryColumns, pattern, engine.patternReadsAnyColumn else { return nil } + return textMatch + case .boolean, .spatial, .vector, .array: + return nil + } + } + + private static func matchesLabel(_ label: String, _ term: String, _ options: DatabaseSearchOptions) -> Bool { + options.isCaseSensitive ? label == term : label.caseInsensitiveCompare(term) == .orderedSame + } + + // SQL Server and Oracle refuse LIKE on XML, and Oracle on LONG. + private static let patternRefusingTypes: Set = ["XML", "XMLTYPE", "LONG"] + + private static let typeWrappers = ["NULLABLE(", "LOWCARDINALITY("] + + private static func unwrappedTypeName(_ rawType: String?) -> String? { + guard var name = rawType?.trimmingCharacters(in: .whitespaces) else { return nil } + while let wrapper = typeWrappers.first(where: { name.uppercased().hasPrefix($0) }), name.hasSuffix(")") { + name = String(name.dropFirst(wrapper.count).dropLast()) + } + return name + } + + private static func baseTypeName(_ rawType: String?) -> String { + guard let name = unwrappedTypeName(rawType) else { return "" } + return name.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces).uppercased() + } +} + +internal enum DatabaseSearchTermParser { + internal static func isDate(_ term: String) -> Bool { + let characters = Array(term) + guard characters.count == 10 else { return false } + return isCalendarDate(characters) + } + + // `yyyy-MM-dd HH:mm:ss` with an optional fraction, a space or `T` between: the one shape every engine, + // Oracle's ANSI literal included, reads as a timestamp. + internal static func isDateTime(_ term: String) -> Bool { + let characters = Array(term) + guard characters.count >= 19, characters[10] == " " || characters[10] == "T", + isCalendarDate(Array(characters[0..<10])), + let hour = number(characters, 11..<13), (0...23).contains(hour), characters[13] == ":", + let minute = number(characters, 14..<16), (0...59).contains(minute), characters[16] == ":", + let second = number(characters, 17..<19), (0...59).contains(second) + else { return false } + guard characters.count > 19 else { return true } + let fraction = characters[20...] + return characters[19] == "." && (1...9).contains(fraction.count) && fraction.allSatisfy(\.isASCIIDigit) + } + + private static func isCalendarDate(_ characters: [Character]) -> Bool { + guard characters.count == 10, characters[4] == "-", characters[7] == "-", + let year = number(characters, 0..<4), + let month = number(characters, 5..<7), + let day = number(characters, 8..<10) + else { return false } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + return DateComponents(year: year, month: month, day: day).isValidDate(in: calendar) + } + + private static func number(_ characters: [Character], _ range: Range) -> Int? { + let slice = characters[range] + guard slice.allSatisfy(\.isASCIIDigit) else { return nil } + return Int(String(slice)) + } +} + +private extension Character { + var isASCIIDigit: Bool { + isASCII && isNumber + } +} diff --git a/apple/TablePro/Core/DatabaseSearch/DatabaseSearchOptions.swift b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchOptions.swift new file mode 100644 index 0000000000..83bc1801b4 --- /dev/null +++ b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchOptions.swift @@ -0,0 +1,91 @@ +// +// DatabaseSearchOptions.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum DatabaseSearchMatchMode: String, CaseIterable, Identifiable, Sendable { + case contains + case exact + case startsWith + + internal var id: String { rawValue } + + internal var displayName: String { + switch self { + case .contains: String(localized: "Contains") + case .exact: String(localized: "Matches Exactly") + case .startsWith: String(localized: "Starts With") + } + } + + internal var textOperator: FilterOperator { + switch self { + case .contains: .contains + case .exact: .equal + case .startsWith: .startsWith + } + } + + internal var usesPattern: Bool { + self != .exact + } +} + +internal struct DatabaseSearchOptions: Equatable, Sendable { + internal var term: String = "" + internal var matchMode: DatabaseSearchMatchMode = .contains + internal var isCaseSensitive = false + internal var includesBinaryColumns = false + + internal var trimmedTerm: String { + term.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +internal struct DatabaseSearchTarget: Hashable, Identifiable, Sendable { + internal let name: String + internal let schema: String? + + internal init(name: String, schema: String?) { + self.name = name + self.schema = schema?.isEmpty == true ? nil : schema + } + + internal var id: String { + IdentityPath.qualified(name: name, schema: schema) + } + + internal var displayName: String { + guard let schema else { return name } + return "\(schema).\(name)" + } +} + +internal struct DatabaseSearchLaunchRequest: Hashable, Sendable { + internal let database: String + internal let schema: String? +} + +internal enum DatabaseSearchEligibility { + // PartiQL has no row limit to cap a table with, and CQL cannot match any one of several columns. + internal static func supportsSearch( + editorLanguage: EditorLanguage, + dialect: SQLDialectDescriptor?, + supportsMatchAnyFilters: Bool + ) -> Bool { + guard editorLanguage == .sql, let dialect, dialect.autoLimitStyle != .none else { return false } + return supportsMatchAnyFilters + } + + @MainActor + internal static func supportsSearch(_ databaseType: DatabaseType) -> Bool { + supportsSearch( + editorLanguage: PluginManager.shared.editorLanguage(for: databaseType), + dialect: try? resolveSQLDialect(for: databaseType), + supportsMatchAnyFilters: PluginManager.shared.supportsMatchAnyFilters(for: databaseType) + ) + } +} diff --git a/apple/TablePro/Core/DatabaseSearch/DatabaseSearchQuery.swift b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchQuery.swift new file mode 100644 index 0000000000..32bf375e49 --- /dev/null +++ b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchQuery.swift @@ -0,0 +1,131 @@ +// +// DatabaseSearchQuery.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// One row per matching row with a 0 or 1 per searched column, so the server says which column matched +// and the values themselves never leave it. +internal struct DatabaseSearchQuery: Equatable, Sendable { + internal let sql: String + internal let filters: [TableFilter] + internal let rowCap: Int + + internal static func build( + target: DatabaseSearchTarget, + columns: [DatabaseSearchColumnPlanner.Column], + filters: [TableFilter], + rowCap: Int, + databaseType: DatabaseType, + dialect: SQLDialectDescriptor, + quoteIdentifier: @escaping (String) -> String, + driver: (any PluginDatabaseDriver)? + ) -> DatabaseSearchQuery? { + let generator = FilterSQLGenerator( + dialect: dialect, + columns: columns.map(\.name), + columnTypes: columns.map(\.type), + quoteIdentifier: quoteIdentifier, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType), + databaseType: databaseType + ) + var kept: [TableFilter] = [] + var conditions: [String] = [] + for filter in filters { + guard let condition = generator.generateCondition(from: filter) else { continue } + kept.append(filter) + conditions.append(condition) + } + guard !conditions.isEmpty, rowCap > 0 else { return nil } + + let flags = conditions.enumerated() + .map { index, condition in "CASE WHEN \(condition) THEN 1 ELSE 0 END AS m\(index + 1)" } + .joined(separator: ", ") + let source = SchemaQualifiedName.render( + name: target.name, schema: target.schema, databaseType: databaseType, quote: quoteIdentifier + ) + let condition = conditions.map { "(\($0))" }.joined(separator: " OR ") + // One row past the cap, so a table holding exactly the cap is not reported as having more. + let limit = rowCap + 1 + let sql: String + if let driver { + sql = SQLRowLimitClause.select( + columns: flags, from: source, where: condition, limit: limit, + driver: driver, databaseType: databaseType + ) + } else { + sql = SQLRowLimitClause.select(columns: flags, from: source, where: condition, limit: limit, dialect: dialect) + } + return DatabaseSearchQuery(sql: sql, filters: kept, rowCap: rowCap) + } + + internal func match(from rows: [[PluginCellValue]]) -> DatabaseSearchMatch? { + guard !rows.isEmpty else { return nil } + let counted = rows.prefix(rowCap) + let matched = filters.indices.filter { index in + counted.contains { row in row.indices.contains(index) && Self.isSet(row[index]) } + } + return DatabaseSearchMatch( + searchedFilters: filters, + matchedFilters: matched.map { filters[$0] }, + rowCount: counted.count, + isCapped: rows.count > rowCap + ) + } + + private static func isSet(_ cell: PluginCellValue) -> Bool { + switch cell { + case .null: + return false + case .bytes(let data): + return data.contains { $0 != 0 } + case .text(let raw): + let text = raw.trimmingCharacters(in: .whitespaces).lowercased() + if let number = Double(text) { return number != 0 } + return text == "true" || text == "t" + } + } +} + +internal struct DatabaseSearchMatch: Equatable, Sendable { + internal let searchedFilters: [TableFilter] + internal let matchedFilters: [TableFilter] + internal let rowCount: Int + internal let isCapped: Bool + + internal var matchedColumns: [String] { + matchedFilters.map(\.columnName) + } + + // Past the cap a column can match only in rows that were never read, so the tab keeps every searched column. + internal var filterState: TabFilterState { + let filters = isCapped || matchedFilters.isEmpty ? searchedFilters : matchedFilters + return TabFilterState(filters: filters, commit: .all, isVisible: true, filterLogicMode: .or) + } +} + +internal struct DatabaseSearchTableResult: Identifiable, Equatable, Sendable { + internal enum Outcome: Equatable, Sendable { + case matched(DatabaseSearchMatch) + case noMatch + case noSearchableColumns + case failed(String) + } + + internal let target: DatabaseSearchTarget + internal let outcome: Outcome + + internal var id: String { target.id } + + internal var match: DatabaseSearchMatch? { + guard case .matched(let match) = outcome else { return nil } + return match + } + + internal var failure: String? { + guard case .failed(let message) = outcome else { return nil } + return message + } +} diff --git a/apple/TablePro/Core/DatabaseSearch/DatabaseSearchReader.swift b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchReader.swift new file mode 100644 index 0000000000..ab47a6daac --- /dev/null +++ b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchReader.swift @@ -0,0 +1,89 @@ +// +// DatabaseSearchReader.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +internal protocol DatabaseSearchReading: AnyObject { + func tables() async throws -> [DatabaseSearchTarget] + func search(_ target: DatabaseSearchTarget, options: DatabaseSearchOptions) async throws + -> DatabaseSearchTableResult.Outcome +} + +// Every read takes a pooled connection on the searched database, which carries the connection's query +// timeout, and the cancellable lease aborts the statement in flight when the search is stopped. +@MainActor +internal final class DatabaseSearchLiveReader: DatabaseSearchReading { + internal static let rowCap = 1_000 + + private let connection: DatabaseConnection + private let database: String + private let manager: DatabaseManager + + internal init(connection: DatabaseConnection, database: String, manager: DatabaseManager = .shared) { + self.connection = connection + self.database = database + self.manager = manager + } + + internal func tables() async throws -> [DatabaseSearchTarget] { + try await manager.ensureConnected(connection) + let endpoint = DatabaseEndpoint.from(connection: connection, database: database) + let namespaces = try await ObjectCopyCatalog(manager: manager).namespaces(of: endpoint, connection: connection) + let systemSchemas = Set(PluginManager.shared.systemSchemaNames(for: connection.type)) + var found: [DatabaseSearchTarget] = [] + for namespace in namespaces { + if let namespace, systemSchemas.contains(namespace) { continue } + let tables = try await manager.withMetadataDriver( + scope: endpoint.withSchema(namespace).scope, workload: .bulk + ) { driver in + try await driver.fetchTables(schema: namespace) + } + found += tables + .filter { $0.type == .table || $0.type == .partitionedTable } + .map { DatabaseSearchTarget(name: $0.name, schema: $0.schema ?? namespace) } + } + return found + } + + internal func search( + _ target: DatabaseSearchTarget, + options: DatabaseSearchOptions + ) async throws -> DatabaseSearchTableResult.Outcome { + let databaseType = connection.type + let dialect = try resolveSQLDialect(for: databaseType) + let family = SQLTypeFamily.of(databaseType) + let engine = DatabaseSearchColumnPlanner.Engine(databaseType: databaseType, dialect: dialect) + let rowCap = Self.rowCap + let scope = DatabaseScope(connectionId: connection.id, database: database, schema: target.schema) + return try await manager.withCancellableRead(scope: scope, route: manager.metadataRoute(for: scope)) { driver in + let classifier = ColumnTypeClassifier(family: family) + let columns = try await driver.fetchColumns(table: target.name, schema: target.schema).map { info in + let type = classifier.classify(rawTypeName: info.typeNameForClassification) + guard let labels = info.allowedValues, !labels.isEmpty else { + return DatabaseSearchColumnPlanner.Column(name: info.name, type: type) + } + return DatabaseSearchColumnPlanner.Column(name: info.name, type: type.withAllowedValues(labels)) + } + let filters = DatabaseSearchColumnPlanner.filters(for: columns, options: options, engine: engine) + guard let query = DatabaseSearchQuery.build( + target: target, + columns: columns, + filters: filters, + rowCap: rowCap, + databaseType: databaseType, + dialect: dialect, + quoteIdentifier: { driver.quoteIdentifier($0) }, + driver: CompareMetadataService.pluginDriver(from: driver) + ) else { return .noSearchableColumns } + // A Stop that landed between the metadata reads found no statement to abort. + try Task.checkCancellation() + let result = try await driver.execute(query: query.sql) + if let error = result.error { throw error } + return query.match(from: result.rows).map { .matched($0) } ?? .noMatch + } + } +} diff --git a/apple/TablePro/Core/DatabaseSearch/DatabaseSearchSession.swift b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchSession.swift new file mode 100644 index 0000000000..28dfb80b19 --- /dev/null +++ b/apple/TablePro/Core/DatabaseSearch/DatabaseSearchSession.swift @@ -0,0 +1,259 @@ +// +// DatabaseSearchSession.swift +// TablePro +// + +import Combine +import Foundation +import os + +internal enum DatabaseSearchStep: Equatable { + case configuring + case searching + case finished +} + +internal enum DatabaseSearchCatalogState: Equatable { + case idle + case loading + case loaded + case failed(String) +} + +internal struct DatabaseSearchSchemaGroup: Identifiable, Equatable { + internal let schema: String? + internal let targets: [DatabaseSearchTarget] + + internal var id: String { schema ?? "" } +} + +@MainActor +internal final class DatabaseSearchSession: ObservableObject, Identifiable { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseSearch") + + internal let id = UUID() + internal let launch: DatabaseSearchLaunchRequest + internal let scopeDescription: String + internal let caseMatching: FilterCaseMatching + private let reader: any DatabaseSearchReading + + @Published internal var options = DatabaseSearchOptions() + @Published internal var tableFilter = "" + @Published internal private(set) var catalogState: DatabaseSearchCatalogState = .idle + @Published internal private(set) var targets: [DatabaseSearchTarget] = [] + @Published internal private(set) var selectedTargetIds: Set = [] + + @Published internal private(set) var step: DatabaseSearchStep = .configuring + @Published internal private(set) var searchedOptions: DatabaseSearchOptions? + @Published internal private(set) var totalCount = 0 + @Published internal private(set) var completedCount = 0 + @Published internal private(set) var currentTarget: DatabaseSearchTarget? + @Published internal private(set) var results: [DatabaseSearchTableResult] = [] + @Published internal private(set) var wasStopped = false + @Published internal var selectedResultId: String? + + internal private(set) var runTask: Task? + private var runGeneration = 0 + + internal init( + launch: DatabaseSearchLaunchRequest, + scopeDescription: String, + caseMatching: FilterCaseMatching, + reader: any DatabaseSearchReading + ) { + self.launch = launch + self.scopeDescription = scopeDescription + self.caseMatching = caseMatching + self.reader = reader + } + + // MARK: - Catalog + + internal func loadTablesIfNeeded() async { + switch catalogState { + case .loaded, .loading: + return + case .idle, .failed: + break + } + catalogState = .loading + do { + let found = try await reader.tables() + targets = found + selectedTargetIds = Set(found.filter(isPreselected).map(\.id)) + catalogState = .loaded + } catch is CancellationError { + catalogState = .idle + } catch { + Self.logger.warning("Search catalog unreadable: \(error.publicLogShape, privacy: .public)") + catalogState = .failed(error.localizedDescription) + } + } + + private func isPreselected(_ target: DatabaseSearchTarget) -> Bool { + guard let schema = launch.schema, !schema.isEmpty else { return true } + return target.schema == schema + } + + // MARK: - Scope + + internal var filteredTargets: [DatabaseSearchTarget] { + let query = tableFilter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return targets } + return targets.filter { $0.displayName.localizedCaseInsensitiveContains(query) } + } + + internal var schemaGroups: [DatabaseSearchSchemaGroup] { + var order: [String?] = [] + var grouped: [String: [DatabaseSearchTarget]] = [:] + for target in filteredTargets { + let key = target.schema ?? "" + if grouped[key] == nil { order.append(target.schema) } + grouped[key, default: []].append(target) + } + return order.map { DatabaseSearchSchemaGroup(schema: $0, targets: grouped[$0 ?? ""] ?? []) } + } + + internal var showsSchemas: Bool { + targets.contains { $0.schema != nil } + } + + internal func isSelected(_ target: DatabaseSearchTarget) -> Bool { + selectedTargetIds.contains(target.id) + } + + internal func setSelected(_ target: DatabaseSearchTarget, _ isSelected: Bool) { + if isSelected { + selectedTargetIds.insert(target.id) + } else { + selectedTargetIds.remove(target.id) + } + } + + internal func selectAllFiltered() { + selectedTargetIds.formUnion(filteredTargets.map(\.id)) + } + + internal func deselectAllFiltered() { + selectedTargetIds.subtract(filteredTargets.map(\.id)) + } + + internal var canSelectAllFiltered: Bool { + filteredTargets.contains { !selectedTargetIds.contains($0.id) } + } + + internal var canDeselectAllFiltered: Bool { + filteredTargets.contains { selectedTargetIds.contains($0.id) } + } + + internal var selectionSummary: String { + String( + format: String(localized: "%1$@ of %2$@ tables"), + selectedTargetIds.count.formatted(.number.grouping(.automatic)), + targets.count.formatted(.number.grouping(.automatic)) + ) + } + + internal var searchDisabledReason: String? { + if options.trimmedTerm.isEmpty { + return String(localized: "Enter the value to search for.") + } + guard catalogState == .loaded else { return nil } + if selectedTargetIds.isEmpty { + return String(localized: "Choose at least one table to search.") + } + return nil + } + + internal var canSearch: Bool { + catalogState == .loaded && searchDisabledReason == nil + } + + // MARK: - Run + + internal func start() { + guard canSearch, step != .searching else { return } + let chosen = targets.filter { selectedTargetIds.contains($0.id) } + let searched = options + runTask?.cancel() + searchedOptions = searched + results = [] + selectedResultId = nil + wasStopped = false + totalCount = chosen.count + completedCount = 0 + currentTarget = nil + step = .searching + runGeneration += 1 + let generation = runGeneration + runTask = Task { [weak self] in + await self?.run(chosen, options: searched, generation: generation) + } + } + + private func run( + _ chosen: [DatabaseSearchTarget], + options searched: DatabaseSearchOptions, + generation: Int + ) async { + for target in chosen { + guard !Task.isCancelled else { break } + currentTarget = target + let outcome: DatabaseSearchTableResult.Outcome + do { + outcome = try await reader.search(target, options: searched) + } catch { + // A stopped search aborts the statement in flight, which reports as that table failing. + guard !Task.isCancelled else { break } + Self.logger.warning("Search of a table failed: \(error.publicLogShape, privacy: .public)") + outcome = .failed(error.localizedDescription) + } + guard !Task.isCancelled else { break } + results.append(DatabaseSearchTableResult(target: target, outcome: outcome)) + completedCount += 1 + } + // Edit Search may have moved on from this run while its last read was still answering. + guard generation == runGeneration, step == .searching else { return } + wasStopped = completedCount < totalCount + currentTarget = nil + step = .finished + if selectedResultId == nil { + selectedResultId = matchedResults.first?.id + } + } + + internal func stop() { + runTask?.cancel() + } + + internal func editSearch() { + runTask?.cancel() + runGeneration += 1 + currentTarget = nil + step = .configuring + } + + // MARK: - Results + + internal var matchedResults: [DatabaseSearchTableResult] { + results.filter { $0.match != nil } + } + + internal var failedResults: [DatabaseSearchTableResult] { + results.filter { $0.failure != nil } + } + + internal var listedResults: [DatabaseSearchTableResult] { + matchedResults + failedResults + } + + internal var selectedMatch: DatabaseSearchTableResult? { + guard let selectedResultId else { return nil } + return results.first { $0.id == selectedResultId && $0.match != nil } + } + + internal var progressFraction: Double { + guard totalCount > 0 else { return 0 } + return Double(completedCount) / Double(totalCount) + } +} diff --git a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift index 23f2418b09..984daf68b7 100644 --- a/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -56,6 +56,10 @@ enum DatabaseMenuBuilder { action: #selector(MainSplitViewController.createTableFolder(_:)) ), MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Search Database…"), + action: #selector(MainSplitViewController.searchDatabase(_:)) + ), /// The sidebar's own Copy To and Duplicate Database, mirrored so both are reachable /// from the keyboard. The menu acts on the database being browsed, which is what a /// command with no clicked row can mean. Spelled exactly as the sidebar and the sheet diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index 2c23165807..bcff7481ec 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -167,6 +167,10 @@ extension MainSplitViewController { commandActions?.duplicateCurrentDatabase() } + @objc func searchDatabase(_ sender: Any?) { + commandActions?.searchDatabase() + } + @objc func showTableStructure(_ sender: Any?) { commandActions?.showTableStructure() } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 9551c8f6a5..f83f99c29f 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -131,6 +131,7 @@ struct MenuValidationContext: Equatable { var canCreateTableFolder = false var canCopyObjects = false var canDuplicateDatabase = false + var canSearchDatabase = false var hasMaintenanceOperations = false var canUndo = false var canRedo = false @@ -303,6 +304,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.canCopyObjects case #selector(duplicateCurrentDatabase(_:)): return context.canDuplicateDatabase + case #selector(searchDatabase(_:)): + return context.isConnected && context.canSearchDatabase case #selector(showTableStructure(_:)), #selector(editViewDefinition(_:)), #selector(showObjectDDL(_:)), @@ -508,6 +511,7 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(navigateBack(_:)), #selector(navigateForward(_:)), #selector(moveTabToConnection(_:)), + #selector(searchDatabase(_:)), ] /// What AppKit is told. A command this window does not own is left enabled, which is what keeps @@ -685,6 +689,7 @@ extension MainSplitViewController: NSMenuItemValidation { canCreateTableFolder: canCreateTableFolder, canCopyObjects: actions.canCopyObjects, canDuplicateDatabase: actions.canDuplicateDatabase, + canSearchDatabase: actions.canSearchDatabase, hasMaintenanceOperations: !actions.maintenanceOperations.isEmpty, canUndo: actions.canUndo, canRedo: actions.canRedo, diff --git a/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift b/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift index 4b15e11818..1f52969da1 100644 --- a/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift +++ b/apple/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift @@ -50,11 +50,14 @@ internal enum ColumnTypeSQLQuoting { /// and XML have no equality operator on the engines that define them (Oracle ORA-00932, SQL /// Server error 402, PostgreSQL `operator does not exist: xml = unknown`), and neither does /// PostgreSQL's `json`. - static func hasEqualityOperator(_ type: ColumnType) -> Bool { + static func hasEqualityOperator(_ type: ColumnType, family: SQLTypeFamily? = nil) -> Bool { switch type { case .text(let rawType): guard let rawType else { return true } - return !typesWithoutEquality.contains(baseName(of: rawType)) + let base = baseName(of: rawType) + // SQL Server's legacy TEXT is NTEXT's single-byte twin, also with no `=` (error 402). + if family == .mssql, base == "TEXT" { return false } + return !typesWithoutEquality.contains(base) case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .enumType, .set, .vector: return true case .blob, .json, .spatial, .array: diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..a7b20a828b 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -5237,6 +5237,9 @@ } } } + }, + "%@ tables could not be searched." : { + }, "%@ takes no ORDER BY, LIMIT or OFFSET" : { "extractionState" : "manual", @@ -5967,6 +5970,9 @@ } } } + }, + "%@+ rows" : { + }, "%@×" : { "comment" : "Number of times a query ran, %@ is a count\nSlowdown multiple, %@ is a number", @@ -7033,6 +7039,9 @@ } } } + }, + "%1$@ of %2$@ tables" : { + }, "%1$@ of about %2$@ rows" : { "localizations" : { @@ -7103,6 +7112,9 @@ } } } + }, + "%1$@ tables hold “%2$@”." : { + }, "%1$@ takes %2$lld dimensions, and the value has %3$lld." : { @@ -13343,6 +13355,12 @@ } } } + }, + "1 table could not be searched." : { + + }, + "1 table holds “%@”." : { + }, "1 visible row" : { "localizations" : { @@ -33468,6 +33486,9 @@ } } } + }, + "Cannot Read the Tables" : { + }, "Cannot Read This Connection" : { "localizations" : { @@ -37455,6 +37476,9 @@ } } } + }, + "Choose at least one table to search." : { + }, "Choose Backup Folder" : { "localizations" : { @@ -48508,6 +48532,9 @@ } } } + }, + "Contains" : { + }, "Content" : { "localizations" : { @@ -68589,6 +68616,9 @@ } } } + }, + "Edit Search" : { + }, "Edit the connection and choose its database type." : { "localizations" : { @@ -71248,6 +71278,9 @@ } } } + }, + "Enter the value to search for." : { + }, "Enter this code on GitHub:" : { "localizations" : { @@ -81072,6 +81105,9 @@ } } } + }, + "Filter Tables" : { + }, "Filter value" : { "localizations" : { @@ -91160,6 +91196,9 @@ } } } + }, + "Include binary columns" : { + }, "Include byte order mark" : { "extractionState" : "stale", @@ -105433,6 +105472,9 @@ }, "Matches “%@”" : { + }, + "Matches Exactly" : { + }, "matches regex" : { "localizations" : { @@ -117815,6 +117857,9 @@ } } } + }, + "No searched column holds this value." : { + }, "No selection" : { "localizations" : { @@ -140810,6 +140855,9 @@ } } } + }, + "Reading the tables…" : { + }, "Reading this connection's database options…" : { "localizations" : { @@ -156206,6 +156254,9 @@ } } } + }, + "Search %@…" : { + }, "Search activity" : { "localizations" : { @@ -156456,6 +156507,12 @@ } } } + }, + "Search Database" : { + + }, + "Search Database…" : { + }, "Search databases" : { "localizations" : { @@ -156884,6 +156941,15 @@ } } } + }, + "Searched %@ tables." : { + + }, + "Searched 1 table." : { + + }, + "Searching %1$@ (%2$@)" : { + }, "Searching…" : { "localizations" : { @@ -169891,6 +169957,9 @@ } } } + }, + "Starts With" : { + }, "Starts with %@" : { "extractionState" : "manual", @@ -171833,6 +171902,9 @@ } } } + }, + "Stopped after %1$@ of %2$@ tables." : { + }, "Stopped after %d seconds, the query timeout" : { "extractionState" : "manual", @@ -179633,6 +179705,9 @@ } } } + }, + "Text columns are always searched. Number, date and UUID columns are searched when the value reads as one, and are compared exactly." : { + }, "Text contrast is %@:1. Aim for at least 4.5:1." : { @@ -192576,6 +192651,9 @@ } } } + }, + "This database reports no tables to search." : { + }, "This database type has no dump TablePro can drive." : { "localizations" : { diff --git a/apple/TablePro/Views/DatabaseSearch/DatabaseSearchConfigureView.swift b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchConfigureView.swift new file mode 100644 index 0000000000..cca57fdbe2 --- /dev/null +++ b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchConfigureView.swift @@ -0,0 +1,197 @@ +// +// DatabaseSearchConfigureView.swift +// TablePro +// + +import SwiftUI + +internal struct DatabaseSearchConfigureView: View { + @ObservedObject internal var session: DatabaseSearchSession + @FocusState private var isTermFocused: Bool + + internal var body: some View { + HStack(spacing: 0) { + settings + .padding(20) + .frame(width: 300, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .top) + Divider() + DatabaseSearchTableListView(session: session) + .frame(maxWidth: .infinity) + } + .frame(maxHeight: .infinity) + .onAppear { isTermFocused = true } + } + + private var settings: some View { + VStack(alignment: .leading, spacing: 18) { + labelled(String(localized: "Find")) { + TextField(String(localized: "Value"), text: $session.options.term) + .textFieldStyle(.roundedBorder) + .focused($isTermFocused) + .onSubmit { session.start() } + .accessibilityLabel(String(localized: "Find")) + .accessibilityIdentifier("database-search-term") + } + labelled(String(localized: "Match")) { + Picker(String(localized: "Match"), selection: $session.options.matchMode) { + ForEach(DatabaseSearchMatchMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .labelsHidden() + .accessibilityIdentifier("database-search-match") + Toggle(String(localized: "Match Case"), isOn: $session.options.isCaseSensitive) + .toggleStyle(.checkbox) + .disabled(!session.caseMatching.isAdjustable) + .accessibilityIdentifier("database-search-case-sensitive") + if let reason = session.caseMatching.fixedReason { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + } + labelled(String(localized: "Columns")) { + Toggle(String(localized: "Include binary columns"), isOn: $session.options.includesBinaryColumns) + .toggleStyle(.checkbox) + .accessibilityIdentifier("database-search-binary") + Text(String(localized: "Text columns are always searched. Number, date and UUID columns are searched when the value reads as one, and are compared exactly.")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func labelled(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.subheadline.weight(.medium)) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +internal struct DatabaseSearchTableListView: View { + @ObservedObject internal var session: DatabaseSearchSession + + internal var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + list + if session.catalogState == .loaded, !session.targets.isEmpty { + Divider() + Text(session.selectionSummary) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .accessibilityIdentifier("database-search-selection-summary") + } + } + } + + private var toolbar: some View { + HStack(spacing: 8) { + NativeSearchField( + text: $session.tableFilter, + placeholder: String(localized: "Filter Tables"), + controlSize: .small, + accessibilityIdentifier: "database-search-table-filter" + ) + Spacer(minLength: 8) + Button(String(localized: "Select All")) { session.selectAllFiltered() } + .controlSize(.small) + .disabled(!session.canSelectAllFiltered) + Button(String(localized: "Deselect All")) { session.deselectAllFiltered() } + .controlSize(.small) + .disabled(!session.canDeselectAllFiltered) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var list: some View { + switch session.catalogState { + case .idle, .loading: + VStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(String(localized: "Reading the tables…")) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + UnavailableStateView { + Label(String(localized: "Cannot Read the Tables"), systemImage: "exclamationmark.triangle") + } description: { + RevealedTextView(message) + } actions: { + Button(String(localized: "Try Again")) { Task { await session.loadTablesIfNeeded() } } + } + case .loaded: + if session.targets.isEmpty { + UnavailableStateView { + Label(String(localized: "No Tables"), systemImage: "tray") + } description: { + Text(String(localized: "This database reports no tables to search.")) + } + } else { + tableList + } + } + } + + private var tableList: some View { + List { + if session.showsSchemas { + ForEach(session.schemaGroups) { group in + Section { + ForEach(group.targets) { target in + row(target, title: target.name) + } + } header: { + schemaToggle(group) + } + } + } else { + ForEach(session.filteredTargets) { target in + row(target, title: target.name) + } + } + } + .listStyle(.inset) + .accessibilityIdentifier("database-search-tables") + } + + private func row(_ target: DatabaseSearchTarget, title: String) -> some View { + Toggle(isOn: selection(for: target)) { + Text(title) + .lineLimit(1) + .truncationMode(.middle) + } + .toggleStyle(.checkbox) + } + + // A mixed checkbox: on when every table of the schema is ticked, dashed when only some are. + private func schemaToggle(_ group: DatabaseSearchSchemaGroup) -> some View { + Toggle(sources: group.targets.map(selection(for:)), isOn: \.self) { + Text(group.schema ?? "") + } + .toggleStyle(.checkbox) + } + + private func selection(for target: DatabaseSearchTarget) -> Binding { + Binding( + get: { session.isSelected(target) }, + set: { session.setSelected(target, $0) } + ) + } +} diff --git a/apple/TablePro/Views/DatabaseSearch/DatabaseSearchResultsView.swift b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchResultsView.swift new file mode 100644 index 0000000000..2982f5da7c --- /dev/null +++ b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchResultsView.swift @@ -0,0 +1,187 @@ +// +// DatabaseSearchResultsView.swift +// TablePro +// + +import SwiftUI + +internal struct DatabaseSearchResultsView: View { + @ObservedObject internal var session: DatabaseSearchSession + internal let onOpen: (DatabaseSearchTableResult) -> Void + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + status + .padding(.horizontal, 20) + .padding(.vertical, 12) + Divider() + results + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var status: some View { + if session.step == .searching { + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: session.progressFraction) + .progressViewStyle(.linear) + Text(DatabaseSearchSummary.progress( + current: session.currentTarget, + completed: session.completedCount, + total: session.totalCount + )) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("database-search-progress") + } else { + Text(DatabaseSearchSummary.finished( + term: session.searchedOptions?.trimmedTerm ?? "", + matchedTables: session.matchedResults.count, + failedTables: session.failedResults.count, + completed: session.completedCount, + total: session.totalCount, + wasStopped: session.wasStopped + )) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("database-search-summary") + } + } + + @ViewBuilder + private var results: some View { + if session.listedResults.isEmpty { + if session.step == .finished { + UnavailableStateView { + Label(String(localized: "No Matches"), systemImage: "magnifyingglass") + } description: { + Text(String(localized: "No searched column holds this value.")) + } + } else { + Spacer(minLength: 0) + } + } else { + List(session.listedResults, selection: $session.selectedResultId) { result in + DatabaseSearchResultRow(result: result) + } + .listStyle(.inset) + .contextMenu(forSelectionType: String.self) { ids in + if let result = openable(ids) { + Button(String(localized: "Open")) { onOpen(result) } + } + } primaryAction: { ids in + if let result = openable(ids) { onOpen(result) } + } + .accessibilityIdentifier("database-search-results") + } + } + + private func openable(_ ids: Set) -> DatabaseSearchTableResult? { + guard ids.count == 1, let id = ids.first else { return nil } + return session.results.first { $0.id == id && $0.match != nil } + } +} + +private struct DatabaseSearchResultRow: View { + let result: DatabaseSearchTableResult + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: result.failure == nil ? "tablecells" : "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(result.target.displayName) + .lineLimit(1) + .truncationMode(.middle) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + } + Spacer(minLength: 8) + if let match = result.match { + Text(DatabaseSearchSummary.rowCount(match)) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + .help(result.failure ?? "") + } + + private var detail: String { + if let failure = result.failure { + return failure + } + guard let match = result.match else { return "" } + return match.matchedColumns.joined(separator: ", ") + } +} + +internal enum DatabaseSearchSummary { + internal static func progress(current: DatabaseSearchTarget?, completed: Int, total: Int) -> String { + let counts = String( + format: String(localized: "%1$@ of %2$@ tables"), + completed.formatted(.number.grouping(.automatic)), + total.formatted(.number.grouping(.automatic)) + ) + guard let current else { return counts } + return String(format: String(localized: "Searching %1$@ (%2$@)"), current.displayName, counts) + } + + internal static func rowCount(_ match: DatabaseSearchMatch) -> String { + let count = match.rowCount.formatted(.number.grouping(.automatic)) + if match.isCapped { + return String(format: String(localized: "%@+ rows"), count) + } + return match.rowCount == 1 + ? String(format: String(localized: "%@ row"), count) + : String(format: String(localized: "%@ rows"), count) + } + + internal static func finished( + term: String, + matchedTables: Int, + failedTables: Int, + completed: Int, + total: Int, + wasStopped: Bool + ) -> String { + let searched = wasStopped + ? String( + format: String(localized: "Stopped after %1$@ of %2$@ tables."), + completed.formatted(.number.grouping(.automatic)), + total.formatted(.number.grouping(.automatic)) + ) + : total == 1 + ? String(localized: "Searched 1 table.") + : String( + format: String(localized: "Searched %@ tables."), + total.formatted(.number.grouping(.automatic)) + ) + let found = matchedTables == 1 + ? String(format: String(localized: "1 table holds “%@”."), term) + : String( + format: String(localized: "%1$@ tables hold “%2$@”."), + matchedTables.formatted(.number.grouping(.automatic)), + term + ) + var parts = [searched, found] + if failedTables > 0 { + parts.append(failedTables == 1 + ? String(localized: "1 table could not be searched.") + : String( + format: String(localized: "%@ tables could not be searched."), + failedTables.formatted(.number.grouping(.automatic)) + )) + } + return parts.joined(separator: " ") + } +} diff --git a/apple/TablePro/Views/DatabaseSearch/DatabaseSearchSheet.swift b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchSheet.swift new file mode 100644 index 0000000000..d09a7707a1 --- /dev/null +++ b/apple/TablePro/Views/DatabaseSearch/DatabaseSearchSheet.swift @@ -0,0 +1,105 @@ +// +// DatabaseSearchSheet.swift +// TablePro +// + +import SwiftUI + +internal struct DatabaseSearchSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject internal var session: DatabaseSearchSession + internal let onOpen: (DatabaseSearchTableResult) -> Void + + internal init(session: DatabaseSearchSession, onOpen: @escaping (DatabaseSearchTableResult) -> Void) { + self.session = session + self.onOpen = onOpen + } + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 720, height: 520) + .task { await session.loadTablesIfNeeded() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Search Database")) + .font(.title3.weight(.semibold)) + Text(session.scopeDescription) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + + @ViewBuilder + private var content: some View { + switch session.step { + case .configuring: + DatabaseSearchConfigureView(session: session) + case .searching, .finished: + DatabaseSearchResultsView(session: session, onOpen: open) + } + } + + private var footer: some View { + DialogFooter { + statusText + } actions: { + actionButtons + } + .padding(20) + } + + @ViewBuilder + private var statusText: some View { + if session.step == .configuring, let reason = session.searchDisabledReason { + Text(reason) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + @ViewBuilder + private var actionButtons: some View { + switch session.step { + case .configuring: + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Search")) { session.start() } + .keyboardShortcut(.defaultAction) + .disabled(!session.canSearch) + .accessibilityIdentifier("database-search-start") + case .searching: + Button(String(localized: "Stop")) { session.stop() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("database-search-stop") + case .finished: + Button(String(localized: "Edit Search")) { session.editSearch() } + .accessibilityIdentifier("database-search-edit") + Button(String(localized: "Done"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Open")) { + if let selected = session.selectedMatch { open(selected) } + } + .keyboardShortcut(.defaultAction) + .disabled(session.selectedMatch == nil) + .accessibilityIdentifier("database-search-open") + } + } + + private func open(_ result: DatabaseSearchTableResult) { + guard result.match != nil else { return } + onOpen(result) + } +} diff --git a/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift b/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift index 31eb3b065d..2c04494c04 100644 --- a/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift +++ b/apple/TablePro/Views/Filter/CellFilterMenuBuilder.swift @@ -20,7 +20,8 @@ enum CellFilterMenuBuilder { static func conditions( columnName: String, columnType: ColumnType?, - value: PluginCellValue + value: PluginCellValue, + family: SQLTypeFamily? = nil ) -> [TableFilter] { switch value { case .null: @@ -29,7 +30,7 @@ enum CellFilterMenuBuilder { guard let columnType, ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return [] } return [.isEmpty, .isNotEmpty].map { filter(columnName, $0) } case .text(let text): - guard let columnType, matchesExactly(text, columnType: columnType) else { return [] } + guard let columnType, matchesExactly(text, columnType: columnType, family: family) else { return [] } return comparisonOperators(for: columnType).map { filter(columnName, $0, value: text) } case .bytes: return [] @@ -50,9 +51,10 @@ enum CellFilterMenuBuilder { columnName: String, columnType: ColumnType?, value: PluginCellValue, + family: SQLTypeFamily? = nil, apply: @escaping (TableFilter) -> Void ) -> NSMenuItem? { - let filters = conditions(columnName: columnName, columnType: columnType, value: value) + let filters = conditions(columnName: columnName, columnType: columnType, value: value, family: family) guard !filters.isEmpty else { return nil } let submenu = NSMenu() @@ -66,8 +68,8 @@ enum CellFilterMenuBuilder { return item } - private static func matchesExactly(_ text: String, columnType: ColumnType) -> Bool { - guard ColumnTypeSQLQuoting.hasEqualityOperator(columnType), + private static func matchesExactly(_ text: String, columnType: ColumnType, family: SQLTypeFamily?) -> Bool { + guard ColumnTypeSQLQuoting.hasEqualityOperator(columnType, family: family), (text as NSString).length <= maxValueLength, text == text.trimmingCharacters(in: .whitespaces) else { return false } return !FilterOperand.readsAsNullLiteral(text, columnType: columnType) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseSearch.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseSearch.swift new file mode 100644 index 0000000000..ed30a38113 --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseSearch.swift @@ -0,0 +1,70 @@ +// +// MainContentCoordinator+DatabaseSearch.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +extension MainContentCoordinator { + var canSearchDatabase: Bool { + DatabaseSearchEligibility.supportsSearch(connection.type) + } + + // The database and schema travel with the request: the sidebar row the user right-clicked can be + // a database the session is not browsing. + func openDatabaseSearch(database: String?, schema: String?) { + let launch = DatabaseSearchLaunchRequest( + database: database?.nilIfEmpty ?? browseDatabaseName, + schema: schema?.nilIfEmpty + ) + activeSheet = .searchDatabase(databaseSearchSession(for: launch)) + } + + func databaseSearchSession(for launch: DatabaseSearchLaunchRequest) -> DatabaseSearchSession { + if let existing = databaseSearchSession, existing.launch == launch { + return existing + } + databaseSearchSession?.stop() + let endpoint = DatabaseEndpoint.from(connection: connection, database: launch.database, schema: launch.schema) + let dialect = try? resolveSQLDialect(for: connection.type) + let session = DatabaseSearchSession( + launch: launch, + scopeDescription: endpoint.qualifiedDescription, + caseMatching: .engine(dialect?.caseSensitivityStyle ?? .unsupported), + reader: DatabaseSearchLiveReader(connection: connection, database: launch.database) + ) + databaseSearchSession = session + return session + } + + func openDatabaseSearchResult(_ result: DatabaseSearchTableResult, in session: DatabaseSearchSession) { + guard let match = result.match else { return } + session.stop() + activeSheet = nil + openTabInNewWindow(makeDatabaseSearchResultPayload( + target: result.target, + match: match, + database: session.launch.database + )) + } + + // Always a tab of its own: reusing an open tab of the table would write over the filters applied there. + func makeDatabaseSearchResultPayload( + target: DatabaseSearchTarget, + match: DatabaseSearchMatch, + database: String + ) -> EditorTabPayload { + EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: target.name, + databaseName: database, + schemaName: target.schema, + isView: false, + forcesNewTab: true, + initialFilterState: match.filterState + ) + } +} diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift index d6a383f341..2bb6581dcc 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -155,6 +155,14 @@ extension MainContentCommandActions { func duplicateCurrentDatabase() { coordinator?.openCopyObjects(mode: .duplicateDatabase, database: nil, schema: nil, preselection: .wholeScope) } + + var canSearchDatabase: Bool { + isConnected && coordinator?.canSearchDatabase == true + } + + func searchDatabase() { + coordinator?.openDatabaseSearch(database: nil, schema: nil) + } } @MainActor diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 13262dc2fd..4cfe29f328 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -91,6 +91,7 @@ enum ActiveSheet: Identifiable { /// carries its scope: the document is written where it was read, not wherever the object /// browser points by the time Save is pressed. case documentEditor(DocumentEditorRequest) + case searchDatabase(DatabaseSearchSession) var id: String { switch self { @@ -114,6 +115,7 @@ enum ActiveSheet: Identifiable { case .rewind: "rewind" case .tableRebuildReview: "tableRebuildReview" case .documentEditor(let request): "documentEditor-\(request.id.uuidString)" + case .searchDatabase(let session): "searchDatabase-\(session.id.uuidString)" } } } @@ -310,6 +312,8 @@ final class MainContentCoordinator: ObservableObject { @Published var importFile: ImportFileHandoff? @Published var exportPreselection: ExportPreselection? @Published var exportFormatId: String? + // Kept after the sheet closes, so reopening Search Database after opening a result shows the results again. + var databaseSearchSession: DatabaseSearchSession? @Published var pendingLoadTrigger: TableLoadTrigger? var deferredRestoreLoadTabId: UUID? @@ -1003,6 +1007,8 @@ final class MainContentCoordinator: ObservableObject { draftSaveTask = nil redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil + databaseSearchSession?.stop() + databaseSearchSession = nil dataTabDelegate?.tableViewCoordinator?.releaseData() diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index b54d2ae30a..d110403180 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -239,6 +239,10 @@ struct MainContentView: View { ObjectCommentSheet(target: target, connection: connection) case .documentEditor(let request): DocumentEditorSheet(request: request, databaseType: connection.type) + case .searchDatabase(let session): + DatabaseSearchSheet(session: session) { result in + coordinator.openDatabaseSearchResult(result, in: session) + } case .exportDialog, .exportQueryResults, .importDialog, .rowImport, .transferTables, .backupDatabase, .restoreDatabase: transferSheetContent(for: sheet, dismiss: dismissBinding) diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+CellFilterMenu.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+CellFilterMenu.swift index 90293d1fec..5bab8e1e1b 100644 --- a/apple/TablePro/Views/Results/Extensions/DataGridView+CellFilterMenu.swift +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+CellFilterMenu.swift @@ -22,6 +22,7 @@ internal extension TableViewCoordinator { columnName: columns[dataColumn], columnType: filterMenuColumnType(forDataColumn: dataColumn, in: tableRows), value: row.values[dataColumn], + family: databaseType.map(SQLTypeFamily.of), apply: apply ) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index ee1de99ca3..f46184f4da 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -125,27 +125,8 @@ extension DatabaseTreeOutlineCoordinator { mainCoordinator?.activeSheet = .backupDatabase(databases: Set(databases)) case .dropContainers(let targets): mainCoordinator?.requestContainerDrop(targets) - case .copyObjectsTo(let objects, let ref): - mainCoordinator?.openCopyObjects( - mode: .copyTo, - database: ref?.database, - schema: ref?.qualifyingSchema, - preselection: .objects(objects) - ) - case .copyContainerTo(let container): - mainCoordinator?.openCopyObjects( - mode: .copyTo, - database: container.database, - schema: container.kind == .schema ? container.schema : nil, - preselection: .wholeScope - ) - case .duplicateDatabase(let container): - mainCoordinator?.openCopyObjects( - mode: .duplicateDatabase, - database: container.database, - schema: nil, - preselection: .wholeScope - ) + case .copyObjectsTo, .copyContainerTo, .duplicateDatabase, .searchContainer: + performSheetCommand(command) case .showAllTablesMetadata: mainCoordinator?.showAllTablesMetadata() case .refreshObjectKind(let kind): @@ -174,6 +155,39 @@ extension DatabaseTreeOutlineCoordinator { } } + private func performSheetCommand(_ command: SidebarMenuCommand) { + switch command { + case .copyObjectsTo(let objects, let ref): + mainCoordinator?.openCopyObjects( + mode: .copyTo, + database: ref?.database, + schema: ref?.qualifyingSchema, + preselection: .objects(objects) + ) + case .copyContainerTo(let container): + mainCoordinator?.openCopyObjects( + mode: .copyTo, + database: container.database, + schema: container.kind == .schema ? container.schema : nil, + preselection: .wholeScope + ) + case .duplicateDatabase(let container): + mainCoordinator?.openCopyObjects( + mode: .duplicateDatabase, + database: container.database, + schema: nil, + preselection: .wholeScope + ) + case .searchContainer(let container): + mainCoordinator?.openDatabaseSearch( + database: container.database, + schema: container.kind == .schema ? container.schema : nil + ) + default: + break + } + } + private func setFavoriteDatabases(_ databases: [String], environment: FavoriteDatabaseEnvironment) { for database in databases { favoriteDatabasesStorage.setFavorite(database: database, environment: environment, connectionId: connectionId) diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 2aa4b51013..6e09134c7e 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -111,6 +111,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { supportsDatabaseSwitching: PluginManager.shared.supportsDatabaseSwitching(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false ), + canSearchData: DatabaseSearchEligibility.supportsSearch(databaseType), canBackUp: backupIsAvailable(), canCreateTable: creation.canCreateTable, canCreateView: creation.canCreateView, diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index 917d3aa101..a49aff939d 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -56,6 +56,7 @@ internal struct DatabaseTreeMenuContext { internal var canCopyObjects: Bool = false /// Duplicating means creating a database, which is the same test the New Database command uses. internal var canDuplicateDatabase: Bool = false + internal var canSearchData: Bool = false /// Whether this connection has a dump tool at all. Backing up writes nothing to the database, /// so safe mode does not gate it, which is the same rule File > Backup Dump follows. diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 59fa1cace4..2c67de3e91 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -362,7 +362,7 @@ internal enum DatabaseTreeMenuSpec { /// Oracle, Snowflake, Trino, Dameng and BigQuery draw their schemas here rather than as /// container rows, and several of them need a schema-scoped source, so leaving Copy To /// on the container path alone put it out of reach on exactly the engines that require it. - DatabaseTreeMenuSection(copyItems(ref, context: context)), + DatabaseTreeMenuSection(searchItems(ref, context: context) + copyItems(ref, context: context)), DatabaseTreeMenuSection(writes) ] } @@ -407,6 +407,9 @@ internal enum DatabaseTreeMenuSpec { context: DatabaseTreeMenuContext ) -> [DatabaseTreeMenuItem] { var items: [DatabaseTreeMenuItem] = [] + if targets.count == 1 { + items += searchItems(clicked, context: context) + } if ExportPreselection.canPreselect( containers: targets, activeDatabase: context.activeDatabase, @@ -445,6 +448,18 @@ internal enum DatabaseTreeMenuSpec { return items } + private static func searchItems( + _ clicked: DatabaseContainerRef, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuItem] { + guard context.canSearchData, !clicked.isSystem else { return [] } + let title = String( + format: String(localized: "Search %@\u{2026}"), + clicked.kind == .schema ? context.schemaEntityName : context.containerEntityName + ) + return [.command(title, .searchContainer(clicked))] + } + private static func backUpTitle(count: Int) -> String { count == 1 ? String(localized: "Back Up\u{2026}") diff --git a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index a1d62e2525..e1da198a3c 100644 --- a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -66,6 +66,7 @@ internal enum SidebarMenuCommand: Equatable { case copyObjectsTo(objects: [ObjectCopySelection], ref: DatabaseTreeTableRef?) case copyContainerTo(DatabaseContainerRef) case duplicateDatabase(DatabaseContainerRef) + case searchContainer(DatabaseContainerRef) case showAllTablesMetadata case refreshObjectKind(SidebarObjectKind) case refreshContainerObjectKind(DatabaseTreeObjectGroup) diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorCaseSensitivityTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorCaseSensitivityTests.swift index 9f43ecc279..655c4ef886 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorCaseSensitivityTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorCaseSensitivityTests.swift @@ -253,23 +253,23 @@ struct FilterSQLGeneratorCaseSensitivityTests { @Test("Trino contains ignoring case uses an unanchored regex") func testTrinoContains() { - #expect(condition(Self.trino, .contains) == "REGEXP_LIKE(\"name\", 'smith', 'i')") + #expect(condition(Self.trino, .contains) == "REGEXP_LIKE(\"name\", '(?i)smith')") } @Test("Trino starts with ignoring case anchors at the start") func testTrinoStartsWith() { - #expect(condition(Self.trino, .startsWith) == "REGEXP_LIKE(\"name\", '^smith', 'i')") + #expect(condition(Self.trino, .startsWith) == "REGEXP_LIKE(\"name\", '(?i)^smith')") } @Test("Trino ends with ignoring case anchors at the end") func testTrinoEndsWith() { - #expect(condition(Self.trino, .endsWith) == "REGEXP_LIKE(\"name\", 'smith$', 'i')") + #expect(condition(Self.trino, .endsWith) == "REGEXP_LIKE(\"name\", '(?i)smith$')") } @Test("Trino equals ignoring case anchors both ends") func testTrinoEquals() { let sql = condition(Self.trino, .equal, isCaseSensitive: false) - #expect(sql == "REGEXP_LIKE(\"name\", '^smith$', 'i')") + #expect(sql == "REGEXP_LIKE(\"name\", '(?i)^smith$')") } @Test("Trino matching case keeps plain LIKE") @@ -281,7 +281,7 @@ struct FilterSQLGeneratorCaseSensitivityTests { @Test("Trino escapes regex metacharacters in the value") func testTrinoEscapesMetacharacters() { let sql = condition(Self.trino, .contains, value: "a.b*c") - #expect(sql == "REGEXP_LIKE(\"name\", 'a\\.b\\*c', 'i')") + #expect(sql == "REGEXP_LIKE(\"name\", '(?i)a\\.b\\*c')") } // MARK: - Wildcard Escaping diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift index b71c05ec34..10a2ebd9d0 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift @@ -413,6 +413,35 @@ struct FilterSQLGeneratorColumnTypeTests { #expect(tinyint == "`v` = '5'") } + // Oracle's default NLS_DATE_FORMAT is DD-MON-RR, so a quoted ISO date fails with ORA-01861. + @Test("An Oracle date or timestamp column compares an ANSI literal") + func oracleTemporalLiterals() { + let oracle = DatabaseType.oracle + let date = Self.engineCondition(oracle, type: .date(rawType: "DATE"), value: "2024-02-29", dialect: Self.postgresqlDialect) + let stamp = Self.engineCondition( + oracle, type: .timestamp(rawType: "TIMESTAMP(6)"), value: "2024-02-29T13:45:10.5", dialect: Self.postgresqlDialect + ) + let text = Self.engineCondition(oracle, type: .text(rawType: "VARCHAR2"), value: "2024-02-29", dialect: Self.postgresqlDialect) + let other = Self.engineCondition(.postgresql, type: .date(rawType: "date"), value: "2024-02-29", dialect: Self.postgresqlDialect) + #expect(date == "\"v\" = DATE '2024-02-29'") + #expect(stamp == "\"v\" = TIMESTAMP '2024-02-29 13:45:10.5'") + #expect(text == "\"v\" = '2024-02-29'") + #expect(other == "\"v\" = '2024-02-29'") + } + + // T-SQL reads `[` as the start of a character class, so `a[bc]` matched `ab` and `ac`. + @Test("A SQL Server pattern escapes the bracket, other engines leave it") + func sqlServerEscapesBracket() { + let mssql = Self.engineCondition( + .mssql, type: .text(rawType: "nvarchar"), op: .contains, value: "a[bc]", dialect: Self.postgresqlDialect + ) + let postgres = Self.engineCondition( + .postgresql, type: .text(rawType: "text"), op: .contains, value: "a[bc]", dialect: Self.postgresqlDialect + ) + #expect(mssql?.contains("'%a![bc]%'") == true) + #expect(postgres?.contains("'%a[bc]%'") == true) + } + @Test("A PostgreSQL bit read as a string of digits compares quoted") func postgresBitIsQuoted() { let type = ColumnTypeClassifier(family: .postgres).classify(rawTypeName: "bit") diff --git a/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchColumnPlannerTests.swift b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchColumnPlannerTests.swift new file mode 100644 index 0000000000..1cd8c32361 --- /dev/null +++ b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchColumnPlannerTests.swift @@ -0,0 +1,206 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct DatabaseSearchColumnPlannerTests { + private typealias Column = DatabaseSearchColumnPlanner.Column + private typealias Engine = DatabaseSearchColumnPlanner.Engine + + private static let postgres = Engine(family: .postgres, castsToText: true) + + private static let columns: [Column] = [ + Column(name: "email", type: .text(rawType: "varchar(255)")), + Column(name: "notes", type: .text(rawType: "TEXT")), + Column(name: "id", type: .integer(rawType: "bigint")), + Column(name: "price", type: .decimal(rawType: "numeric(10,2)")), + Column(name: "born", type: .date(rawType: "date")), + Column(name: "created_at", type: .timestamp(rawType: "timestamp")), + Column(name: "opens_at", type: .date(rawType: "time")), + Column(name: "token", type: .text(rawType: "uuid")), + Column(name: "avatar", type: .blob(rawType: "bytea")), + Column(name: "active", type: .boolean(rawType: "boolean")), + Column(name: "profile", type: .json(rawType: "jsonb")), + Column(name: "address", type: .text(rawType: "inet")), + Column(name: "body", type: .text(rawType: "CLOB")), + Column(name: "doc", type: .text(rawType: "xml")), + Column(name: "tags", type: .array(rawType: "text[]", element: .text(rawType: "text"))), + Column(name: "embedding", type: .vector(rawType: "vector(3)")) + ] + + private func searched(_ term: String, _ configure: (inout DatabaseSearchOptions) -> Void = { _ in }) -> [String: TableFilter] { + var options = DatabaseSearchOptions(term: term) + configure(&options) + let filters = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: options, engine: Self.postgres) + return Dictionary(uniqueKeysWithValues: filters.map { ($0.columnName, $0) }) + } + + @Test("A word searches text columns only, with the match mode and case flag") + func wordSearchesTextColumns() { + let filters = searched("ada@example.com") + #expect(Set(filters.keys) == ["email", "notes", "profile", "body"]) + #expect(filters["email"]?.filterOperator == .contains) + #expect(filters["email"]?.isCaseSensitive == false) + #expect(filters["email"]?.value == "ada@example.com") + } + + @Test("A number joins integer and decimal columns as an exact comparison") + func numberJoinsNumericColumns() { + let filters = searched("42") + #expect(filters["id"]?.filterOperator == .equal) + #expect(filters["price"]?.filterOperator == .equal) + #expect(filters["email"]?.filterOperator == .contains) + #expect(filters["born"] == nil) + } + + @Test("A fraction joins decimal columns but not integer ones") + func fractionSkipsIntegers() { + let filters = searched("4.25") + #expect(filters["price"] != nil) + #expect(filters["id"] == nil) + } + + @Test("A date joins date columns, a date and time joins timestamp columns") + func datesJoinTheirColumns() { + let date = searched("2024-02-29") + #expect(date["born"]?.filterOperator == .equal) + #expect(date["created_at"] == nil) + #expect(date["opens_at"] == nil) + + let moment = searched("2024-02-29 13:45:10.123") + #expect(moment["created_at"]?.filterOperator == .equal) + #expect(moment["born"] == nil) + + #expect(searched("2023-02-29")["born"] == nil) + #expect(searched("2024-02-29 24:00:00")["created_at"] == nil) + #expect(searched("2024-02-29 13:45")["created_at"] == nil) + #expect(searched("2024-02-29T13:45:10")["created_at"]?.filterOperator == .equal) + } + + @Test("A UUID joins UUID columns, anything else leaves them out") + func uuidJoinsUUIDColumns() { + let filters = searched("6F9619FF-8B86-D011-B42D-00C04FC964FF") + #expect(filters["token"]?.filterOperator == .equal) + #expect(searched("6F9619FF")["token"] == nil) + } + + @Test("Binary columns join only when ticked, and never for an exact match") + func binaryNeedsTheOption() { + #expect(searched("abc")["avatar"] == nil) + #expect(searched("abc") { $0.includesBinaryColumns = true }["avatar"]?.filterOperator == .contains) + let exact = searched("abc") { + $0.includesBinaryColumns = true + $0.matchMode = .exact + } + #expect(exact["avatar"] == nil) + } + + @Test("An exact match skips types with no equality and non-character text") + func exactSkipsTypesWithoutEquality() { + let filters = searched("hello") { $0.matchMode = .exact } + #expect(Set(filters.keys) == ["email", "notes"]) + #expect(filters["email"]?.filterOperator == .equal) + } + + @Test("Starts with and case sensitivity reach every text filter") + func modeAndCaseReachTextFilters() { + let filters = searched("Ada") { + $0.matchMode = .startsWith + $0.isCaseSensitive = true + } + #expect(filters["email"]?.filterOperator == .startsWith) + #expect(filters["email"]?.isCaseSensitive == true) + } + + @Test("Boolean, array, vector and XML columns are never searched") + func unsearchableTypesStayOut() { + let filters = searched("true") + #expect(filters["active"] == nil) + #expect(filters["tags"] == nil) + #expect(filters["doc"] == nil) + #expect(searched("[1,2,3]")["embedding"] == nil) + } + + @Test("A blank term searches nothing, and surrounding whitespace is dropped") + func blankAndPaddedTerms() { + #expect(searched(" ").isEmpty) + #expect(searched(" ada\n")["email"]?.value == "ada") + } + + @Test("ClickHouse's Nullable(String) counts as character text for an exact match") + func wrappedStringIsCharacterText() { + var options = DatabaseSearchOptions(term: "ada") + options.matchMode = .exact + let filters = DatabaseSearchColumnPlanner.filters( + for: [Column(name: "name", type: .text(rawType: "Nullable(String)"))], + options: options, + engine: Engine(family: .clickhouse, castsToText: true) + ) + #expect(filters.map(\.columnName) == ["name"]) + } + + @Test("An exact match skips SQL Server's legacy TEXT, which has no equality, and keeps TEXT elsewhere") + func exactSkipsSQLServerText() { + var options = DatabaseSearchOptions(term: "hello") + options.matchMode = .exact + let notes = [Column(name: "notes", type: .text(rawType: "text"))] + #expect(DatabaseSearchColumnPlanner.filters(for: notes, options: options, engine: Engine(family: .mssql, castsToText: false)).isEmpty) + #expect(DatabaseSearchColumnPlanner.filters(for: notes, options: options, engine: Self.postgres).count == 1) + options.matchMode = .contains + #expect(DatabaseSearchColumnPlanner.filters(for: notes, options: options, engine: Engine(family: .mssql, castsToText: false)).count == 1) + } + + @Test("An engine that cannot read every column as text searches only its character columns") + func strictEngineSearchesCharacterColumnsOnly() { + let trino = Engine(family: .generic, castsToText: false) + var options = DatabaseSearchOptions(term: "ada") + options.includesBinaryColumns = true + let words = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: options, engine: trino) + #expect(Set(words.map(\.columnName)) == ["email", "notes", "body"]) + + let oracle = Engine(family: .oracle, castsToText: false) + let oracleWords = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: options, engine: oracle) + #expect(Set(oracleWords.map(\.columnName)) == ["email", "notes", "body"]) + + let mysql = Engine(family: .mysql, castsToText: false) + let mysqlWords = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: options, engine: mysql) + #expect(Set(mysqlWords.map(\.columnName)) == ["email", "notes", "profile", "body", "avatar"]) + } + + @Test("An engine without a type family gets no quoted date or UUID comparison") + func genericEngineSkipsTypedLiterals() { + let trino = Engine(family: .generic, castsToText: false) + let date = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: DatabaseSearchOptions(term: "2024-02-29"), engine: trino) + #expect(date.map(\.columnName).contains("born") == false) + let uuid = DatabaseSearchColumnPlanner.filters( + for: Self.columns, options: DatabaseSearchOptions(term: "6F9619FF-8B86-D011-B42D-00C04FC964FF"), engine: trino + ) + #expect(uuid.map(\.columnName).contains("token") == false) + let number = DatabaseSearchColumnPlanner.filters(for: Self.columns, options: DatabaseSearchOptions(term: "42"), engine: trino) + #expect(number.map(\.columnName).contains("id")) + } + + @Test("An exact match on a SET takes a list of declared labels") + func exactSetTakesALabelList() { + let colors = Column(name: "colors", type: .set(rawType: "set", values: ["red", "green", "blue"])) + var options = DatabaseSearchOptions(term: "red,blue") + options.matchMode = .exact + #expect(DatabaseSearchColumnPlanner.filters(for: [colors], options: options, engine: Self.postgres).count == 1) + options.term = "red,pink" + #expect(DatabaseSearchColumnPlanner.filters(for: [colors], options: options, engine: Self.postgres).isEmpty) + } + + @Test("An exact match on an enum needs a declared label") + func exactEnumNeedsALabel() { + let status = Column(name: "status", type: .enumType(rawType: "enum", values: ["open", "closed"])) + var options = DatabaseSearchOptions(term: "OPEN") + options.matchMode = .exact + #expect(DatabaseSearchColumnPlanner.filters(for: [status], options: options, engine: Self.postgres).count == 1) + options.isCaseSensitive = true + #expect(DatabaseSearchColumnPlanner.filters(for: [status], options: options, engine: Self.postgres).isEmpty) + options.term = "pending" + options.isCaseSensitive = false + #expect(DatabaseSearchColumnPlanner.filters(for: [status], options: options, engine: Self.postgres).isEmpty) + } +} diff --git a/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchQueryTests.swift b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchQueryTests.swift new file mode 100644 index 0000000000..3389d49343 --- /dev/null +++ b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchQueryTests.swift @@ -0,0 +1,125 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct DatabaseSearchQueryTests { + private typealias Column = DatabaseSearchColumnPlanner.Column + + private static let postgres = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .tilde, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static let sqlServer = SQLDialectDescriptor( + identifierQuote: "[", keywords: [], functions: [], dataTypes: [], + booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, autoLimitStyle: .top + ) + + private static let columns = [ + Column(name: "email", type: .text(rawType: "varchar")), + Column(name: "id", type: .integer(rawType: "int")) + ] + + private static func quote(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private func build( + _ term: String, + target: DatabaseSearchTarget = DatabaseSearchTarget(name: "users", schema: "app"), + columns: [Column] = Self.columns, + dialect: SQLDialectDescriptor = Self.postgres, + rowCap: Int = 1_000 + ) -> DatabaseSearchQuery? { + let filters = DatabaseSearchColumnPlanner.filters( + for: columns, + options: DatabaseSearchOptions(term: term), + engine: DatabaseSearchColumnPlanner.Engine(family: .postgres, castsToText: true) + ) + return DatabaseSearchQuery.build( + target: target, + columns: columns, + filters: filters, + rowCap: rowCap, + databaseType: .postgresql, + dialect: dialect, + quoteIdentifier: Self.quote, + driver: nil + ) + } + + @Test("One flag per searched column, the columns joined with OR, and one row past the cap") + func flagsPerColumnAndCap() throws { + let query = try #require(build("42")) + #expect(query.filters.map(\.columnName) == ["email", "id"]) + #expect(query.sql.hasPrefix("SELECT CASE WHEN ")) + #expect(query.sql.contains("THEN 1 ELSE 0 END AS m1, CASE WHEN ")) + #expect(query.sql.contains("FROM \"app\".\"users\" WHERE (")) + #expect(query.sql.contains(") OR (\"id\" = 42)")) + #expect(query.sql.hasSuffix(" LIMIT 1001")) + } + + @Test("LIKE wildcards and quotes in the value are escaped, identifiers are quoted") + func valueAndIdentifiersAreEscaped() throws { + let columns = [Column(name: "na\"me", type: .text(rawType: "text"))] + let query = try #require(build("50%_off's", target: DatabaseSearchTarget(name: "odd\"table", schema: nil), columns: columns)) + #expect(query.sql.contains("\"na\"\"me\"")) + #expect(query.sql.contains("FROM \"odd\"\"table\" WHERE")) + #expect(query.sql.contains("50!%!_off''s")) + #expect(query.sql.contains("ESCAPE '!'")) + } + + @Test("An engine that cannot say LIMIT gets its own row cap") + func offsetFetchEngineCapsWithTop() throws { + let query = try #require(build("ada", dialect: Self.sqlServer)) + #expect(query.sql.contains("OFFSET 0 ROWS FETCH NEXT 1001 ROWS ONLY")) + #expect(!query.sql.contains("LIMIT")) + } + + @Test("No searchable column builds no query") + func noColumnsNoQuery() { + #expect(build("ada", columns: [Column(name: "id", type: .integer(rawType: "int"))]) == nil) + } + + @Test("The rows say which columns matched and how many rows") + func matchReadsTheFlags() throws { + let query = try #require(build("42", rowCap: 3)) + let match = try #require(query.match(from: [["0", "1"], ["0", "1"]])) + #expect(match.matchedColumns == ["id"]) + #expect(match.rowCount == 2) + #expect(match.isCapped == false) + #expect(query.match(from: []) == nil) + } + + @Test("A row past the cap marks the count as capped and stops counting at the cap") + func capIsReported() throws { + let query = try #require(build("42", rowCap: 2)) + let match = try #require(query.match(from: [["1", "0"], ["1", "0"], ["0", "1"]])) + #expect(match.rowCount == 2) + #expect(match.isCapped) + #expect(match.matchedColumns == ["email"]) + } + + @Test("Flags read as numbers, booleans or bytes") + func flagSpellings() throws { + let query = try #require(build("42")) + let match = try #require(query.match(from: [["true", .bytes(Data([1]))], ["0.0", nil]])) + #expect(match.matchedColumns == ["email", "id"]) + } + + @Test("The tab opens on the matched columns, or every searched one when the count was capped") + func filterStateFollowsTheCap() throws { + let query = try #require(build("42", rowCap: 5)) + let exact = try #require(query.match(from: [["0", "1"]])) + #expect(exact.filterState.filters.map(\.columnName) == ["id"]) + #expect(exact.filterState.filterLogicMode == .or) + #expect(exact.filterState.appliedFilters.map(\.columnName) == ["id"]) + + let capped = try #require(DatabaseSearchQuery(sql: "", filters: query.filters, rowCap: 1).match(from: [["0", "1"], ["0", "1"]])) + #expect(capped.filterState.filters.map(\.columnName) == ["email", "id"]) + } +} diff --git a/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchSessionTests.swift b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchSessionTests.swift new file mode 100644 index 0000000000..62833fad9e --- /dev/null +++ b/apple/TableProTests/Core/DatabaseSearch/DatabaseSearchSessionTests.swift @@ -0,0 +1,179 @@ +import Foundation +import TableProPluginKit +import TableProTestSupport +import Testing + +@testable import TablePro + +@MainActor +private final class ScriptedSearchReader: DatabaseSearchReading { + var catalog: [DatabaseSearchTarget] + var outcomes: [String: DatabaseSearchTableResult.Outcome] = [:] + var failures: [String: String] = [:] + var gate: AsyncGate? + var gatedTable: String? + private(set) var searched: [String] = [] + + init(catalog: [DatabaseSearchTarget]) { + self.catalog = catalog + } + + func tables() async throws -> [DatabaseSearchTarget] { + catalog + } + + func search( + _ target: DatabaseSearchTarget, + options: DatabaseSearchOptions + ) async throws -> DatabaseSearchTableResult.Outcome { + searched.append(target.name) + if target.name == gatedTable, let gate { + await gate.wait() + } + if let message = failures[target.name] { + throw DatabaseError.queryFailed(message) + } + return outcomes[target.name] ?? .noMatch + } +} + +@MainActor +struct DatabaseSearchSessionTests { + private static let users = DatabaseSearchTarget(name: "users", schema: "app") + private static let orders = DatabaseSearchTarget(name: "orders", schema: "app") + private static let audit = DatabaseSearchTarget(name: "audit", schema: "log") + + private static func match(_ column: String) -> DatabaseSearchTableResult.Outcome { + let filter = TableFilter(columnName: column, filterOperator: .contains, value: "ada") + return .matched(DatabaseSearchMatch(searchedFilters: [filter], matchedFilters: [filter], rowCount: 1, isCapped: false)) + } + + private func makeSession( + _ reader: ScriptedSearchReader, + schema: String? = nil + ) async -> DatabaseSearchSession { + let session = DatabaseSearchSession( + launch: DatabaseSearchLaunchRequest(database: "shop", schema: schema), + scopeDescription: "Local / shop", + caseMatching: .engine(.caseFoldFunction), + reader: reader + ) + await session.loadTablesIfNeeded() + session.options.term = "ada" + return session + } + + private func finish(_ session: DatabaseSearchSession) async { + await session.runTask?.value + } + + @Test("A database launch ticks every table, a schema launch only that schema's") + func preselectionFollowsTheLaunch() async { + let reader = ScriptedSearchReader(catalog: [Self.users, Self.orders, Self.audit]) + let whole = await makeSession(reader) + #expect(whole.selectedTargetIds == [Self.users.id, Self.orders.id, Self.audit.id]) + + let schema = await makeSession(reader, schema: "log") + #expect(schema.selectedTargetIds == [Self.audit.id]) + #expect(schema.schemaGroups.map(\.schema) == ["app", "log"]) + } + + @Test("Every ticked table is searched in order and the matches are listed") + func searchesEveryTickedTable() async { + let reader = ScriptedSearchReader(catalog: [Self.users, Self.orders, Self.audit]) + reader.outcomes = ["users": Self.match("email"), "audit": Self.match("actor")] + let session = await makeSession(reader) + session.setSelected(Self.orders, false) + + session.start() + await finish(session) + + #expect(reader.searched == ["users", "audit"]) + #expect(session.step == .finished) + #expect(session.completedCount == 2) + #expect(session.wasStopped == false) + #expect(session.matchedResults.map(\.target.name) == ["users", "audit"]) + #expect(session.selectedMatch?.target == Self.users) + } + + @Test("A table that fails is listed with its error and the search goes on") + func failureDoesNotEndTheSearch() async { + let reader = ScriptedSearchReader(catalog: [Self.users, Self.orders, Self.audit]) + reader.failures = ["orders": "permission denied for table orders"] + reader.outcomes = ["audit": Self.match("actor")] + let session = await makeSession(reader) + + session.start() + await finish(session) + + #expect(reader.searched == ["users", "orders", "audit"]) + #expect(session.failedResults.map(\.target.name) == ["orders"]) + #expect(session.failedResults.first?.failure == "permission denied for table orders") + #expect(session.matchedResults.map(\.target.name) == ["audit"]) + #expect(session.listedResults.map(\.target.name) == ["audit", "orders"]) + } + + @Test("Stop ends the search before the next table and keeps what was found") + func stopEndsAtTheNextTable() async throws { + let reader = ScriptedSearchReader(catalog: [Self.users, Self.orders, Self.audit]) + reader.outcomes = ["users": Self.match("email")] + let gate = AsyncGate() + reader.gate = gate + reader.gatedTable = "orders" + let session = await makeSession(reader) + + session.start() + try await waitUntil { reader.searched == ["users", "orders"] } + session.stop() + await gate.open() + await finish(session) + + #expect(reader.searched == ["users", "orders"]) + #expect(session.step == .finished) + #expect(session.wasStopped) + #expect(session.completedCount == 1) + #expect(session.matchedResults.map(\.target.name) == ["users"]) + } + + @Test("Edit Search returns to the form and a late read cannot finish the run behind it") + func editSearchFencesTheRun() async throws { + let reader = ScriptedSearchReader(catalog: [Self.users]) + let gate = AsyncGate() + reader.gate = gate + reader.gatedTable = "users" + let session = await makeSession(reader) + + session.start() + try await waitUntil { reader.searched == ["users"] } + session.editSearch() + await gate.open() + await finish(session) + + #expect(session.step == .configuring) + #expect(session.results.isEmpty) + } + + @Test("Search is refused without a value or without a table") + func searchNeedsTermAndTables() async { + let reader = ScriptedSearchReader(catalog: [Self.users]) + let session = await makeSession(reader) + session.options.term = " " + #expect(!session.canSearch) + session.options.term = "ada" + session.deselectAllFiltered() + #expect(!session.canSearch) + session.selectAllFiltered() + #expect(session.canSearch) + } + + @Test("The table filter narrows the list and Select All acts on what it shows") + func filterNarrowsSelection() async { + let reader = ScriptedSearchReader(catalog: [Self.users, Self.orders, Self.audit]) + let session = await makeSession(reader) + session.deselectAllFiltered() + session.tableFilter = "ORD" + #expect(session.filteredTargets == [Self.orders]) + session.selectAllFiltered() + #expect(session.selectedTargetIds == [Self.orders.id]) + } +} diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json index 1d2c5cdd36..b89ef0c615 100644 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ b/apple/TableProTests/Core/Menu/ChromeSnapshot.json @@ -201,6 +201,7 @@ {"path": "Database > New View…", "action": "createNewView:"}, {"path": "Database > New Folder", "action": "createTableFolder:"}, {"path": "Database", "separator": true}, + {"path": "Database > Search Database…", "action": "searchDatabase:"}, {"path": "Database > Copy To…", "action": "copyObjectsToDatabase:"}, {"path": "Database > Duplicate Database…", "action": "duplicateCurrentDatabase:"}, {"path": "Database", "separator": true}, diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 57baa9fd55..2e1c59c869 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -1086,6 +1086,17 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.duplicateCurrentDatabase(_:)), context)) } + @Test("Search Database needs a connection and an engine it can search") + func searchDatabaseNeedsAnEligibleEngine() { + var context = MenuValidationContext() + context.canSearchDatabase = true + #expect(!enabled(#selector(MainSplitViewController.searchDatabase(_:)), context)) + context.isConnected = true + #expect(enabled(#selector(MainSplitViewController.searchDatabase(_:)), context)) + context.canSearchDatabase = false + #expect(!enabled(#selector(MainSplitViewController.searchDatabase(_:)), context)) + } + @Test("New Database needs a driver that switches containers") func createDatabaseNeedsContainerSupport() { var context = MenuValidationContext() diff --git a/apple/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift b/apple/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift index 7501f7e580..263f52c241 100644 --- a/apple/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift +++ b/apple/TableProTests/Views/Filter/CellFilterMenuBuilderTests.swift @@ -83,6 +83,13 @@ struct CellFilterMenuBuilderTests { #expect(operators(.bytes(Data([0x01, 0x02])), type: .blob(rawType: "BLOB")).isEmpty) } + @Test("SQL Server's legacy TEXT offers nothing, TEXT on other engines compares") + func sqlServerTextHasNoEquality() { + let text = ColumnType.text(rawType: "text") + #expect(CellFilterMenuBuilder.conditions(columnName: "c", columnType: text, value: "x", family: .mssql).isEmpty) + #expect(CellFilterMenuBuilder.conditions(columnName: "c", columnType: text, value: "x", family: .mysql).count == 2) + } + @Test("A value over the length cap offers nothing") func overlongValue() { let atCap = String(repeating: "x", count: CellFilterMenuBuilder.maxValueLength) diff --git a/apple/TableProTests/Views/Main/DatabaseSearchResultOpeningTests.swift b/apple/TableProTests/Views/Main/DatabaseSearchResultOpeningTests.swift new file mode 100644 index 0000000000..fee2eb9e5f --- /dev/null +++ b/apple/TableProTests/Views/Main/DatabaseSearchResultOpeningTests.swift @@ -0,0 +1,101 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class StaticSearchReader: DatabaseSearchReading { + func tables() async throws -> [DatabaseSearchTarget] { [] } + + func search( + _ target: DatabaseSearchTarget, + options: DatabaseSearchOptions + ) async throws -> DatabaseSearchTableResult.Outcome { + .noMatch + } +} + +@MainActor +struct DatabaseSearchResultOpeningTests { + private func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "shop"), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + @Test("Opening a result opens the table in a tab of its own, filtered to the matched columns") + func openingAResultOpensAFilteredTab() { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + + let email = TableFilter(columnName: "email", filterOperator: .contains, value: "ada") + let name = TableFilter(columnName: "name", filterOperator: .contains, value: "ada") + let result = DatabaseSearchTableResult( + target: DatabaseSearchTarget(name: "customers", schema: "sales"), + outcome: .matched(DatabaseSearchMatch( + searchedFilters: [email, name], matchedFilters: [email], rowCount: 3, isCapped: false + )) + ) + let session = DatabaseSearchSession( + launch: DatabaseSearchLaunchRequest(database: "archive", schema: nil), + scopeDescription: "", + caseMatching: .engine(.caseFoldFunction), + reader: StaticSearchReader() + ) + coordinator.activeSheet = .searchDatabase(session) + + coordinator.openDatabaseSearchResult(result, in: session) + + #expect(coordinator.activeSheet == nil) + #expect(opened.count == 1) + let payload = opened.first + #expect(payload?.tabType == .table) + #expect(payload?.tableName == "customers") + #expect(payload?.schemaName == "sales") + #expect(payload?.databaseName == "archive") + #expect(payload?.forcesNewTab == true) + #expect(payload?.initialFilterState?.appliedFilters == [email]) + #expect(payload?.initialFilterState?.filterLogicMode == .or) + } + + @Test("A result without a match opens nothing") + func failedResultOpensNothing() { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + var opened: [EditorTabPayload] = [] + coordinator.openTabInNewWindow = { opened.append($0) } + let session = DatabaseSearchSession( + launch: DatabaseSearchLaunchRequest(database: "shop", schema: nil), + scopeDescription: "", + caseMatching: .engine(.caseFoldFunction), + reader: StaticSearchReader() + ) + + coordinator.openDatabaseSearchResult( + DatabaseSearchTableResult(target: DatabaseSearchTarget(name: "t", schema: nil), outcome: .failed("x")), + in: session + ) + + #expect(opened.isEmpty) + } + + @Test("Reopening the search on the same database keeps its session, another scope starts a new one") + func sessionIsKeptPerScope() { + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + + let first = coordinator.databaseSearchSession(for: DatabaseSearchLaunchRequest(database: "shop", schema: nil)) + let again = coordinator.databaseSearchSession(for: DatabaseSearchLaunchRequest(database: "shop", schema: nil)) + let other = coordinator.databaseSearchSession(for: DatabaseSearchLaunchRequest(database: "shop", schema: "sales")) + + #expect(first === again) + #expect(first !== other) + #expect(coordinator.databaseSearchSession === other) + } +} diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 1f7a36a9bd..34269ca44a 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -51,6 +51,7 @@ struct DatabaseTreeMenuSpecTests { supportsRename: Bool = true, canCopyObjects: Bool = true, canDuplicateDatabase: Bool = true, + canSearchData: Bool = true, canCreateTable: Bool = true, canCreateView: Bool = true, canCreateDatabase: Bool = false, @@ -122,6 +123,7 @@ struct DatabaseTreeMenuSpecTests { hasSchemaFilter: hasSchemaFilter, canCopyObjects: canCopyObjects, canDuplicateDatabase: canDuplicateDatabase, + canSearchData: canSearchData, canCreateTable: canCreateTable, canCreateView: canCreateView, canCreateDatabase: canCreateDatabase, @@ -1176,6 +1178,49 @@ struct DatabaseTreeMenuSpecTests { #expect(!issued.contains { if case .duplicateDatabase = $0 { return true } else { return false } }) } + // MARK: - Search + + @Test("A database row offers Search Database, a schema row Search Schema") + func containersOfferSearch() { + let database = DatabaseTreeMenuSpec.sections(for: context( + clicked: databaseKind("app"), selectedContainers: [.database("app")] + )) + #expect(commands(database).contains(.searchContainer(.database("app")))) + #expect(titles(database).contains("Search Database\u{2026}")) + + let schema = DatabaseContainerRef.schema(database: "app", schema: "sales") + let schemaSections = DatabaseTreeMenuSpec.sections(for: context( + clicked: .schema(database: "app", schema: "sales"), selectedContainers: [schema] + )) + #expect(commands(schemaSections).contains(.searchContainer(schema))) + #expect(titles(schemaSections).contains("Search Schema\u{2026}")) + } + + @Test("A schema drawn in a hierarchical tree offers Search Schema too") + func hierarchicalSchemaOffersSearch() { + let issued = commands(DatabaseTreeMenuSpec.sections( + for: context(clicked: .hierarchicalSchemaSection(schema: "SALES")) + )) + #expect(issued.contains(.searchContainer(.schema(database: "app", schema: "SALES")))) + } + + @Test("Search is not offered on an engine it cannot run on, a system container or a multi-selection") + func searchNeedsOneUserContainer() { + func offersSearch(_ sections: [DatabaseTreeMenuSection]) -> Bool { + commands(sections).contains { if case .searchContainer = $0 { return true } else { return false } } + } + #expect(!offersSearch(DatabaseTreeMenuSpec.sections(for: context( + clicked: databaseKind("app"), selectedContainers: [.database("app")], canSearchData: false + )))) + #expect(!offersSearch(DatabaseTreeMenuSpec.sections(for: context( + clicked: databaseKind("information_schema", isSystem: true), + selectedContainers: [.database("information_schema", isSystem: true)] + )))) + #expect(!offersSearch(DatabaseTreeMenuSpec.sections(for: context( + clicked: databaseKind("app"), selectedContainers: [.database("app"), .database("archive")] + )))) + } + // MARK: - Schema management @Test("A schema row offers Edit where the engine has a facet to edit") diff --git a/apple/TableProUITests/SearchDatabaseUITests.swift b/apple/TableProUITests/SearchDatabaseUITests.swift new file mode 100644 index 0000000000..812c724ab7 --- /dev/null +++ b/apple/TableProUITests/SearchDatabaseUITests.swift @@ -0,0 +1,50 @@ +import XCTest + +// #3601: Search Database finds which table holds a value and opens that table filtered to its rows. +final class SearchDatabaseUITests: UITestCase { + func testSearchFindsTheTableHoldingAValueAndOpensItFiltered() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + + menuItem("Search Database…", under: "Database", in: app).click() + + let sheet = window.children(matching: .sheet).firstMatch + let term = sheet.textFields.matching(identifier: "database-search-term").firstMatch + XCTAssertTrue(waitUntilHittable(term, timeout: 20), "Search Database must open a sheet with a value field") + XCTAssertTrue( + sheet.descendants(matching: .any).matching(identifier: "database-search-tables").firstMatch + .waitToExist(timeout: 30), + "The sheet must list the tables it can search" + ) + term.click() + app.typeText("brook") + + let search = sheet.buttons.matching(identifier: "database-search-start").firstMatch + XCTAssertTrue(waitForPredicate(timeout: 20) { search.isEnabled }, "Search must enable once a value is typed") + search.click() + + let summary = sheet.staticTexts.matching(identifier: "database-search-summary").firstMatch + XCTAssertTrue(summary.waitToExist(timeout: 60), "The search must finish and say what it found") + XCTAssertTrue( + (summary.value as? String ?? summary.label).contains("1 table holds"), + "Only artist holds Brook" + ) + + let open = sheet.buttons.matching(identifier: "database-search-open").firstMatch + XCTAssertTrue(waitForPredicate(timeout: 20) { open.isEnabled }, "The first match must be selected and openable") + open.click() + + XCTAssertTrue(waitForPredicate(timeout: 20) { !sheet.exists }, "Opening a match must close the sheet") + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.editorTabs(titled: "artist", in: window).firstMatch.exists }, + "Opening a match must open its table in a tab" + ) + let grid = fixtureGrid(in: window, app: app) + XCTAssertTrue(grid.waitForValue("Brook", row: 1, column: 2, timeout: 30), "The tab must show the matching row") + XCTAssertFalse( + grid.cell(row: 2, column: 2).waitToExist(timeout: 2), + "The tab must be filtered to the matching rows" + ) + } +} diff --git a/apple/changelog.d/3737.added.md b/apple/changelog.d/3737.added.md new file mode 100644 index 0000000000..3afd05917d --- /dev/null +++ b/apple/changelog.d/3737.added.md @@ -0,0 +1 @@ +Search Database finds every table and column holding a value, and opens a table filtered to the matching rows. diff --git a/apple/changelog.d/3737.fixed.1.md b/apple/changelog.d/3737.fixed.1.md new file mode 100644 index 0000000000..4fd9cb0e9f --- /dev/null +++ b/apple/changelog.d/3737.fixed.1.md @@ -0,0 +1 @@ +The cell Filter menu offered equals on a SQL Server TEXT column, which the server refuses. diff --git a/apple/changelog.d/3737.fixed.md b/apple/changelog.d/3737.fixed.md new file mode 100644 index 0000000000..3b9f462584 --- /dev/null +++ b/apple/changelog.d/3737.fixed.md @@ -0,0 +1 @@ +Filters that ignore case failed on Trino and Presto with a REGEXP_LIKE error.