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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Remove Invisible Characters** in the Query menu. (#2717)
- **Show invisible characters** in Settings > Editor. (#2717)
- Warnings in the SQL editor for full-width punctuation, curly quotes and non-ASCII spaces. (#2717)
- Highlight rules that color data grid rows or cells by value. (#2723)

### Changed

Expand All @@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Blank welcome window list when a search matched nothing and a favorite existed.
- Welcome window reading No Connections while a tag filter hid every connection.
- Favorited connection inside a group listed twice on the welcome window.
- Missing red wash on a row deleted together with a new, unsaved row.
- Welcome window tag filter stuck on a tag no connection carries any more, hiding every connection.
- Collapsing every group on the welcome window undone at the next launch.
- Linked Folders and Team Library connections ignoring the welcome window search, with no context menu.
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/DataGrid/DataGridDisplayState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ final class DataGridDisplayState {
/// reports a schema and a format change and clears the text it was just handed.
var identitySchema: ColumnIdentitySchema?
var displayFormats: [ValueDisplayFormat?]?
var highlightRuleSetKey: HighlightRuleSet.Key?
}
22 changes: 22 additions & 0 deletions TablePro/Core/DataGrid/RowDisplayBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class RowDisplayCache {
}

private var storage: [RowID: Entry] = [:]
private var highlights: [RowID: RowHighlight] = [:]
private var insertionOrder: [RowID] = []
private var insertionHead: Int = 0
private var totalCost: Int = 0
Expand Down Expand Up @@ -52,8 +53,28 @@ final class RowDisplayCache {
evictIfNeeded()
}

func highlight(forID id: RowID) -> RowHighlight? {
highlights[id]
}

func setHighlight(_ highlight: RowHighlight, forID id: RowID) {
if highlights.count >= countLimit {
highlights.removeAll(keepingCapacity: true)
}
highlights[id] = highlight
}

func clearHighlight(forID id: RowID) {
highlights.removeValue(forKey: id)
}

func clearHighlights() {
highlights.removeAll(keepingCapacity: true)
}

func removeAll() {
storage.removeAll(keepingCapacity: true)
highlights.removeAll(keepingCapacity: true)
insertionOrder.removeAll(keepingCapacity: true)
insertionHead = 0
totalCost = 0
Expand All @@ -64,6 +85,7 @@ final class RowDisplayCache {
/// whose content changed in place keeps its id and would otherwise be served
/// its pre-edit text.
func clearValues(forID id: RowID) {
highlights.removeValue(forKey: id)
guard let existing = storage[id] else { return }
totalCost -= existing.cost
for index in existing.box.values.indices {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Menu/ViewMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ enum ViewMenuBuilder {
shortcut: .toggleFilters,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Highlight Rules…"),
action: #selector(MainSplitViewController.showHighlightRules(_:))
),
MenuItemFactory.item(
String(localized: "Show Query History"),
action: #selector(MainSplitViewController.toggleQueryHistory(_:)),
Expand Down
275 changes: 275 additions & 0 deletions TablePro/Core/Services/Highlight/HighlightCondition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
//
// HighlightCondition.swift
// TablePro
//

import Foundation
import TableProPluginKit

struct HighlightCondition {
static let searchLimit = 10_000

private enum ValueKind {
case numeric
case boolean
case text
}

private struct Operand {
let text: String
let number: Decimal?
let boolean: Bool?
let isNullLiteral: Bool

init(_ raw: String, allowsNullLiteral: Bool) {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
text = raw
number = HighlightCondition.number(from: trimmed)
boolean = HighlightCondition.boolean(from: trimmed)
isNullLiteral = allowsNullLiteral && HighlightCondition.isNullKeyword(trimmed)
}
}

private let filterOperator: FilterOperator
private let valueKind: ValueKind
private let comparesCaseInsensitively: Bool
private let supportsEmptyString: Bool
private let operand: Operand
private let secondOperand: Operand
private let listOperands: [Operand]
private let regex: NSRegularExpression?

init(rule: HighlightRule, columnType: ColumnType?) {
filterOperator = rule.filterOperator
valueKind = Self.valueKind(for: columnType)
comparesCaseInsensitively = rule.filterOperator.supportsCaseSensitivity && !rule.isCaseSensitive
supportsEmptyString = ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType)

let allowsNullLiteral = Self.allowsNullLiteral(for: columnType)
operand = Operand(rule.value, allowsNullLiteral: allowsNullLiteral)
secondOperand = Operand(rule.secondValue ?? "", allowsNullLiteral: allowsNullLiteral)
listOperands = rule.filterOperator == .inList || rule.filterOperator == .notInList
? Self.listItems(rule.value).map { Operand($0, allowsNullLiteral: allowsNullLiteral) }
: []
regex = rule.filterOperator == .regex
? Self.regularExpression(rule.value, ignoresCase: comparesCaseInsensitively)
: nil
}

func matches(_ value: PluginCellValue) -> Bool {
switch value {
case .null:
return matchesNull()
case .bytes:
return matchesBinary()
case .text(let text):
return matches(text: text)
}
}

private func matchesNull() -> Bool {
switch filterOperator {
case .isNull, .isEmpty:
return true
case .equal:
return operand.isNullLiteral
case .inList:
return listOperands.contains { $0.isNullLiteral }
case .notEqual, .contains, .notContains, .startsWith, .endsWith, .greaterThan, .greaterOrEqual,
.lessThan, .lessOrEqual, .isNotNull, .isNotEmpty, .notInList, .between, .regex:
return false
}
}

private func matchesBinary() -> Bool {
switch filterOperator {
case .isNotNull, .isNotEmpty:
return true
case .isNull, .isEmpty, .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith,
.greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .inList, .notInList, .between, .regex:
return false
}
}

private func matches(text: String) -> Bool {
switch filterOperator {
case .equal:
return !operand.isNullLiteral && order(text, against: operand) == .orderedSame
case .notEqual:
return operand.isNullLiteral || order(text, against: operand) != .orderedSame
case .contains:
return contains(text)
case .notContains:
return !contains(text)
case .startsWith:
return hasAffix(text, anchoredAtEnd: false)
case .endsWith:
return hasAffix(text, anchoredAtEnd: true)
case .greaterThan:
return !operand.isNullLiteral && order(text, against: operand) == .orderedDescending
case .greaterOrEqual:
return !operand.isNullLiteral && order(text, against: operand) != .orderedAscending
case .lessThan:
return !operand.isNullLiteral && order(text, against: operand) == .orderedAscending
case .lessOrEqual:
return !operand.isNullLiteral && order(text, against: operand) != .orderedDescending
case .isNull:
return false
case .isNotNull:
return true
case .isEmpty:
return supportsEmptyString && text.isEmpty
case .isNotEmpty:
return !supportsEmptyString || !text.isEmpty
case .inList:
return listOperands.contains { !$0.isNullLiteral && order(text, against: $0) == .orderedSame }
case .notInList:
let values = listOperands.filter { !$0.isNullLiteral }
return !values.isEmpty && !values.contains { order(text, against: $0) == .orderedSame }
case .between:
return order(text, against: operand) != .orderedAscending
&& order(text, against: secondOperand) != .orderedDescending
case .regex:
return matchesRegex(text)
}
}

private func order(_ text: String, against operand: Operand) -> ComparisonResult {
if prefersNumbers, let lhs = Self.number(from: text), let rhs = operand.number {
return Self.compare(lhs, rhs)
}
if prefersBooleans, let lhs = Self.boolean(from: text), let rhs = operand.boolean {
return Self.compare(lhs ? 1 : 0, rhs ? 1 : 0)
}
return text.compare(operand.text, options: comparesCaseInsensitively ? [.caseInsensitive] : [.literal])
}

private var prefersNumbers: Bool {
switch valueKind {
case .numeric:
return true
case .boolean:
return false
case .text:
return isOrderingOperator
}
}

private var prefersBooleans: Bool {
switch valueKind {
case .numeric, .boolean:
return true
case .text:
return false
}
}

private var isOrderingOperator: Bool {
switch filterOperator {
case .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .between:
return true
case .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull,
.isEmpty, .isNotEmpty, .inList, .notInList, .regex:
return false
}
}

private var searchOptions: String.CompareOptions {
comparesCaseInsensitively ? [.caseInsensitive] : [.literal]
}

private func contains(_ text: String) -> Bool {
guard !operand.text.isEmpty else { return true }
return Self.searchable(text).range(of: operand.text, options: searchOptions) != nil
}

private func hasAffix(_ text: String, anchoredAtEnd: Bool) -> Bool {
guard !operand.text.isEmpty else { return true }
let options = searchOptions.union(anchoredAtEnd ? [.anchored, .backwards] : [.anchored])
return text.range(of: operand.text, options: options) != nil
}

private func matchesRegex(_ text: String) -> Bool {
guard let regex else { return false }
let searchable = Self.searchable(text) as NSString
return regex.firstMatch(
in: searchable as String,
options: [],
range: NSRange(location: 0, length: searchable.length)
) != nil
}

private static func searchable(_ text: String) -> String {
let source = text as NSString
guard source.length > searchLimit else { return text }
let cut = source.rangeOfComposedCharacterSequence(at: searchLimit).location
return source.substring(to: cut)
}

private static func regularExpression(_ pattern: String, ignoresCase: Bool) -> NSRegularExpression? {
guard !pattern.isEmpty, (pattern as NSString).length <= searchLimit else { return nil }
return try? NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : [])
}

private static func valueKind(for columnType: ColumnType?) -> ValueKind {
switch columnType {
case .integer, .decimal:
return .numeric
case .boolean:
return .boolean
case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array, .none:
return .text
}
}

private static func listItems(_ input: String) -> [String] {
input.split(separator: ",", omittingEmptySubsequences: true).compactMap {
let trimmed = $0.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
}
}

static func readsAsNullLiteral(_ text: String, columnType: ColumnType?) -> Bool {
allowsNullLiteral(for: columnType) && isNullKeyword(text.trimmingCharacters(in: .whitespaces))
}

private static func allowsNullLiteral(for columnType: ColumnType?) -> Bool {
!ColumnTypeSQLQuoting.isKnownTextLike(columnType)
}

private static func isNullKeyword(_ text: String) -> Bool {
text.caseInsensitiveCompare("NULL") == .orderedSame
}

static func number(from text: String) -> Decimal? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard PluginNumericLiteral.isValid(trimmed) else { return nil }
return Decimal(string: trimmed, locale: Locale(identifier: "en_US_POSIX"))
}

static func boolean(from text: String) -> Bool? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
switch PluginSQLLiteral.booleanSynonym(for: trimmed) {
case .isTrue:
return true
case .isFalse:
return false
default:
break
}
switch trimmed.lowercased() {
case "t":
return true
case "f":
return false
default:
return nil
}
}

private static func compare<Value: Comparable>(_ lhs: Value, _ rhs: Value) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
return .orderedSame
}
}
Loading
Loading