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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Filter rows now have a Match Case option in the operator menu. Contains, not contains, starts with, and ends with ignore case on every database that can express it, so PostgreSQL and DuckDB now behave like MySQL and SQLite already did. Equals, IN, and regex still match case until you say otherwise. (#2048)
- Databases whose collation decides case sensitivity, such as MySQL and SQL Server, show the option greyed out with the reason, as do Cassandra and Redis, which cannot ignore case at all. (#2048)
- MongoDB, Elasticsearch, DynamoDB, and etcd filters can now match case. They always ignored it before, with no way to turn that off. (#2048)
- Oracle connections can now sign in as SYSDBA or SYSOPER, on both Mac and mobile. Administrative logons previously had no way to connect. (#2039)
- Oracle now works in TablePro Mobile: connect, browse schemas and tables, run queries, and edit rows, with a Service Name or SID picker and the same SSL modes as the Mac app. (#2033)
- When an Oracle listener rejects the connect identifier, the connection form now names which one was wrong and offers to switch between Service Name and SID in one tap. (#2033)
Expand Down
42 changes: 41 additions & 1 deletion Packages/TableProCore/Sources/TableProModels/TableFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public struct TableFilter: Identifiable, Codable, Sendable {
public var secondValue: String
public var isEnabled: Bool
public var rawSQL: String?
public var isCaseSensitive: Bool

public static let rawSQLColumn = "__raw_sql__"

Expand Down Expand Up @@ -37,7 +38,8 @@ public struct TableFilter: Identifiable, Codable, Sendable {
value: String = "",
secondValue: String = "",
isEnabled: Bool = true,
rawSQL: String? = nil
rawSQL: String? = nil,
isCaseSensitive: Bool? = nil
) {
self.id = id
self.columnName = columnName
Expand All @@ -46,6 +48,25 @@ public struct TableFilter: Identifiable, Codable, Sendable {
self.secondValue = secondValue
self.isEnabled = isEnabled
self.rawSQL = rawSQL
self.isCaseSensitive = isCaseSensitive ?? filterOperator.defaultIsCaseSensitive
}

public enum CodingKeys: String, CodingKey {
case id, columnName, filterOperator, value, secondValue, isEnabled, rawSQL, isCaseSensitive
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedOperator = try container.decodeIfPresent(FilterOperator.self, forKey: .filterOperator) ?? .equal
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.columnName = try container.decodeIfPresent(String.self, forKey: .columnName) ?? ""
self.filterOperator = decodedOperator
self.value = try container.decodeIfPresent(String.self, forKey: .value) ?? ""
self.secondValue = try container.decodeIfPresent(String.self, forKey: .secondValue) ?? ""
self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
self.rawSQL = try container.decodeIfPresent(String.self, forKey: .rawSQL)
self.isCaseSensitive = try container.decodeIfPresent(Bool.self, forKey: .isCaseSensitive)
?? decodedOperator.defaultIsCaseSensitive
}
}

Expand All @@ -67,6 +88,25 @@ public enum FilterOperator: String, Codable, Sendable, CaseIterable {
case startsWith
case endsWith

public var supportsCaseSensitivity: Bool {
switch self {
case .like, .notLike, .contains, .startsWith, .endsWith, .equal, .notEqual, .in, .notIn:
return true
default:
return false
}
}

/// Pattern matching ignores case by default; exact and list matching does not
public var defaultIsCaseSensitive: Bool {
switch self {
case .like, .notLike, .contains, .startsWith, .endsWith:
return false
default:
return true
}
}

public var sqlSymbol: String {
switch self {
case .equal: return "="
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ public struct FilterSQLGenerator: Sendable {

let quotedColumn = quoteIdentifier(filter.columnName)
let escapedValue = escapeValue(filter.value)
let folding = caseFolding(for: filter)

switch filter.filterOperator {
case .equal:
return "\(quotedColumn) = \(escapedValue)"
return comparisonCondition(quotedColumn, "=", escapedValue, folding: folding)
case .notEqual:
return "\(quotedColumn) != \(escapedValue)"
return comparisonCondition(quotedColumn, "!=", escapedValue, folding: folding)
case .greaterThan:
return "\(quotedColumn) > \(escapedValue)"
case .greaterThanOrEqual:
Expand All @@ -46,34 +47,68 @@ public struct FilterSQLGenerator: Sendable {
case .lessThanOrEqual:
return "\(quotedColumn) <= \(escapedValue)"
case .like:
return "\(quotedColumn) LIKE \(escapedValue)\(likeEscape)"
return likeCondition(quotedColumn, escapedValue, negated: false, folding: folding)
case .notLike:
return "\(quotedColumn) NOT LIKE \(escapedValue)\(likeEscape)"
return likeCondition(quotedColumn, escapedValue, negated: true, folding: folding)
case .isNull:
return "\(quotedColumn) IS NULL"
case .isNotNull:
return "\(quotedColumn) IS NOT NULL"
case .in:
let values = parseInValues(filter.value)
return "\(quotedColumn) IN (\(values))"
return listCondition(quotedColumn, filter.value, negated: false, folding: folding)
case .notIn:
let values = parseInValues(filter.value)
return "\(quotedColumn) NOT IN (\(values))"
return listCondition(quotedColumn, filter.value, negated: true, folding: folding)
case .between:
let escapedSecond = escapeValue(filter.secondValue)
return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)"
case .contains:
let pattern = escapeLikePattern(filter.value)
return "\(quotedColumn) LIKE '%\(pattern)%'\(likeEscape)"
return likeCondition(quotedColumn, "'%\(pattern)%'", negated: false, folding: folding)
case .startsWith:
let pattern = escapeLikePattern(filter.value)
return "\(quotedColumn) LIKE '\(pattern)%'\(likeEscape)"
return likeCondition(quotedColumn, "'\(pattern)%'", negated: false, folding: folding)
case .endsWith:
let pattern = escapeLikePattern(filter.value)
return "\(quotedColumn) LIKE '%\(pattern)'\(likeEscape)"
return likeCondition(quotedColumn, "'%\(pattern)'", negated: false, folding: folding)
}
}

private func caseFolding(for filter: TableFilter) -> PluginSQLCaseFolding {
PluginSQLCaseFolding.resolve(
style: dialect.caseSensitivityStyle,
foldFunction: dialect.caseFoldFunction,
isCaseSensitive: filter.isCaseSensitive || !filter.filterOperator.supportsCaseSensitivity
)
}

private func comparisonCondition(
_ column: String, _ operatorText: String, _ literal: String, folding: PluginSQLCaseFolding
) -> String {
guard folding.foldsComparisonOperands, literal.hasPrefix("'") else {
return "\(column) \(operatorText) \(literal)"
}
return "\(folding.fold(column)) \(operatorText) \(folding.fold(literal))"
}

private func likeCondition(
_ column: String, _ pattern: String, negated: Bool, folding: PluginSQLCaseFolding
) -> String {
let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword
return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscape)"
}

private func listCondition(
_ column: String, _ rawValue: String, negated: Bool, folding: PluginSQLCaseFolding
) -> String {
let keyword = negated ? "NOT IN" : "IN"
let items = rawValue.split(separator: ",")
.map { escapeValue($0.trimmingCharacters(in: .whitespaces)) }
let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") }
let rendered = foldable ? items.map { folding.fold($0) } : items
let subject = foldable ? folding.fold(column) : column
return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))"
}

private var likeEscape: String {
switch dialect.likeEscapeStyle {
case .explicit:
Expand Down Expand Up @@ -119,12 +154,4 @@ public struct FilterSQLGenerator: Sendable {
}
return result
}

private func parseInValues(_ value: String) -> String {
let parts = value.components(separatedBy: ",")
return parts.map { part in
let trimmed = part.trimmingCharacters(in: .whitespaces)
return escapeValue(trimmed)
}.joined(separator: ", ")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation
import TableProModels
import TableProPluginKit
import Testing
@testable import TableProQuery

@Suite("Mobile Filter Case Sensitivity")
struct MobileFilterSQLGeneratorCaseSensitivityTests {

private static let postgresql = SQLDialectDescriptor(
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
likeEscapeStyle: .explicit, caseSensitivityStyle: .ilikeOperator
)

private static let oracle = SQLDialectDescriptor(
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
likeEscapeStyle: .explicit, caseSensitivityStyle: .caseFoldFunction
)

private static let mysql = SQLDialectDescriptor(
identifierQuote: "`", keywords: [], functions: [], dataTypes: [],
likeEscapeStyle: .implicit, caseSensitivityStyle: .collationDefined
)

private func clause(
_ dialect: SQLDialectDescriptor,
_ filterOperator: FilterOperator,
value: String = "smith",
isCaseSensitive: Bool? = nil
) -> String {
FilterSQLGenerator(dialect: dialect).generateWhereClause(
from: [TableFilter(
columnName: "name",
filterOperator: filterOperator,
value: value,
isCaseSensitive: isCaseSensitive
)],
logicMode: .and
)
}

@Test("Pattern operators ignore case by default")
func testPatternDefaults() {
#expect(TableFilter(filterOperator: .contains).isCaseSensitive == false)
#expect(TableFilter(filterOperator: .like).isCaseSensitive == false)
#expect(TableFilter(filterOperator: .equal).isCaseSensitive)
}

@Test("PostgreSQL contains ignoring case uses ILIKE")
func testPostgresContains() {
#expect(clause(Self.postgresql, .contains) == "WHERE \"name\" ILIKE '%smith%' ESCAPE '!'")
}

@Test("PostgreSQL contains matching case keeps LIKE")
func testPostgresContainsMatchingCase() {
#expect(clause(Self.postgresql, .contains, isCaseSensitive: true) == "WHERE \"name\" LIKE '%smith%' ESCAPE '!'")
}

@Test("Oracle contains ignoring case folds both sides")
func testOracleContains() {
#expect(clause(Self.oracle, .contains) == "WHERE LOWER(\"name\") LIKE LOWER('%smith%') ESCAPE '!'")
}

@Test("MySQL emits the same SQL whichever way the row is set")
func testMySQLUnchanged() {
#expect(clause(Self.mysql, .contains) == clause(Self.mysql, .contains, isCaseSensitive: true))
}

@Test("Equals ignoring case folds both sides")
func testEqualsIgnoringCase() {
#expect(clause(Self.postgresql, .equal, isCaseSensitive: false) == "WHERE LOWER(\"name\") = LOWER('smith')")
}

@Test("Equals matching case stays untouched")
func testEqualsMatchingCase() {
#expect(clause(Self.postgresql, .equal) == "WHERE \"name\" = 'smith'")
}

@Test("IN ignoring case folds the column and every value")
func testInListIgnoringCase() {
let sql = clause(Self.postgresql, .in, value: "a,b", isCaseSensitive: false)
#expect(sql == "WHERE LOWER(\"name\") IN (LOWER('a'), LOWER('b'))")
}

@Test("A saved filter with no case key decodes to the operator default")
func testLegacyDecode() throws {
let json = """
{"id":"8B9E4F1C-2A3D-4B5E-9F60-1234567890AB","columnName":"name",
"filterOperator":"contains","value":"smith","secondValue":"","isEnabled":true}
"""
let filter = try JSONDecoder().decode(TableFilter.self, from: Data(json.utf8))
#expect(filter.isCaseSensitive == false)
}
}
3 changes: 2 additions & 1 deletion Plugins/BeancountDriverPlugin/BeancountPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin {
regexSyntax: .unsupported,
booleanLiteralStyle: .numeric,
likeEscapeStyle: .explicit,
paginationStyle: .limit
paginationStyle: .limit,
caseSensitivityStyle: .collationDefined
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
Expand Down
5 changes: 3 additions & 2 deletions Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin {
],
regexSyntax: .unsupported,
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit
likeEscapeStyle: .implicit,
paginationStyle: .limit,
caseSensitivityStyle: .caseFoldFunction
)

static let explainVariants: [ExplainVariant] = [
Expand Down
21 changes: 20 additions & 1 deletion Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,25 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
buildFilteredQuery(
table: table, schema: schema,
queryFilters: filters.map { PluginQueryFilter(column: $0.column, op: $0.op, value: $0.value) },
logicMode: logicMode, sortColumns: sortColumns, columns: columns,
limit: limit, offset: offset, columnKinds: columnKinds
)
}

func buildFilteredQuery(
table: String,
schema: String?,
queryFilters: [PluginQueryFilter],
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
columns: [String],
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
let dataset: String = lock.withLock {
let ds = schema ?? _currentDataset ?? ""
Expand All @@ -571,7 +590,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
}
return BigQueryQueryBuilder.encodeFilteredQuery(
table: table, dataset: dataset,
filters: filters, logicMode: logicMode,
filters: queryFilters, logicMode: logicMode,
sortColumns: sortColumns, limit: limit, offset: offset, columnKinds: columnKinds
)
}
Expand Down
Loading
Loading