Skip to content

Commit ea306b2

Browse files
authored
Merge pull request #345 from datlechin/fix/341-filtering
fix: generate WHERE clauses for SQL database filters and quick search
2 parents 91c96c6 + 5953067 commit ea306b2

6 files changed

Lines changed: 302 additions & 22 deletions

File tree

TablePro.xcodeproj/project.pbxproj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
3636
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
3737
5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; };
38+
5AEA8B302F6808270040461A /* EtcdDriverPlugin.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
3839
5AEA8B422F6808CA0040461A /* EtcdStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */; };
3940
5AEA8B432F6808CA0040461A /* EtcdPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3D2F6808CA0040461A /* EtcdPlugin.swift */; };
4041
5AEA8B442F6808CA0040461A /* EtcdCommandParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */; };
@@ -116,6 +117,13 @@
116117
remoteGlobalIDString = 5A86C000000000000;
117118
remoteInfo = SQLExport;
118119
};
120+
5AEA8B312F6808270040461A /* PBXContainerItemProxy */ = {
121+
isa = PBXContainerItemProxy;
122+
containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
123+
proxyType = 1;
124+
remoteGlobalIDString = 5AEA8B292F6808270040461A;
125+
remoteInfo = EtcdDriverPlugin;
126+
};
119127
5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = {
120128
isa = PBXContainerItemProxy;
121129
containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
@@ -145,6 +153,7 @@
145153
5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */,
146154
5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */,
147155
5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */,
156+
5AEA8B302F6808270040461A /* EtcdDriverPlugin.tableplugin in Copy Plug-Ins */,
148157
);
149158
name = "Copy Plug-Ins";
150159
runOnlyForDeploymentPostprocessing = 0;
@@ -651,6 +660,7 @@
651660
5A1091BE2EF17EDC0055EA7C = {
652661
isa = PBXGroup;
653662
children = (
663+
5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */,
654664
5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */,
655665
5A1091C92EF17EDC0055EA7C /* TablePro */,
656666
5A860000500000000 /* Plugins/TableProPluginKit */,
@@ -704,6 +714,7 @@
704714
name = Products;
705715
sourceTree = "<group>";
706716
};
717+
5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */ = {
707718
5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = {
708719
isa = PBXGroup;
709720
children = (
@@ -760,6 +771,7 @@
760771
5A86A000C00000000 /* PBXTargetDependency */,
761772
5A86B000C00000000 /* PBXTargetDependency */,
762773
5A86C000C00000000 /* PBXTargetDependency */,
774+
5AEA8B322F6808270040461A /* PBXTargetDependency */,
763775
);
764776
fileSystemSynchronizedGroups = (
765777
5A1091C92EF17EDC0055EA7C /* TablePro */,
@@ -1618,6 +1630,11 @@
16181630
target = 5A86C000000000000 /* SQLExport */;
16191631
targetProxy = 5A86C000B00000000 /* PBXContainerItemProxy */;
16201632
};
1633+
5AEA8B322F6808270040461A /* PBXTargetDependency */ = {
1634+
isa = PBXTargetDependency;
1635+
target = 5AEA8B292F6808270040461A /* EtcdDriverPlugin */;
1636+
targetProxy = 5AEA8B312F6808270040461A /* PBXContainerItemProxy */;
1637+
};
16211638
5ABCC5AC2F43856700EAF3FC /* PBXTargetDependency */ = {
16221639
isa = PBXTargetDependency;
16231640
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;

TablePro/Core/Database/FilterSQLGenerator.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ struct FilterSQLGenerator {
3838
return conditions.joined(separator: separator)
3939
}
4040

41+
/// Generate WHERE clause for quick search across multiple columns
42+
func generateQuickSearchWhereClause(searchText: String, columns: [String]) -> String {
43+
let conditions = generateQuickSearchConditions(searchText: searchText, columns: columns)
44+
guard !conditions.isEmpty else { return "" }
45+
return "WHERE (\(conditions))"
46+
}
47+
48+
/// Generate OR-joined LIKE conditions for quick search (without WHERE keyword)
49+
func generateQuickSearchConditions(searchText: String, columns: [String]) -> String {
50+
guard !searchText.isEmpty, !columns.isEmpty else { return "" }
51+
let escapedValue = escapeLikeWildcards(searchText)
52+
let pattern = "%\(escapedValue)%"
53+
let quotedPattern = escapeSQLQuote(pattern)
54+
let escape = likeEscapeClause
55+
// CAST to TEXT for databases like PostgreSQL where LIKE on non-text columns fails
56+
let needsCast = dialect.regexSyntax == .tilde
57+
let conditions = columns.map { column in
58+
let quoted = quoteIdentifierFn(column)
59+
let target = needsCast ? "CAST(\(quoted) AS TEXT)" : quoted
60+
return "\(target) LIKE '\(quotedPattern)'\(escape)"
61+
}
62+
return conditions.joined(separator: " OR ")
63+
}
64+
4165
/// Generate a single filter condition
4266
func generateCondition(from filter: TableFilter) -> String? {
4367
guard filter.isValid else { return nil }

TablePro/Core/Services/Query/TableQueryBuilder.swift

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ struct TableQueryBuilder {
1515

1616
private let databaseType: DatabaseType
1717
private var pluginDriver: (any PluginDatabaseDriver)?
18+
private let dialect: SQLDialectDescriptor?
1819
private let dialectQuote: (String) -> String
1920

2021
// MARK: - Initialization
2122

2223
init(
2324
databaseType: DatabaseType,
2425
pluginDriver: (any PluginDatabaseDriver)? = nil,
26+
dialect: SQLDialectDescriptor? = nil,
2527
dialectQuote: ((String) -> String)? = nil
2628
) {
2729
self.databaseType = databaseType
2830
self.pluginDriver = pluginDriver
31+
self.dialect = dialect
2932
self.dialectQuote = dialectQuote ?? { name in
3033
let escaped = name.replacingOccurrences(of: "\"", with: "\"\"")
3134
return "\"\(escaped)\""
@@ -69,7 +72,7 @@ struct TableQueryBuilder {
6972
query += " \(orderBy)"
7073
}
7174

72-
query += " LIMIT \(limit) OFFSET \(offset)"
75+
query += " \(buildPaginationClause(limit: limit, offset: offset))"
7376
return query
7477
}
7578

@@ -97,7 +100,23 @@ struct TableQueryBuilder {
97100
}
98101

99102
let quotedTable = quote(tableName)
100-
return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)"
103+
var query = "SELECT * FROM \(quotedTable)"
104+
105+
if let dialect {
106+
let activeFilters = filters.filter { $0.isEnabled }
107+
let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote)
108+
let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode)
109+
if !whereClause.isEmpty {
110+
query += " \(whereClause)"
111+
}
112+
}
113+
114+
if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) {
115+
query += " \(orderBy)"
116+
}
117+
118+
query += " \(buildPaginationClause(limit: limit, offset: offset))"
119+
return query
101120
}
102121

103122
func buildQuickSearchQuery(
@@ -119,7 +138,22 @@ struct TableQueryBuilder {
119138
}
120139

121140
let quotedTable = quote(tableName)
122-
return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)"
141+
var query = "SELECT * FROM \(quotedTable)"
142+
143+
if let dialect {
144+
let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote)
145+
let searchWhere = filterGen.generateQuickSearchWhereClause(searchText: searchText, columns: columns)
146+
if !searchWhere.isEmpty {
147+
query += " \(searchWhere)"
148+
}
149+
}
150+
151+
if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) {
152+
query += " \(orderBy)"
153+
}
154+
155+
query += " \(buildPaginationClause(limit: limit, offset: offset))"
156+
return query
123157
}
124158

