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/Core/Services/Query/RowParser.swift b/TablePro/Core/Services/Query/RowParser.swift index 2ef6bcfcb..f55c61cac 100644 --- a/TablePro/Core/Services/Query/RowParser.swift +++ b/TablePro/Core/Services/Query/RowParser.swift @@ -23,12 +23,10 @@ protocol RowDataParser { /// Matches the format produced by RowOperationsManager.copySelectedRowsToClipboard() struct TSVRowParser: RowDataParser { func parse(_ text: String, schema: TableSchema) -> Result<[ParsedRow], RowParseError> { - // Check for empty input guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .failure(.emptyClipboard) } - // Split into lines let lines = text.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } @@ -42,18 +40,14 @@ struct TSVRowParser: RowDataParser { for (index, line) in lines.enumerated() { let lineNumber = index + 1 - // Parse TSV line let rawValues = line.components(separatedBy: "\t") var values = rawValues.map { normalizeValue($0) } - // Handle column count mismatch if values.count < schema.columnCount { - // Pad with NULL for missing columns while values.count < schema.columnCount { values.append(nil) } } else if values.count > schema.columnCount { - // Truncate extra columns values = Array(values.prefix(schema.columnCount)) } @@ -76,7 +70,6 @@ struct TSVRowParser: RowDataParser { private func normalizeValue(_ rawValue: String) -> String? { let trimmed = rawValue.trimmingCharacters(in: .whitespaces) - // Empty string or "NULL" (case-insensitive) → nil if trimmed.isEmpty || trimmed.uppercased() == "NULL" { return nil } @@ -107,7 +100,6 @@ struct CSVRowParser: RowDataParser { return .failure(.noValidRows) } - // Detect header row: if first row's values match column names, skip it let startIndex = isHeaderRow(records[0], schema: schema) ? 1 : 0 guard startIndex < records.count else { return .failure(.noValidRows) @@ -119,7 +111,6 @@ struct CSVRowParser: RowDataParser { let lineNumber = recordIndex + 1 var values = records[recordIndex].map { normalizeValue($0) } - // Handle column count mismatch if values.count < schema.columnCount { while values.count < schema.columnCount { values.append(nil) 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")