diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 5102a2a0d..18a667da4 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -80,7 +80,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable guard let match = databases.first(where: { $0.name == databaseName }) else { throw CloudflareD1Error( - message: String(localized: "Database '\(databaseName)' not found in account") + message: String(format: String(localized: "Database '%@' not found in account"), databaseName) ) } databaseId = match.uuid @@ -603,7 +603,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable guard let resolvedUuid = uuid else { throw CloudflareD1Error( - message: String(localized: "Database '\(database)' not found") + message: String(format: String(localized: "Database '%@' not found"), database) ) } diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index 12db3513a..2d2c901f4 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -377,7 +377,7 @@ final class D1HttpClient: @unchecked Sendable { Self.logger.warning("D1 rate limited. Retry-After: \(retryAfter ?? "not specified")") if let seconds = retryAfter { throw D1HttpError( - message: String(localized: "Rate limited by Cloudflare. Retry after \(seconds) seconds.") + message: String(format: String(localized: "Rate limited by Cloudflare. Retry after %@ seconds."), seconds) ) } else { throw D1HttpError( diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 8c1e16de7..5adebeb62 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -127,15 +127,15 @@ internal enum DynamoDBError: Error, LocalizedError { case .notConnected: return String(localized: "Not connected to DynamoDB") case .connectionFailed(let detail): - return String(localized: "Connection failed: \(detail)") + return String(format: String(localized: "Connection failed: %@"), detail) case .serverError(let detail): - return String(localized: "DynamoDB error: \(detail)") + return String(format: String(localized: "DynamoDB error: %@"), detail) case .authFailed(let detail): - return String(localized: "Authentication failed: \(detail)") + return String(format: String(localized: "Authentication failed: %@"), detail) case .requestCancelled: return String(localized: "Request was cancelled") case .invalidResponse(let detail): - return String(localized: "Invalid response: \(detail)") + return String(format: String(localized: "Invalid response: %@"), detail) } } } diff --git a/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift b/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift index 14eff57c2..b73c6d5ba 100644 --- a/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift +++ b/Plugins/EtcdDriverPlugin/EtcdCommandParser.swift @@ -72,9 +72,9 @@ extension EtcdParseError: PluginDriverError { var pluginErrorMessage: String { switch self { case .emptySyntax: return String(localized: "Empty etcd command") - case .unknownCommand(let cmd): return String(localized: "Unknown command: \(cmd)") - case .missingArgument(let msg): return String(localized: "Missing argument: \(msg)") - case .invalidArgument(let msg): return String(localized: "Invalid argument: \(msg)") + case .unknownCommand(let cmd): return String(format: String(localized: "Unknown command: %@"), cmd) + case .missingArgument(let msg): return String(format: String(localized: "Missing argument: %@"), msg) + case .invalidArgument(let msg): return String(format: String(localized: "Invalid argument: %@"), msg) } } } diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index fdd1bb7be..da0a68442 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -22,11 +22,11 @@ internal enum EtcdError: Error, LocalizedError { case .notConnected: return String(localized: "Not connected to etcd") case .connectionFailed(let detail): - return String(localized: "Connection failed: \(detail)") + return String(format: String(localized: "Connection failed: %@"), detail) case .serverError(let detail): - return String(localized: "Server error: \(detail)") + return String(format: String(localized: "Server error: %@"), detail) case .authFailed(let detail): - return String(localized: "Authentication failed: \(detail)") + return String(format: String(localized: "Authentication failed: %@"), detail) case .requestCancelled: return String(localized: "Request was cancelled") } diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 21d360a9c..548ca7d54 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -91,8 +91,8 @@ extension RedisParseError: PluginDriverError { var pluginErrorMessage: String { switch self { case .emptySyntax: return String(localized: "Empty Redis command") - case .invalidArgument(let msg): return String(localized: "Invalid argument: \(msg)") - case .missingArgument(let msg): return String(localized: "Missing argument: \(msg)") + case .invalidArgument(let msg): return String(format: String(localized: "Invalid argument: %@"), msg) + case .missingArgument(let msg): return String(format: String(localized: "Missing argument: %@"), msg) } } } diff --git a/Plugins/TableProPluginKit/MongoShellParser.swift b/Plugins/TableProPluginKit/MongoShellParser.swift index e18ca569c..2e66647c1 100644 --- a/Plugins/TableProPluginKit/MongoShellParser.swift +++ b/Plugins/TableProPluginKit/MongoShellParser.swift @@ -59,13 +59,13 @@ public enum MongoShellParseError: Error, LocalizedError { public var errorDescription: String? { switch self { case .invalidSyntax(let msg): - return String(localized: "Invalid MongoDB syntax: \(msg)") + return String(format: String(localized: "Invalid MongoDB syntax: %@"), msg) case .unsupportedMethod(let method): - return String(localized: "Unsupported MongoDB method: \(method)") + return String(format: String(localized: "Unsupported MongoDB method: %@"), method) case .invalidJson(let msg): - return String(localized: "Invalid JSON: \(msg)") + return String(format: String(localized: "Invalid JSON: %@"), msg) case .missingArgument(let msg): - return String(localized: "Missing argument: \(msg)") + return String(format: String(localized: "Missing argument: %@"), msg) } } } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 32cf563fc..e56469f41 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -259,12 +259,6 @@ struct SQLStatementGenerator { return ParameterizedStatement(sql: sql, parameters: parameters) } - /// Marker type for SQL function literals that cannot be parameterized - private struct SQLFunctionLiteral { - let value: String - init(_ value: String) { self.value = value } - } - // MARK: - UPDATE Generation func generateUpdateSQL(for change: RowChange) -> ParameterizedStatement? { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 609ea43c8..35fe83ce4 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -230,16 +230,11 @@ extension DatabaseDriver { var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { nil } func quoteIdentifier(_ name: String) -> String { - let q = "\"" - let escaped = name.replacingOccurrences(of: q, with: q + q) - return "\(q)\(escaped)\(q)" + SQLEscaping.quoteIdentifier(name) } func escapeStringLiteral(_ value: String) -> String { - var result = value - result = result.replacingOccurrences(of: "'", with: "''") - result = result.replacingOccurrences(of: "\0", with: "") - return result + SQLEscaping.escapeStringLiteral(value) } func createViewTemplate() -> String? { nil } diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index 48c9a6b9d..774404ad8 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -25,6 +25,13 @@ enum SQLEscaping { return result } + /// Quote a SQL identifier using ANSI double-quote rules, doubling any embedded quote. + static func quoteIdentifier(_ identifier: String) -> String { + let quote = "\"" + let escaped = identifier.replacingOccurrences(of: quote, with: quote + quote) + return "\(quote)\(escaped)\(quote)" + } + /// Known SQL temporal function expressions that should not be quoted/parameterized. /// Canonical source — used by SQLStatementGenerator and sidebar save logic. static let temporalFunctionExpressions: Set = [ diff --git a/TablePro/Core/MCP/MCPAuditLogStorage.swift b/TablePro/Core/MCP/MCPAuditLogStorage.swift index c31d3b2af..ce6d951ae 100644 --- a/TablePro/Core/MCP/MCPAuditLogStorage.swift +++ b/TablePro/Core/MCP/MCPAuditLogStorage.swift @@ -19,12 +19,6 @@ actor MCPAuditLogStorage { private var dbPath: String? private let testDatabaseSuffix: String? - enum TimeRange: Equatable { - case lastHours(Int) - case lastDays(Int) - case all - } - init() { self.testDatabaseSuffix = nil setupDatabase() diff --git a/TablePro/Core/MCP/TokenPermissionFilter.swift b/TablePro/Core/MCP/TokenPermissionFilter.swift deleted file mode 100644 index 8c42600e6..000000000 --- a/TablePro/Core/MCP/TokenPermissionFilter.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -protocol ConnectionIdentifiable { - var connectionId: UUID { get } -} - -enum TokenPermissionFilter { - static let overfetchMultiplier = 3 - private static let maxRoundTrips = 2 - - static func filter(_ items: [T], by access: ConnectionAccess) -> [T] { - switch access { - case .all: - return items - case .limited(let ids): - return items.filter { ids.contains($0.connectionId) } - } - } - - static func fetchFiltered( - access: ConnectionAccess, - limit: Int, - fetch: (Int, Int) async throws -> [T] - ) async throws -> [T] { - if case .all = access { - let items = try await fetch(limit, 0) - return Array(items.prefix(limit)) - } - - guard limit > 0 else { return [] } - - let fetchLimit = limit * overfetchMultiplier - var collected: [T] = [] - var offset = 0 - - for _ in 0..= limit { break } - if raw.count < fetchLimit { break } - offset += fetchLimit - } - - return Array(collected.prefix(limit)) - } -} diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index ca3c22387..8d29a0686 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -50,14 +50,14 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send if let driver { return driver.quoteIdentifier(identifier) } - return "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" + return SQLEscaping.quoteIdentifier(identifier) } func escapeStringLiteral(_ value: String) -> String { if let driver { return driver.escapeStringLiteral(value) } - return value.replacingOccurrences(of: "'", with: "''") + return SQLEscaping.escapeStringLiteral(value) } func fetchTableDDL(table: String, databaseName: String) async throws -> String { diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index 5715841d7..c3c46d550 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -6,18 +6,6 @@ import Foundation import UniformTypeIdentifiers -// MARK: - Sheet Binding Wrappers - -struct IdentifiableURL: Identifiable { - let id = UUID() - let url: URL -} - -struct IdentifiableConnections: Identifiable { - let id = UUID() - let connections: [DatabaseConnection] -} - // MARK: - UTType extension UTType { diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 451e92723..7dd79311c 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -11,46 +11,6 @@ import Observation import SwiftUI import TableProPluginKit -// MARK: - Connection Environment - -/// Represents the connection environment type for visual badges -enum ConnectionEnvironment: String, CaseIterable { - case local = "LOCAL" - case ssh = "SSH" - case production = "PROD" - case staging = "STAGING" - - /// SF Symbol for this environment type - var iconName: String { - switch self { - case .local: return "house.fill" - case .ssh: return "lock.fill" - case .production: return "exclamationmark.triangle.fill" - case .staging: return "testtube.2" - } - } - - /// Badge background color - var backgroundColor: Color { - switch self { - case .local: return .gray.opacity(0.3) - case .ssh: return .orange.opacity(0.3) - case .production: return .red.opacity(0.3) - case .staging: return .blue.opacity(0.3) - } - } - - /// Badge foreground color - var foregroundColor: Color { - switch self { - case .local: return .secondary - case .ssh: return .orange - case .production: return .red - case .staging: return .blue - } - } -} - // MARK: - Connection State /// Represents the current state of the database connection diff --git a/TableProTests/Core/Database/SQLEscapingTests.swift b/TableProTests/Core/Database/SQLEscapingTests.swift index 488cba8e4..c0866f3d3 100644 --- a/TableProTests/Core/Database/SQLEscapingTests.swift +++ b/TableProTests/Core/Database/SQLEscapingTests.swift @@ -106,6 +106,23 @@ struct SQLEscapingTests { #expect(result == "\\''") } + // MARK: - quoteIdentifier Tests (ANSI SQL) + + @Test("Plain identifier wrapped in double quotes") + func testQuoteIdentifierPlain() { + #expect(SQLEscaping.quoteIdentifier("users") == "\"users\"") + } + + @Test("Embedded double quote is doubled") + func testQuoteIdentifierEmbeddedQuote() { + #expect(SQLEscaping.quoteIdentifier("we\"ird") == "\"we\"\"ird\"") + } + + @Test("Empty identifier yields empty quotes") + func testQuoteIdentifierEmpty() { + #expect(SQLEscaping.quoteIdentifier("") == "\"\"") + } + // MARK: - escapeLikeWildcards Tests @Test("LIKE plain string unchanged") diff --git a/TableProTests/Core/MongoDB/MongoShellParserTests.swift b/TableProTests/Core/MongoDB/MongoShellParserTests.swift index de330fb42..3c1c0bf07 100644 --- a/TableProTests/Core/MongoDB/MongoShellParserTests.swift +++ b/TableProTests/Core/MongoDB/MongoShellParserTests.swift @@ -1072,4 +1072,12 @@ struct MongoShellParserTests { Issue.record("Expected .deleteOne operation") } } + + @Test("error descriptions substitute the interpolated value") + func testErrorDescriptionsFormatArgument() { + #expect(MongoShellParseError.invalidSyntax("bad{").errorDescription == "Invalid MongoDB syntax: bad{") + #expect(MongoShellParseError.unsupportedMethod("foo").errorDescription == "Unsupported MongoDB method: foo") + #expect(MongoShellParseError.invalidJson("oops").errorDescription == "Invalid JSON: oops") + #expect(MongoShellParseError.missingArgument("id").errorDescription == "Missing argument: id") + } }