125159
func buildCombinedQuery(
@@ -149,7 +183,34 @@ struct TableQueryBuilder {
149183
}
150184

151185
let quotedTable = quote(tableName)
152-
return "SELECT * FROM \(quotedTable) LIMIT \(limit) OFFSET \(offset)"
186+
var query = "SELECT * FROM \(quotedTable)"
187+
188+
if let dialect {
189+
let activeFilters = filters.filter { $0.isEnabled }
190+
let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote)
191+
var whereParts: [String] = []
192+
193+
let filterConditions = filterGen.generateConditions(from: activeFilters, logicMode: logicMode)
194+
if !filterConditions.isEmpty {
195+
whereParts.append("(\(filterConditions))")
196+
}
197+
198+
let searchConditions = filterGen.generateQuickSearchConditions(searchText: searchText, columns: searchColumns)
199+
if !searchConditions.isEmpty {
200+
whereParts.append("(\(searchConditions))")
201+
}
202+
203+
if !whereParts.isEmpty {
204+
query += " WHERE \(whereParts.joined(separator: " AND "))"
205+
}
206+
}
207+
208+
if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) {
209+
query += " \(orderBy)"
210+
}
211+
212+
query += " \(buildPaginationClause(limit: limit, offset: offset))"
213+
return query
153214
}
154215

155216
func buildSortedQuery(
@@ -213,6 +274,13 @@ struct TableQueryBuilder {
213274

214275
// MARK: - Private Helpers
215276

277+
private func buildPaginationClause(limit: Int, offset: Int) -> String {
278+
if let dialect, dialect.paginationStyle == .offsetFetch {
279+
return "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY"
280+
}
281+
return "LIMIT \(limit) OFFSET \(offset)"
282+
}
283+
216284
private func sortColumnsAsTuples(_ sortState: SortState?) -> [(columnIndex: Int, ascending: Bool)] {
217285
sortState?.columns.compactMap { sortCol -> (columnIndex: Int, ascending: Bool)? in
218286
guard sortCol.columnIndex >= 0 else { return nil }

TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,24 +100,11 @@ extension MainContentCoordinator {
100100
}
101101
}
102102

103-
/// Reload current page data
104103
private func reloadCurrentPage() {
105104
guard let tabIndex = tabManager.selectedTabIndex,
106-
tabIndex < tabManager.tabs.count,
107-
let tableName = tabManager.tabs[tabIndex].tableName else { return }
108-
109-
let tab = tabManager.tabs[tabIndex]
110-
let pagination = tab.pagination
111-
112-
let newQuery = queryBuilder.buildBaseQuery(
113-
tableName: tableName,
114-
sortState: tab.sortState,
115-
columns: tab.resultColumns,
116-
limit: pagination.pageSize,
117-
offset: pagination.currentOffset
118-
)
105+
tabIndex < tabManager.tabs.count else { return }
119106

120-
tabManager.tabs[tabIndex].query = newQuery
107+
rebuildTableQuery(at: tabIndex)
121108
runQuery()
122109
}
123110
}

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,11 @@ final class MainContentCoordinator {
232232
self.filterStateManager = filterStateManager
233233
self.columnVisibilityManager = columnVisibilityManager
234234
self.toolbarState = toolbarState
235+
let dialect = PluginManager.shared.sqlDialect(for: connection.type)
235236
self.queryBuilder = TableQueryBuilder(
236237
databaseType: connection.type,
237-
dialectQuote: quoteIdentifierFromDialect(
238-
PluginManager.shared.sqlDialect(for: connection.type)
239-
)
238+
dialect: dialect,
239+
dialectQuote: quoteIdentifierFromDialect(dialect)
240240
)
241241
self.persistence = TabPersistenceCoordinator(connectionId: connection.id)
242242

0 commit comments

Comments
 (0)