Skip to content

Commit c81bf23

Browse files
datlechinNgo Quoc Dat
andauthored
feat(datagrid): add case sensitivity to data grid filters (#2052)
* feat(datagrid): add case sensitivity to data grid filters * fix(plugins): keep case sensitivity separate from what a backend supports --------- Co-authored-by: Ngo Quoc Dat <lehuuthang1702@gmail.com>
1 parent dcf80e9 commit c81bf23

68 files changed

Lines changed: 2231 additions & 386 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- 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)
13+
- 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)
14+
- MongoDB, Elasticsearch, DynamoDB, and etcd filters can now match case. They always ignored it before, with no way to turn that off. (#2048)
1215
- Oracle connections can now sign in as SYSDBA or SYSOPER, on both Mac and mobile. Administrative logons previously had no way to connect. (#2039)
1316
- 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)
1417
- 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)

Packages/TableProCore/Sources/TableProModels/TableFilter.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public struct TableFilter: Identifiable, Codable, Sendable {
88
public var secondValue: String
99
public var isEnabled: Bool
1010
public var rawSQL: String?
11+
public var isCaseSensitive: Bool
1112

1213
public static let rawSQLColumn = "__raw_sql__"
1314

@@ -37,7 +38,8 @@ public struct TableFilter: Identifiable, Codable, Sendable {
3738
value: String = "",
3839
secondValue: String = "",
3940
isEnabled: Bool = true,
40-
rawSQL: String? = nil
41+
rawSQL: String? = nil,
42+
isCaseSensitive: Bool? = nil
4143
) {
4244
self.id = id
4345
self.columnName = columnName
@@ -46,6 +48,25 @@ public struct TableFilter: Identifiable, Codable, Sendable {
4648
self.secondValue = secondValue
4749
self.isEnabled = isEnabled
4850
self.rawSQL = rawSQL
51+
self.isCaseSensitive = isCaseSensitive ?? filterOperator.defaultIsCaseSensitive
52+
}
53+
54+
public enum CodingKeys: String, CodingKey {
55+
case id, columnName, filterOperator, value, secondValue, isEnabled, rawSQL, isCaseSensitive
56+
}
57+
58+
public init(from decoder: Decoder) throws {
59+
let container = try decoder.container(keyedBy: CodingKeys.self)
60+
let decodedOperator = try container.decodeIfPresent(FilterOperator.self, forKey: .filterOperator) ?? .equal
61+
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
62+
self.columnName = try container.decodeIfPresent(String.self, forKey: .columnName) ?? ""
63+
self.filterOperator = decodedOperator
64+
self.value = try container.decodeIfPresent(String.self, forKey: .value) ?? ""
65+
self.secondValue = try container.decodeIfPresent(String.self, forKey: .secondValue) ?? ""
66+
self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
67+
self.rawSQL = try container.decodeIfPresent(String.self, forKey: .rawSQL)
68+
self.isCaseSensitive = try container.decodeIfPresent(Bool.self, forKey: .isCaseSensitive)
69+
?? decodedOperator.defaultIsCaseSensitive
4970
}
5071
}
5172

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

91+
public var supportsCaseSensitivity: Bool {
92+
switch self {
93+
case .like, .notLike, .contains, .startsWith, .endsWith, .equal, .notEqual, .in, .notIn:
94+
return true
95+
default:
96+
return false
97+
}
98+
}
99+
100+
/// Pattern matching ignores case by default; exact and list matching does not
101+
public var defaultIsCaseSensitive: Bool {
102+
switch self {
103+
case .like, .notLike, .contains, .startsWith, .endsWith:
104+
return false
105+
default:
106+
return true
107+
}
108+
}
109+
70110
public var sqlSymbol: String {
71111
switch self {
72112
case .equal: return "="

Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ public struct FilterSQLGenerator: Sendable {
3131

3232
let quotedColumn = quoteIdentifier(filter.columnName)
3333
let escapedValue = escapeValue(filter.value)
34+
let folding = caseFolding(for: filter)
3435

3536
switch filter.filterOperator {
3637
case .equal:
37-
return "\(quotedColumn) = \(escapedValue)"
38+
return comparisonCondition(quotedColumn, "=", escapedValue, folding: folding)
3839
case .notEqual:
39-
return "\(quotedColumn) != \(escapedValue)"
40+
return comparisonCondition(quotedColumn, "!=", escapedValue, folding: folding)
4041
case .greaterThan:
4142
return "\(quotedColumn) > \(escapedValue)"
4243
case .greaterThanOrEqual:
@@ -46,34 +47,68 @@ public struct FilterSQLGenerator: Sendable {
4647
case .lessThanOrEqual:
4748
return "\(quotedColumn) <= \(escapedValue)"
4849
case .like:
49-
return "\(quotedColumn) LIKE \(escapedValue)\(likeEscape)"
50+
return likeCondition(quotedColumn, escapedValue, negated: false, folding: folding)
5051
case .notLike:
51-
return "\(quotedColumn) NOT LIKE \(escapedValue)\(likeEscape)"
52+
return likeCondition(quotedColumn, escapedValue, negated: true, folding: folding)
5253
case .isNull:
5354
return "\(quotedColumn) IS NULL"
5455
case .isNotNull:
5556
return "\(quotedColumn) IS NOT NULL"
5657
case .in:
57-
let values = parseInValues(filter.value)
58-
return "\(quotedColumn) IN (\(values))"
58+
return listCondition(quotedColumn, filter.value, negated: false, folding: folding)
5959
case .notIn:
60-
let values = parseInValues(filter.value)
61-
return "\(quotedColumn) NOT IN (\(values))"
60+
return listCondition(quotedColumn, filter.value, negated: true, folding: folding)
6261
case .between:
6362
let escapedSecond = escapeValue(filter.secondValue)
6463
return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)"
6564
case .contains:
6665
let pattern = escapeLikePattern(filter.value)
67-
return "\(quotedColumn) LIKE '%\(pattern)%'\(likeEscape)"
66+
return likeCondition(quotedColumn, "'%\(pattern)%'", negated: false, folding: folding)
6867
case .startsWith:
6968
let pattern = escapeLikePattern(filter.value)
70-
return "\(quotedColumn) LIKE '\(pattern)%'\(likeEscape)"
69+
return likeCondition(quotedColumn, "'\(pattern)%'", negated: false, folding: folding)
7170
case .endsWith:
7271
let pattern = escapeLikePattern(filter.value)
73-
return "\(quotedColumn) LIKE '%\(pattern)'\(likeEscape)"
72+
return likeCondition(quotedColumn, "'%\(pattern)'", negated: false, folding: folding)
7473
}
7574
}
7675

76+
private func caseFolding(for filter: TableFilter) -> PluginSQLCaseFolding {
77+
PluginSQLCaseFolding.resolve(
78+
style: dialect.caseSensitivityStyle,
79+
foldFunction: dialect.caseFoldFunction,
80+
isCaseSensitive: filter.isCaseSensitive || !filter.filterOperator.supportsCaseSensitivity
81+
)
82+
}
83+
84+
private func comparisonCondition(
85+
_ column: String, _ operatorText: String, _ literal: String, folding: PluginSQLCaseFolding
86+
) -> String {
87+
guard folding.foldsComparisonOperands, literal.hasPrefix("'") else {
88+
return "\(column) \(operatorText) \(literal)"
89+
}
90+
return "\(folding.fold(column)) \(operatorText) \(folding.fold(literal))"
91+
}
92+
93+
private func likeCondition(
94+
_ column: String, _ pattern: String, negated: Bool, folding: PluginSQLCaseFolding
95+
) -> String {
96+
let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword
97+
return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscape)"
98+
}
99+
100+
private func listCondition(
101+
_ column: String, _ rawValue: String, negated: Bool, folding: PluginSQLCaseFolding
102+
) -> String {
103+
let keyword = negated ? "NOT IN" : "IN"
104+
let items = rawValue.split(separator: ",")
105+
.map { escapeValue($0.trimmingCharacters(in: .whitespaces)) }
106+
let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") }
107+
let rendered = foldable ? items.map { folding.fold($0) } : items
108+
let subject = foldable ? folding.fold(column) : column
109+
return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))"
110+
}
111+
77112
private var likeEscape: String {
78113
switch dialect.likeEscapeStyle {
79114
case .explicit:
@@ -119,12 +154,4 @@ public struct FilterSQLGenerator: Sendable {
119154
}
120155
return result
121156
}
122-
123-
private func parseInValues(_ value: String) -> String {
124-
let parts = value.components(separatedBy: ",")
125-
return parts.map { part in
126-
let trimmed = part.trimmingCharacters(in: .whitespaces)
127-
return escapeValue(trimmed)
128-
}.joined(separator: ", ")
129-
}
130157
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Foundation
2+
import TableProModels
3+
import TableProPluginKit
4+
import Testing
5+
@testable import TableProQuery
6+
7+
@Suite("Mobile Filter Case Sensitivity")
8+
struct MobileFilterSQLGeneratorCaseSensitivityTests {
9+
10+
private static let postgresql = SQLDialectDescriptor(
11+
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
12+
likeEscapeStyle: .explicit, caseSensitivityStyle: .ilikeOperator
13+
)
14+
15+
private static let oracle = SQLDialectDescriptor(
16+
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
17+
likeEscapeStyle: .explicit, caseSensitivityStyle: .caseFoldFunction
18+
)
19+
20+
private static let mysql = SQLDialectDescriptor(
21+
identifierQuote: "`", keywords: [], functions: [], dataTypes: [],
22+
likeEscapeStyle: .implicit, caseSensitivityStyle: .collationDefined
23+
)
24+
25+
private func clause(
26+
_ dialect: SQLDialectDescriptor,
27+
_ filterOperator: FilterOperator,
28+
value: String = "smith",
29+
isCaseSensitive: Bool? = nil
30+
) -> String {
31+
FilterSQLGenerator(dialect: dialect).generateWhereClause(
32+
from: [TableFilter(
33+
columnName: "name",
34+
filterOperator: filterOperator,
35+
value: value,
36+
isCaseSensitive: isCaseSensitive
37+
)],
38+
logicMode: .and
39+
)
40+
}
41+
42+
@Test("Pattern operators ignore case by default")
43+
func testPatternDefaults() {
44+
#expect(TableFilter(filterOperator: .contains).isCaseSensitive == false)
45+
#expect(TableFilter(filterOperator: .like).isCaseSensitive == false)
46+
#expect(TableFilter(filterOperator: .equal).isCaseSensitive)
47+
}
48+
49+
@Test("PostgreSQL contains ignoring case uses ILIKE")
50+
func testPostgresContains() {
51+
#expect(clause(Self.postgresql, .contains) == "WHERE \"name\" ILIKE '%smith%' ESCAPE '!'")
52+
}
53+
54+
@Test("PostgreSQL contains matching case keeps LIKE")
55+
func testPostgresContainsMatchingCase() {
56+
#expect(clause(Self.postgresql, .contains, isCaseSensitive: true) == "WHERE \"name\" LIKE '%smith%' ESCAPE '!'")
57+
}
58+
59+
@Test("Oracle contains ignoring case folds both sides")
60+
func testOracleContains() {
61+
#expect(clause(Self.oracle, .contains) == "WHERE LOWER(\"name\") LIKE LOWER('%smith%') ESCAPE '!'")
62+
}
63+
64+
@Test("MySQL emits the same SQL whichever way the row is set")
65+
func testMySQLUnchanged() {
66+
#expect(clause(Self.mysql, .contains) == clause(Self.mysql, .contains, isCaseSensitive: true))
67+
}
68+
69+
@Test("Equals ignoring case folds both sides")
70+
func testEqualsIgnoringCase() {
71+
#expect(clause(Self.postgresql, .equal, isCaseSensitive: false) == "WHERE LOWER(\"name\") = LOWER('smith')")
72+
}
73+
74+
@Test("Equals matching case stays untouched")
75+
func testEqualsMatchingCase() {
76+
#expect(clause(Self.postgresql, .equal) == "WHERE \"name\" = 'smith'")
77+
}
78+
79+
@Test("IN ignoring case folds the column and every value")
80+
func testInListIgnoringCase() {
81+
let sql = clause(Self.postgresql, .in, value: "a,b", isCaseSensitive: false)
82+
#expect(sql == "WHERE LOWER(\"name\") IN (LOWER('a'), LOWER('b'))")
83+
}
84+
85+
@Test("A saved filter with no case key decodes to the operator default")
86+
func testLegacyDecode() throws {
87+
let json = """
88+
{"id":"8B9E4F1C-2A3D-4B5E-9F60-1234567890AB","columnName":"name",
89+
"filterOperator":"contains","value":"smith","secondValue":"","isEnabled":true}
90+
"""
91+
let filter = try JSONDecoder().decode(TableFilter.self, from: Data(json.utf8))
92+
#expect(filter.isCaseSensitive == false)
93+
}
94+
}

Plugins/BeancountDriverPlugin/BeancountPlugin.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin {
6464
regexSyntax: .unsupported,
6565
booleanLiteralStyle: .numeric,
6666
likeEscapeStyle: .explicit,
67-
paginationStyle: .limit
67+
paginationStyle: .limit,
68+
caseSensitivityStyle: .collationDefined
6869
)
6970

7071
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {

Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,9 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin {
159159
],
160160
regexSyntax: .unsupported,
161161
booleanLiteralStyle: .truefalse,
162-
likeEscapeStyle: .explicit,
163-
paginationStyle: .limit
162+
likeEscapeStyle: .implicit,
163+
paginationStyle: .limit,
164+
caseSensitivityStyle: .caseFoldFunction
164165
)
165166

166167
static let explainVariants: [ExplainVariant] = [

Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,25 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
563563
limit: Int,
564564
offset: Int,
565565
columnKinds: [String: PluginColumnKind]
566+
) -> String? {
567+
buildFilteredQuery(
568+
table: table, schema: schema,
569+
queryFilters: filters.map { PluginQueryFilter(column: $0.column, op: $0.op, value: $0.value) },
570+
logicMode: logicMode, sortColumns: sortColumns, columns: columns,
571+
limit: limit, offset: offset, columnKinds: columnKinds
572+
)
573+
}
574+
575+
func buildFilteredQuery(
576+
table: String,
577+
schema: String?,
578+
queryFilters: [PluginQueryFilter],
579+
logicMode: String,
580+
sortColumns: [(columnIndex: Int, ascending: Bool)],
581+
columns: [String],
582+
limit: Int,
583+
offset: Int,
584+
columnKinds: [String: PluginColumnKind]
566585
) -> String? {
567586
let dataset: String = lock.withLock {
568587
let ds = schema ?? _currentDataset ?? ""
@@ -571,7 +590,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
571590
}
572591
return BigQueryQueryBuilder.encodeFilteredQuery(
573592
table: table, dataset: dataset,
574-
filters: filters, logicMode: logicMode,
593+
filters: queryFilters, logicMode: logicMode,
575594
sortColumns: sortColumns, limit: limit, offset: offset, columnKinds: columnKinds
576595
)
577596
}

0 commit comments

Comments
 (0)