Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions apple/TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)')" }
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

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-05 matches only midnight rows. This also changes the existing filter bar, not only search.

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 }
Expand Down Expand Up @@ -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
}
}

Expand Down
188 changes: 188 additions & 0 deletions apple/TablePro/Core/DatabaseSearch/DatabaseSearchColumnPlanner.swift
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 datetime rejects more than 3 fractional digits and reads the space form by session language. Searching 2024-01-05 10:00:00.1234567 would fail the whole table query, so no column of that table gets searched.

// `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
}
}
91 changes: 91 additions & 0 deletions apple/TablePro/Core/DatabaseSearch/DatabaseSearchOptions.swift
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)
)
}
}
Loading
Loading