Skip to content

Commit 464cdca

Browse files
committed
fix: address PR review findings for DuckDB integration
- Use regexp_matches() for DuckDB regex filter instead of ~ operator (DuckDB ~ is full-match, not substring like PostgreSQL) - Add explicit CAST(column AS VARCHAR) for DuckDB LIKE on non-text columns - Fix index column regex to handle schema-qualified identifiers - Replace regex-based stripLimitOffset with parenthesis-aware parser that only strips top-level trailing LIMIT/OFFSET clauses - Add .duckdb to SQLStatementGenerator ($N placeholders, standard UPDATE/DELETE), SQLEscaping, and SQLParameterInliner - Add DuckDB case to showAllTablesMetadata via information_schema - Sync autocomplete types with TypePickerContentView (add TINYINT, SMALLINT, CHAR, BPCHAR, BLOB, BYTEA, TIMESTAMP WITH TIME ZONE, etc.)
1 parent 932d762 commit 464cdca

File tree

9 files changed

+87
-24
lines changed

9 files changed

+87
-24
lines changed

Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,6 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
271271
private var _currentSchema: String = "main"
272272

273273
private static let logger = Logger(subsystem: "com.TablePro", category: "DuckDBPluginDriver")
274-
private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+")
275-
private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+")
276274

277275
var currentSchema: String? { _currentSchema }
278276
var serverVersion: String? { String(cString: duckdb_library_version()) }
@@ -703,23 +701,60 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
703701
}
704702

