Repository navigation
feat(search): find a value in every table of a database #3737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
de7e0f7
7bb8b02
67ab952
b5d1bd6
c24d494
09fc1d8
66aaeb7
5d6afaf
984acf3
5c56bd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> = ["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) | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. isDateTime accepts 1 to 9 fractional digits and a space separator for every engine. SQL Server |
||
| // `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>) -> Int? { | ||
| let slice = characters[range] | ||
| guard slice.allSatisfy(\.isASCIIDigit) else { return nil } | ||
| return Int(String(slice)) | ||
| } | ||
| } | ||
|
|
||
| private extension Character { | ||
| var isASCIIDigit: Bool { | ||
| isASCII && isNumber | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oracleTemporalLiteral turns every ANSI-shaped value on an Oracle date column into DATE '...', so
= 2024-01-05matches only midnight rows. This also changes the existing filter bar, not only search.