Skip to content

Commit 38be65b

Browse files
authored
Merge pull request #256 from datlechin/feat/plugin-system-phase2-security-performance
feat: implement plugin system
2 parents 336014f + 60bd986 commit 38be65b

38 files changed

Lines changed: 3392 additions & 2532 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Redis sidebar click showing data briefly then going empty due to double-navigation race condition (#251)
1515
- MongoDB showing "Invalid database name: ''" when connecting without a database name
1616

17+
### Changed
18+
19+
- NoSQL query building moved from Core to MongoDB/Redis plugins via optional `PluginDatabaseDriver` protocol methods
20+
- Standardized parameter binding across all database drivers with improved default escaping (type-aware numeric handling, NUL byte stripping, NULL literal support)
21+
1722
### Added
1823

24+
- True prepared statements for MSSQL (`sp_executesql`) and ClickHouse (HTTP query parameters), eliminating string interpolation for parameterized queries
25+
- Batch query operations for MSSQL, Oracle, and ClickHouse, eliminating N+1 query patterns for column, foreign key, and database metadata fetching; SQLite adds a batched `fetchAllForeignKeys` override within PRAGMA limitations
26+
- `PluginDriverError` protocol in TableProPluginKit for structured error reporting from driver plugins, with richer connection error messages showing error codes and SQL states
27+
- `pluginDispatchAsync` concurrency helper in TableProPluginKit for standardized async bridging in plugins
28+
- Shared `PluginRowLimits` constant in TableProPluginKit with 100K row default, enforced across all 8 driver plugins (ClickHouse, MSSQL, Oracle previously had no cap)
29+
- `driverVariant(for:)` method on `DriverPlugin` protocol for dynamic multi-type plugin dispatch, replacing hardcoded variant mapping
1930
- Safe mode levels: per-connection setting with 6 levels (Silent, Alert, Alert Full, Safe Mode, Safe Mode Full, Read-Only) replacing the boolean read-only toggle, with confirmation dialogs and Touch ID/password authentication for stricter levels
2031
- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab
2132
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture

Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
2525

2626
// MARK: - Error Types
2727

28-
private struct ClickHouseError: Error, LocalizedError {
28+
private struct ClickHouseError: Error, PluginDriverError {
2929
let message: String
3030

3131
var errorDescription: String? { "ClickHouse Error: \(message)" }
32+
var pluginErrorMessage: String { message }
3233

3334
static let notConnected = ClickHouseError(message: "Not connected to database")
3435
static let connectionFailed = ClickHouseError(message: "Failed to establish connection")
@@ -142,6 +143,26 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
142143
)
143144
}
144145

146+
func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult {
147+
guard !parameters.isEmpty else {
148+
return try await execute(query: query)
149+
}
150+
151+
let startTime = Date()
152+
let queryId = UUID().uuidString
153+
let (convertedQuery, paramMap) = Self.buildClickHouseParams(query: query, parameters: parameters)
154+
let result = try await executeRawWithParams(convertedQuery, params: paramMap, queryId: queryId)
155+
let executionTime = Date().timeIntervalSince(startTime)
156+
157+
return PluginQueryResult(
158+
columns: result.columns,
159+
columnTypeNames: result.columnTypeNames,
160+
rows: result.rows,
161+
rowsAffected: result.affectedRows,
162+
executionTime: executionTime
163+
)
164+
}
165+
145166
func fetchRowCount(query: String) async throws -> Int {
146167
let countQuery = "SELECT count() FROM (\(query)) AS __cnt"
147168
let result = try await execute(query: countQuery)
@@ -431,6 +452,22 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
431452
return PluginDatabaseMetadata(name: database)
432453
}
433454

455+
func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] {
456+
let sql = """
457+
SELECT database, count() AS table_count, sum(total_bytes) AS size_bytes
458+
FROM system.tables
459+
GROUP BY database
460+
ORDER BY database
461+
"""
462+
let result = try await execute(query: sql)
463+
return result.rows.compactMap { row -> PluginDatabaseMetadata? in
464+
guard let name = row[safe: 0] ?? nil else { return nil }
465+
let tableCount = (row[safe: 1] ?? nil).flatMap { Int($0) } ?? 0
466+
let sizeBytes = (row[safe: 2] ?? nil).flatMap { Int64($0) }
467+
return PluginDatabaseMetadata(name: name, tableCount: tableCount, sizeBytes: sizeBytes)
468+
}
469+
}
470+
434471
func createDatabase(name: String, charset: String, collation: String?) async throws {
435472
let escapedName = name.replacingOccurrences(of: "`", with: "``")
436473
_ = try await execute(query: "CREATE DATABASE `\(escapedName)`")
@@ -550,7 +587,64 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
550587
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0)
551588
}
552589