705703
private func stripLimitOffset(from query: String) -> String {
706-
var result = query
704+
var result = query.trimmingCharacters(in: .whitespacesAndNewlines)
707705

708-
if let limitRegex = Self.limitRegex {
709-
let range = NSRange(result.startIndex..., in: result)
710-
result = limitRegex.stringByReplacingMatches(
711-
in: result, range: range, withTemplate: ""
712-
)
706+
// Strip trailing semicolons
707+
while result.hasSuffix(";") {
708+
result = String(result.dropLast()).trimmingCharacters(in: .whitespaces)
713709
}
714710

715-
if let offsetRegex = Self.offsetRegex {
716-
let range = NSRange(result.startIndex..., in: result)
717-
result = offsetRegex.stringByReplacingMatches(
718-
in: result, range: range, withTemplate: ""
719-
)
711+
// Only strip LIMIT/OFFSET at the top level (depth 0) from the end.
712+
// Strip OFFSET first (comes after LIMIT), then LIMIT.
713+
for keyword in ["OFFSET", "LIMIT"] {
714+
let upper = result.uppercased() as NSString
715+
if let pos = findLastTopLevelKeyword(keyword, upper: upper, length: upper.length) {
716+
result = (result as NSString).substring(to: pos)
717+
.trimmingCharacters(in: .whitespaces)
718+
}
719+
}
720+
721+
return result
722+
}
723+
724+
private func findLastTopLevelKeyword(
725+
_ keyword: String,
726+
upper: NSString,
727+
length: Int
728+
) -> Int? {
729+
let keyLen = keyword.count
730+
var depth = 0
731+
var i = length - 1
732+
733+
while i >= keyLen {
734+
let ch = upper.character(at: i)
735+
if ch == UInt16(UnicodeScalar(")").value) {
736+
depth += 1
737+
} else if ch == UInt16(UnicodeScalar("(").value) {
738+
depth -= 1
739+
} else if depth == 0 {
740+
let start = i - keyLen + 1
741+
if start >= 0 {
742+
let candidate = upper.substring(with: NSRange(location: start, length: keyLen))
743+
if candidate == keyword {
744+
let beforeOk = start == 0
745+
|| CharacterSet.whitespaces.contains(
746+
Unicode.Scalar(upper.character(at: start - 1))!
747+
)
748+
if beforeOk {
749+
return start
750+
}
751+
}
752+
}
753+
}
754+
i -= 1
720755
}
721756

722-
return result.trimmingCharacters(in: .whitespacesAndNewlines)
757+
return nil
723758
}
724759

725760
private func fetchPrimaryKeyColumns(
@@ -741,7 +776,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
741776
}
742777

743778
private static let indexColumnsRegex = try? NSRegularExpression(
744-
pattern: #"ON\s+(?:"[^"]*"|[^\s(]+)\s*\(([^)]+)\)"#, options: .caseInsensitive
779+
pattern: #"ON\s+(?:(?:"[^"]*"|[^\s(]+)\s*\.\s*)*(?:"[^"]*"|[^\s(]+)\s*\(([^)]+)\)"#,
780+
options: .caseInsensitive
745781
)
746782

747783
private func extractIndexColumns(from sql: String?) -> [String] {

TablePro/Core/Autocomplete/SQLCompletionProvider.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,11 @@ final class SQLCompletionProvider {
545545

546546
case .duckdb:
547547
types += [
548-
"HUGEINT", "LIST", "MAP", "STRUCT", "UNION", "ENUM", "UUID", "JSON", "BIT", "INTERVAL",
548+
"HUGEINT", "TINYINT", "SMALLINT", "REAL", "NUMERIC",
549+
"CHAR", "BPCHAR",
550+
"BLOB", "BYTEA",
551+
"TIMESTAMP WITH TIME ZONE",
552+
"LIST", "MAP", "STRUCT", "UNION", "ENUM", "UUID", "JSON", "BIT", "INTERVAL",
549553
]
550554

551555
case .mongodb:

TablePro/Core/ChangeTracking/SQLStatementGenerator.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ struct SQLStatementGenerator {
9898
/// Get placeholder syntax for the database type
9999
private func placeholder(at index: Int) -> String {
100100
switch databaseType {
101-
case .postgresql, .redshift:
102-
return "$\(index + 1)" // PostgreSQL uses $1, $2, etc.
101+
case .postgresql, .redshift, .duckdb:
102+
return "$\(index + 1)" // PostgreSQL/DuckDB uses $1, $2, etc.
103103
case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse:
104104
return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, Oracle, and ClickHouse use ?
105105
}
@@ -309,7 +309,7 @@ struct SQLStatementGenerator {
309309
case .clickhouse:
310310
sql =
311311
"ALTER TABLE \(databaseType.quoteIdentifier(tableName)) UPDATE \(setClauses) WHERE \(whereClause)"
312-
case .postgresql, .redshift, .mongodb, .redis:
312+
case .postgresql, .redshift, .duckdb, .mongodb, .redis:
313313
sql =
314314
"UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)"
315315
}
@@ -401,7 +401,7 @@ struct SQLStatementGenerator {
401401
case .clickhouse:
402402
sql =
403403
"ALTER TABLE \(databaseType.quoteIdentifier(tableName)) DELETE WHERE \(whereClause)"
404-
case .postgresql, .redshift, .mongodb, .redis:
404+
case .postgresql, .redshift, .duckdb, .mongodb, .redis:
405405
sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)"
406406
}
407407

TablePro/Core/Database/FilterSQLGenerator.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,10 @@ struct FilterSQLGenerator {
149149
switch databaseType {
150150
case .mysql, .mariadb:
151151
return "\(column) REGEXP '\(escapedPattern)'"
152-
case .postgresql, .redshift, .duckdb:
152+
case .postgresql, .redshift:
153153
return "\(column) ~ '\(escapedPattern)'"
154+
case .duckdb:
155+
return "regexp_matches(\(column), '\(escapedPattern)')"
154156
case .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse:
155157
return "\(column) LIKE '%\(escapedPattern)%'"
156158
}

TablePro/Core/Database/SQLEscaping.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ enum SQLEscaping {
4848
result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") // MySQL EOF marker (Ctrl+Z)
4949
return result
5050

51-
case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql, .oracle:
51+
case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql, .oracle, .duckdb:
5252
// Standard SQL: only single quotes need doubling
5353
// Newlines, tabs, backslashes are valid as-is in string literals
5454
var result = str

TablePro/Core/Services/Query/TableQueryBuilder.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,9 @@ struct TableQueryBuilder {
480480
return "CAST(\(column) AS CHAR) LIKE '%\(searchText)%'"
481481
case .clickhouse:
482482
return "toString(\(column)) LIKE '%\(searchText)%' ESCAPE '\\'"
483-
case .sqlite, .mongodb, .redis, .duckdb:
483+
case .duckdb:
484+
return "CAST(\(column) AS VARCHAR) LIKE '%\(searchText)%' ESCAPE '\\'"
485+
case .sqlite, .mongodb, .redis:
484486
return "\(column) LIKE '%\(searchText)%' ESCAPE '\\'"
485487
case .mssql:
486488
return "CAST(\(column) AS NVARCHAR(MAX)) LIKE '%\(searchText)%' ESCAPE '\\'"

TablePro/Core/Utilities/SQL/SQLParameterInliner.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ struct SQLParameterInliner {
1919
/// - Returns: A SQL string with placeholders replaced by formatted literal values.
2020
static func inline(_ statement: ParameterizedStatement, databaseType: DatabaseType) -> String {
2121
switch databaseType {
22-
case .postgresql, .redshift:
22+
case .postgresql, .redshift, .duckdb:
2323
return inlineDollarPlaceholders(statement.sql, parameters: statement.parameters)
2424
case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle, .clickhouse:
2525
return inlineQuestionMarkPlaceholders(statement.sql, parameters: statement.parameters)

TablePro/Resources/Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@
231231
}
232232
}
233233
}
234+
},
235+
"/path/to/database.duckdb" : {
236+
234237
},
235238
"/path/to/database.sqlite" : {
236239
"localizations" : {

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,22 @@ extension MainContentCoordinator {
361361
WHERE OWNER = '\(schema)'
362362
ORDER BY TABLE_NAME
363363
"""
364+
case .duckdb:
365+
let schema: String
366+
if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable {
367+
schema = schemaDriver.escapedSchema
368+
} else {
369+
schema = "main"
370+
}
371+
sql = """
372+
SELECT
373+
table_schema as schema_name,
374+
table_name as name,
375+
table_type as kind
376+
FROM information_schema.tables
377+
WHERE table_schema = '\(schema)'
378+
ORDER BY table_name
379+
"""
364380
case .mongodb:
365381
tabManager.addTab(
366382
initialQuery: "db.runCommand({\"listCollections\": 1, \"nameOnly\": false})",

0 commit comments

Comments
 (0)