553-
private func buildRequest(query: String, database: String, queryId: String? = nil) throws -> URLRequest {
590+
private func executeRawWithParams(_ query: String, params: [String: String?], queryId: String? = nil) async throws -> CHQueryResult {
591+
lock.lock()
592+
guard let session = self.session else {
593+
lock.unlock()
594+
throw ClickHouseError.notConnected
595+
}
596+
let database = _currentDatabase
597+
if let queryId {
598+
_lastQueryId = queryId
599+
}
600+
lock.unlock()
601+
602+
let request = try buildRequest(query: query, database: database, queryId: queryId, params: params)
603+
let isSelect = Self.isSelectLikeQuery(query)
604+
605+
let (data, response) = try await withTaskCancellationHandler {
606+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in
607+
let task = session.dataTask(with: request) { data, response, error in
608+
if let error {
609+
continuation.resume(throwing: error)
610+
return
611+
}
612+
guard let data, let response else {
613+
continuation.resume(throwing: ClickHouseError(message: "Empty response from server"))
614+
return
615+
}
616+
continuation.resume(returning: (data, response))
617+
}
618+
self.lock.lock()
619+
self.currentTask = task
620+
self.lock.unlock()
621+
task.resume()
622+
}
623+
} onCancel: {
624+
self.lock.lock()
625+
self.currentTask?.cancel()
626+
self.currentTask = nil
627+
self.lock.unlock()
628+
}
629+
630+
lock.lock()
631+
currentTask = nil
632+
lock.unlock()
633+
634+
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 {
635+
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
636+
Self.logger.error("ClickHouse HTTP \(httpResponse.statusCode): \(body)")
637+
throw ClickHouseError(message: body.trimmingCharacters(in: .whitespacesAndNewlines))
638+
}
639+
640+
if isSelect {
641+
return parseTabSeparatedResponse(data)
642+
}
643+
644+
return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0)
645+
}
646+
647+
private func buildRequest(query: String, database: String, queryId: String? = nil, params: [String: String?]? = nil) throws -> URLRequest {
554648
let useTLS = config.additionalFields["sslMode"] != nil
555649
&& config.additionalFields["sslMode"] != "Disabled"
556650

@@ -568,6 +662,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
568662
queryItems.append(URLQueryItem(name: "query_id", value: queryId))
569663
}
570664
queryItems.append(URLQueryItem(name: "send_progress_in_http_headers", value: "1"))
665+
if let params {
666+
for (key, value) in params.sorted(by: { $0.key < $1.key }) {
667+
queryItems.append(URLQueryItem(name: "param_\(key)", value: value))
668+
}
669+
}
571670
if !queryItems.isEmpty {
572671
components.queryItems = queryItems
573672
}
@@ -631,6 +730,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
631730
return Self.unescapeTsvField(field)
632731
}
633732
rows.append(row)
733+
if rows.count >= PluginRowLimits.defaultMax {
734+
break
735+
}
634736
}
635737

636738
return CHQueryResult(
@@ -699,6 +801,49 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
699801
return query
700802
}
701803

804+
/// Convert `?` placeholders to `{p1:String}` and build parameter map for ClickHouse HTTP params.
805+
private static func buildClickHouseParams(
806+
query: String,
807+
parameters: [String?]
808+
) -> (String, [String: String?]) {
809+
var converted = ""
810+
var paramIndex = 0
811+
var inSingleQuote = false
812+
var inDoubleQuote = false
813+
var isEscaped = false
814+
815+
for char in query {
816+
if isEscaped {
817+
isEscaped = false
818+
converted.append(char)
819+
continue
820+
}
821+
if char == "\\" && (inSingleQuote || inDoubleQuote) {
822+
isEscaped = true
823+
converted.append(char)
824+
continue
825+
}
826+
if char == "'" && !inDoubleQuote {
827+
inSingleQuote.toggle()
828+
} else if char == "\"" && !inSingleQuote {
829+
inDoubleQuote.toggle()
830+
}
831+
if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count {
832+
paramIndex += 1
833+
converted.append("{p\(paramIndex):String}")
834+
} else {
835+
converted.append(char)
836+
}
837+
}
838+
839+
var paramMap: [String: String?] = [:]
840+
for i in 0..<paramIndex where i < parameters.count {
841+
paramMap["p\(i + 1)"] = parameters[i]
842+
}
843+
844+
return (converted, paramMap)
845+
}
846+
702847
// MARK: - TLS Delegate
703848

704849
private class InsecureTLSDelegate: NSObject, URLSessionDelegate {

0 commit comments

Comments
 (0)