From 2fda2938576d269864c40d18a7822337c05dde0a Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Thu, 4 Jun 2026 00:58:24 -0500 Subject: [PATCH 01/14] feat(plugins): add Snowflake database driver (#1420) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-plugin.yml | 5 + CHANGELOG.md | 1 + .../TableProCoreTypes/DatabaseType.swift | 4 +- .../DatabaseTypeTests.swift | 4 +- Plugins/SnowflakeDriverPlugin/Info.plist | 10 + .../SnowflakeDriverPlugin/SnowflakeAuth.swift | 238 ++++++ .../SnowflakeBrowserAuthServer.swift | 198 +++++ .../SnowflakeConnection.swift | 707 ++++++++++++++++++ .../SnowflakeError.swift | 54 ++ .../SnowflakeMFATokenStore.swift | 83 ++ .../SnowflakePlugin.swift | 327 ++++++++ .../SnowflakePluginDriver.swift | 391 ++++++++++ .../SnowflakeTypeMapper.swift | 90 +++ TablePro.xcodeproj/project.pbxproj | 202 ++++- .../snowflake-icon.imageset/Contents.json | 16 + .../snowflake-icon.imageset/snowflake.svg | 9 + ...PluginMetadataRegistry+CloudDefaults.swift | 307 ++++++++ docs/databases/snowflake.mdx | 93 +++ docs/docs.json | 1 + scripts/add-snowflake-to-xcode.rb | 73 ++ scripts/release-all-plugins.sh | 1 + 21 files changed, 2787 insertions(+), 27 deletions(-) create mode 100644 Plugins/SnowflakeDriverPlugin/Info.plist create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeBrowserAuthServer.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeError.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift create mode 100644 TablePro/Assets.xcassets/snowflake-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/snowflake-icon.imageset/snowflake.svg create mode 100644 docs/databases/snowflake.mdx create mode 100644 scripts/add-snowflake-to-xcode.rb diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index ffecdef41..9bc0c9d38 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -199,6 +199,11 @@ jobs: DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API" DB_TYPE_IDS='["BigQuery"]'; ICON="bigquery-icon"; BUNDLE_NAME="BigQueryDriverPlugin" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/bigquery" ;; + snowflake) + TARGET="SnowflakeDriverPlugin"; BUNDLE_ID="com.TablePro.SnowflakeDriverPlugin" + DISPLAY_NAME="Snowflake Driver"; SUMMARY="Snowflake cloud data warehouse driver via the connector REST protocol" + DB_TYPE_IDS='["Snowflake"]'; ICON="snowflake-icon"; BUNDLE_NAME="SnowflakeDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/snowflake" ;; xlsx) TARGET="XLSXExport"; BUNDLE_ID="com.TablePro.XLSXExportPlugin" DISPLAY_NAME="XLSX Export"; SUMMARY="Export data to Microsoft Excel XLSX format" diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c6aa956..2a63c4f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Snowflake support: connect with username & password (including MFA TOTP passcodes and cached MFA tokens), key-pair (.p8), browser SSO, or a programmatic access token; browse databases, schemas, and tables; run Snowflake SQL with full result sets, query cancellation, and automatic session renewal. Connections defined in the Snowflake CLI's config files can be reused by name. (#1420) - Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) - Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 6e3898fa9..2b0843f95 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -25,6 +25,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") public static let dynamodb = DatabaseType(rawValue: "DynamoDB") public static let bigquery = DatabaseType(rawValue: "BigQuery") + public static let snowflake = DatabaseType(rawValue: "Snowflake") public static let libsql = DatabaseType(rawValue: "libSQL") public static let cockroachdb = DatabaseType(rawValue: "CockroachDB") public static let scylladb = DatabaseType(rawValue: "ScyllaDB") @@ -33,7 +34,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, - .etcd, .cloudflareD1, .dynamodb, .bigquery, .libsql + .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -55,6 +56,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .cloudflareD1: return "cloudflare-d1-icon" case .dynamodb: return "dynamodb-icon" case .bigquery: return "bigquery-icon" + case .snowflake: return "snowflake-icon" case .libsql: return "libsql-icon" default: return "externaldrive" } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index a9ff3d770..41b749052 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -15,6 +15,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mssql.rawValue == "SQL Server") #expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1") #expect(DatabaseType.bigquery.rawValue == "BigQuery") + #expect(DatabaseType.snowflake.rawValue == "Snowflake") } @Test("pluginTypeId maps multi-type databases") @@ -51,9 +52,10 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 17) + #expect(DatabaseType.allKnownTypes.count == 18) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) + #expect(DatabaseType.allKnownTypes.contains(.snowflake)) #expect(DatabaseType.allKnownTypes.contains(.libsql)) } diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist new file mode 100644 index 000000000..d2e3be973 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -0,0 +1,10 @@ + + + + + TableProMinAppVersion + 0.48.0 + TableProPluginKitVersion + 18 + + diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift new file mode 100644 index 000000000..3ea1a9565 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift @@ -0,0 +1,238 @@ +// +// SnowflakeAuth.swift +// SnowflakeDriverPlugin +// +// Account identifier parsing, key-pair JWT generation, and +// ~/.snowflake/connections.toml parsing. +// + +import CryptoKit +import Foundation +import os +import Security + +enum SnowflakeAccount { + static func host(forAccount account: String) -> String { + let trimmed = account.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasSuffix(".snowflakecomputing.com") { + return trimmed + } + if trimmed.contains("://") { + return URL(string: trimmed)?.host ?? trimmed + } + return "\(trimmed).snowflakecomputing.com" + } + + /// The account name used as the JWT issuer/subject prefix. Snowflake expects the + /// account locator without any region/cloud segment, uppercased. + static func issuerAccountName(forAccount account: String) -> String { + var name = account.trimmingCharacters(in: .whitespacesAndNewlines) + if name.lowercased().hasSuffix(".snowflakecomputing.com") { + name = String(name.dropLast(".snowflakecomputing.com".count)) + } + if let dotIndex = name.firstIndex(of: ".") { + name = String(name[.. String { + let privateKey = try loadPrivateKey() + let qualifiedUser = "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" + let fingerprint = try publicKeyFingerprint(for: privateKey) + let issuer = "\(qualifiedUser).\(fingerprint)" + + let now = Date() + let iat = Int(now.timeIntervalSince1970) + let exp = iat + Int(lifetime) + + let headerJSON = #"{"alg":"RS256","typ":"JWT"}"# + let claimsJSON = #"{"iss":"\#(issuer)","sub":"\#(qualifiedUser)","iat":\#(iat),"exp":\#(exp)}"# + + let signingInput = "\(base64URL(Data(headerJSON.utf8))).\(base64URL(Data(claimsJSON.utf8)))" + let signature = try sign(Data(signingInput.utf8), with: privateKey) + return "\(signingInput).\(base64URL(signature))" + } + + private func loadPrivateKey() throws -> SecKey { + guard let pemData = privateKeyPEM.data(using: .utf8) else { + throw SnowflakeError.authFailed("Private key is not valid UTF-8") + } + + var inputFormat = SecExternalFormat.formatUnknown + var itemType = SecExternalItemType.itemTypeUnknown + var importedItems: CFArray? + + var keyParams = SecItemImportExportKeyParameters() + var passphraseRef: CFTypeRef? + if let passphrase, !passphrase.isEmpty { + let ref = passphrase as CFString + passphraseRef = ref + keyParams.passphrase = Unmanaged.passUnretained(ref) + } + _ = passphraseRef + + let status = SecItemImport( + pemData as CFData, + "p8" as CFString, + &inputFormat, + &itemType, + SecItemImportExportFlags(rawValue: 0), + &keyParams, + nil, + &importedItems + ) + + guard status == errSecSuccess, + let items = importedItems as? [SecKey], + let key = items.first + else { + throw SnowflakeError.authFailed( + "Failed to load private key (OSStatus \(status)). Ensure the file is a valid RSA .p8 key and the passphrase is correct." + ) + } + return key + } + + private func publicKeyFingerprint(for privateKey: SecKey) throws -> String { + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw SnowflakeError.authFailed("Could not derive public key from private key") + } + var error: Unmanaged? + guard let pkcs1 = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { + let message = error?.takeRetainedValue().localizedDescription ?? "unknown error" + throw SnowflakeError.authFailed("Could not export public key: \(message)") + } + let spki = Self.wrapPKCS1IntoSPKI(pkcs1) + let digest = SHA256.hash(data: spki) + return "SHA256:\(Data(digest).base64EncodedString())" + } + + private func sign(_ data: Data, with key: SecKey) throws -> Data { + var error: Unmanaged? + guard let signature = SecKeyCreateSignature( + key, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error + ) as Data? else { + let message = error?.takeRetainedValue().localizedDescription ?? "unknown error" + throw SnowflakeError.authFailed("Failed to sign JWT: \(message)") + } + return signature + } + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Wrap a PKCS#1 RSAPublicKey DER blob into a SubjectPublicKeyInfo DER blob, + /// which is what Snowflake fingerprints with SHA-256. + static func wrapPKCS1IntoSPKI(_ pkcs1: Data) -> Data { + let rsaAlgorithmID: [UInt8] = [ + 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 + ] + var bitString: [UInt8] = [0x03] + bitString += derLength(pkcs1.count + 1) + bitString.append(0x00) + bitString += [UInt8](pkcs1) + + var body = rsaAlgorithmID + body += bitString + + var spki: [UInt8] = [0x30] + spki += derLength(body.count) + spki += body + return Data(spki) + } + + private static func derLength(_ length: Int) -> [UInt8] { + if length < 0x80 { + return [UInt8(length)] + } + var value = length + var bytes: [UInt8] = [] + while value > 0 { + bytes.insert(UInt8(value & 0xFF), at: 0) + value >>= 8 + } + return [UInt8(0x80 | bytes.count)] + bytes + } +} + +enum SnowflakeConnectionsTOML { + /// Look up the named connection in the Snowflake CLI's config files, checking + /// ~/.snowflake/connections.toml first, then [connections.*] sections in + /// ~/.snowflake/config.toml. Keys follow the CLI's snake_case naming + /// (account, user, password, authenticator, private_key_file, role, ...). + static func parameters(forConnection name: String) -> [String: String]? { + for filename in ["connections.toml", "config.toml"] { + let path = NSString(string: "~/.snowflake/\(filename)").expandingTildeInPath + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + if let section = parse(contents)[name] { + return section + } + } + return nil + } + + static func parse(_ contents: String) -> [String: [String: String]] { + var sections: [String: [String: String]] = [:] + var currentSection: String? + + for rawLine in contents.components(separatedBy: .newlines) { + let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + + if line.hasPrefix("[") && line.hasSuffix("]") { + var name = String(line.dropFirst().dropLast()) + if name.hasPrefix("connections.") { + name = String(name.dropFirst("connections.".count)) + } + name = name.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + currentSection = name + if sections[name] == nil { sections[name] = [:] } + continue + } + + guard let section = currentSection, + let equalIndex = line.firstIndex(of: "=") else { continue } + + let key = line[.. String { + var inString = false + var result = "" + for char in line { + if char == "\"" { inString.toggle() } + if char == "#" && !inString { break } + result.append(char) + } + return result + } + + private static func unquote(_ value: String) -> String { + if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { + return String(value.dropFirst().dropLast()) + } + if value.count >= 2, value.hasPrefix("'"), value.hasSuffix("'") { + return String(value.dropFirst().dropLast()) + } + return value + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeBrowserAuthServer.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeBrowserAuthServer.swift new file mode 100644 index 000000000..12e9cfb43 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeBrowserAuthServer.swift @@ -0,0 +1,198 @@ +// +// SnowflakeBrowserAuthServer.swift +// SnowflakeDriverPlugin +// +// Ephemeral localhost HTTP server that captures the SAML token returned by the +// identity provider during EXTERNALBROWSER (SSO) authentication. +// + +import Foundation +import Network +import os + +final class SnowflakeBrowserAuthServer: @unchecked Sendable { + private var listener: NWListener? + private var connection: NWConnection? + private var readyContinuation: CheckedContinuation? + private var continuation: CheckedContinuation? + private let lock = NSLock() + private var timeoutTask: Task? + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeBrowserAuthServer") + + func start() async throws -> UInt16 { + try await withCheckedThrowingContinuation { cont in + lock.withLock { readyContinuation = cont } + do { + try startListener() + } catch { + lock.withLock { readyContinuation = nil } + cont.resume(throwing: error) + } + } + } + + func waitForToken() async throws -> String { + try await withCheckedThrowingContinuation { cont in + lock.withLock { continuation = cont } + let task = Task { + try? await Task.sleep(nanoseconds: 120_000_000_000) + self.lock.withLock { + if let cont = self.continuation { + self.continuation = nil + cont.resume(throwing: SnowflakeError.timeout("Browser authentication timed out (2 minutes)")) + } + } + self.stop() + } + lock.withLock { timeoutTask = task } + } + } + + func stop() { + let (task, conn, lst): (Task?, NWConnection?, NWListener?) = lock.withLock { + let values = (timeoutTask, connection, listener) + timeoutTask = nil + connection = nil + listener = nil + return values + } + task?.cancel() + conn?.cancel() + lst?.cancel() + } + + private func startListener() throws { + let params = NWParameters.tcp + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + + let listener = try NWListener(using: params) + lock.withLock { self.listener = listener } + + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + let port = listener.port?.rawValue ?? 0 + self?.lock.withLock { + if let cont = self?.readyContinuation { + self?.readyContinuation = nil + cont.resume(returning: port) + } + } + case .failed(let error): + Self.logger.error("Browser auth server failed: \(error.localizedDescription)") + let authError = SnowflakeError.authFailed("Browser auth server failed: \(error.localizedDescription)") + self?.lock.withLock { + if let cont = self?.readyContinuation { + self?.readyContinuation = nil + cont.resume(throwing: authError) + } + if let cont = self?.continuation { + self?.continuation = nil + cont.resume(throwing: authError) + } + } + default: + break + } + } + + listener.newConnectionHandler = { [weak self] newConnection in + self?.handleConnection(newConnection) + } + + listener.start(queue: .global(qos: .userInitiated)) + } + + private func handleConnection(_ newConnection: NWConnection) { + lock.withLock { connection = newConnection } + newConnection.stateUpdateHandler = { [weak self] state in + if case .ready = state { + self?.readRequest(from: newConnection) + } + } + newConnection.start(queue: .global(qos: .userInitiated)) + } + + private func readRequest(from connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] content, _, _, error in + guard let self, let data = content, error == nil, + let request = String(data: data, encoding: .utf8) else { + self?.resumeWithError(SnowflakeError.authFailed("Failed to read browser callback")) + return + } + + if let token = self.extractToken(from: request) { + self.sendSuccessResponse(to: connection) + self.resumeWithToken(token) + } else if request.contains("error=") { + let desc = self.extractParam(named: "error", from: request) ?? "unknown" + self.sendErrorResponse(to: connection, error: desc) + self.resumeWithError(SnowflakeError.authFailed("SSO authorization failed: \(desc)")) + } else { + self.readRequest(from: connection) + } + } + } + + private func extractToken(from request: String) -> String? { + extractParam(named: "token", from: request) + } + + private func extractParam(named name: String, from request: String) -> String? { + guard let firstLine = request.components(separatedBy: "\r\n").first, + let pathPart = firstLine.components(separatedBy: " ").dropFirst().first, + let components = URLComponents(string: "http://localhost\(pathPart)") + else { return nil } + return components.queryItems?.first(where: { $0.name == name })?.value + } + + private func sendSuccessResponse(to connection: NWConnection) { + let html = """ + +

Authentication Successful

+

You can close this tab and return to TablePro.

+ + """ + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n\(html)" + connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func sendErrorResponse(to connection: NWConnection, error: String) { + let escaped = error + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + let html = """ + +

Authentication Failed

+

\(escaped)

+ + """ + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n\(html)" + connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func resumeWithToken(_ token: String) { + lock.withLock { + if let cont = continuation { + continuation = nil + cont.resume(returning: token) + } + } + stop() + } + + private func resumeWithError(_ error: Error) { + lock.withLock { + if let cont = continuation { + continuation = nil + cont.resume(throwing: error) + } + } + stop() + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift new file mode 100644 index 000000000..9505c6901 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -0,0 +1,707 @@ +// +// SnowflakeConnection.swift +// SnowflakeDriverPlugin +// +// Implements the Snowflake connector REST protocol (session login + query +// execution) used by the official Snowflake drivers. Supports password, +// key-pair JWT, OAuth token, and external-browser SSO authentication. +// + +import AppKit +import Compression +import Foundation +import os +import TableProPluginKit + +struct SnowflakeQueryResult: Sendable { + var columns: [SnowflakeColumnMeta] + var rows: [[PluginCellValueBox]] + var affectedRows: Int + var isTruncated: Bool + var statusMessage: String? +} + +/// JSON-decoded cell value before conversion to PluginCellValue (kept Sendable for streaming). +enum PluginCellValueBox: Sendable { + case null + case text(String) +} + +final class SnowflakeConnection: @unchecked Sendable { + struct ResolvedParameters { + var account: String + var user: String + var password: String + var mfaPasscode: String + var authMethod: String + var privateKeyPath: String + var privateKeyPassphrase: String + var oauthToken: String + var warehouse: String + var database: String + var schema: String + var role: String + } + + let host: String + let params: ResolvedParameters + + private let session: URLSession + private let lock = NSLock() + private var sessionToken: String? + private var renewalToken: String? + private var currentRequestID: String? + private var sequenceId = 0 + + private var _currentDatabase: String? + private var _currentSchema: String? + private var _currentWarehouse: String? + private var _currentRole: String? + + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeConnection") + private static let appName = "TablePro" + private static let appVersion = "1.0.0" + + var currentDatabase: String? { lock.withLock { _currentDatabase } } + var currentSchema: String? { lock.withLock { _currentSchema } } + var currentWarehouse: String? { lock.withLock { _currentWarehouse } } + var currentRole: String? { lock.withLock { _currentRole } } + + init(config: DriverConnectionConfig) { + self.params = Self.resolveParameters(from: config) + self.host = SnowflakeAccount.host(forAccount: params.account) + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 120 + configuration.timeoutIntervalForResource = 600 + self.session = URLSession(configuration: configuration) + + self._currentDatabase = params.database.isEmpty ? nil : params.database + self._currentSchema = params.schema.isEmpty ? nil : params.schema + self._currentWarehouse = params.warehouse.isEmpty ? nil : params.warehouse + self._currentRole = params.role.isEmpty ? nil : params.role + } + + // MARK: - Parameter Resolution + + private static func resolveParameters(from config: DriverConnectionConfig) -> ResolvedParameters { + let fields = config.additionalFields + func field(_ key: String) -> String { + fields[key]?.trimmingCharacters(in: .whitespaces) ?? "" + } + func pick(_ custom: String, _ standard: String) -> String { + custom.isEmpty ? standard : custom + } + var params = ResolvedParameters( + account: pick(field("snowflakeAccount"), config.host), + user: pick(field("snowflakeUser"), config.username), + password: pick(field("snowflakePassword"), config.password), + mfaPasscode: field("snowflakeMFAPasscode"), + authMethod: pick(field("snowflakeAuthMethod"), "password"), + privateKeyPath: field("snowflakePrivateKeyPath"), + privateKeyPassphrase: field("snowflakePrivateKeyPassphrase"), + oauthToken: field("snowflakeOAuthToken"), + warehouse: field("snowflakeWarehouse"), + database: pick(field("snowflakeDatabase"), config.database), + schema: field("snowflakeSchema"), + role: field("snowflakeRole") + ) + + let connectionName = fields["snowflakeConnectionName"]?.trimmingCharacters(in: .whitespaces) ?? "" + if !connectionName.isEmpty, let toml = SnowflakeConnectionsTOML.parameters(forConnection: connectionName) { + params.merge(toml: toml) + } + return params + } + + // MARK: - Connection Lifecycle + + func connect() async throws { + switch params.authMethod { + case "keyPair": + try await loginWithKeyPair() + case "oauth": + try await login(authenticator: "OAUTH", extra: ["TOKEN": params.oauthToken]) + case "externalBrowser": + try await loginWithExternalBrowser() + default: + try await loginWithPassword() + } + } + + func disconnect() { + let token = lock.withLock { sessionToken } + guard token != nil else { return } + lock.withLock { + sessionToken = nil + renewalToken = nil + currentRequestID = nil + } + Task { [weak self] in + try? await self?.postLogout(token: token) + } + } + + func ping() async throws { + _ = try await query("SELECT 1") + } + + // MARK: - Authentication + + private func loginWithPassword() async throws { + if let cachedToken = SnowflakeMFATokenStore.token(account: params.account, user: params.user) { + do { + try await login( + authenticator: "USERNAME_PASSWORD_MFA", + extra: ["PASSWORD": params.password, "TOKEN": cachedToken] + ) + return + } catch { + SnowflakeMFATokenStore.clear(account: params.account, user: params.user) + Self.logger.info("Cached MFA token rejected; retrying with credentials") + } + } + + var extra: [String: Any] = ["PASSWORD": params.password] + if !params.mfaPasscode.isEmpty { + extra["PASSCODE"] = params.mfaPasscode + extra["EXT_AUTHN_DUO_METHOD"] = "passcode" + } + try await login(authenticator: "SNOWFLAKE", extra: extra) + } + + private func loginWithKeyPair() async throws { + let path = NSString(string: params.privateKeyPath).expandingTildeInPath + guard let pem = try? String(contentsOfFile: path, encoding: .utf8) else { + throw SnowflakeError.configuration("Could not read private key file at \(params.privateKeyPath)") + } + let auth = SnowflakeKeyPairAuth( + account: params.account, + user: params.user, + privateKeyPEM: pem, + passphrase: params.privateKeyPassphrase.isEmpty ? nil : params.privateKeyPassphrase + ) + let jwt = try auth.makeJWT() + try await login(authenticator: "SNOWFLAKE_JWT", extra: ["TOKEN": jwt]) + } + + private func loginWithExternalBrowser() async throws { + let server = SnowflakeBrowserAuthServer() + let port = try await server.start() + + let authRequest: [String: Any] = [ + "data": [ + "ACCOUNT_NAME": SnowflakeAccount.issuerAccountName(forAccount: params.account), + "LOGIN_NAME": params.user, + "AUTHENTICATOR": "EXTERNALBROWSER", + "BROWSER_MODE_REDIRECT_PORT": String(port) + ] + ] + + let authResponse: [String: Any] + do { + authResponse = try await postJSON( + path: "/session/authenticator-request", + queryItems: Self.trackingQueryItems(), + body: authRequest, + token: nil + ) + } catch { + server.stop() + throw error + } + + guard let data = authResponse["data"] as? [String: Any], + let ssoURLString = data["ssoUrl"] as? String, + let ssoURL = URL(string: ssoURLString) else { + server.stop() + throw SnowflakeError.authFailed("Snowflake did not return an SSO URL for browser authentication") + } + let proofKey = data["proofKey"] as? String ?? "" + + _ = await MainActor.run { + NSWorkspace.shared.open(ssoURL) + } + + let token: String + do { + token = try await server.waitForToken() + } catch { + server.stop() + throw error + } + + try await login(authenticator: "EXTERNALBROWSER", extra: ["TOKEN": token, "PROOF_KEY": proofKey]) + } + + private func login(authenticator: String, extra: [String: Any]) async throws { + var data: [String: Any] = [ + "ACCOUNT_NAME": SnowflakeAccount.issuerAccountName(forAccount: params.account), + "LOGIN_NAME": params.user, + "CLIENT_APP_ID": Self.appName, + "CLIENT_APP_VERSION": Self.appVersion, + "CLIENT_ENVIRONMENT": [ + "APPLICATION": Self.appName, + "OS": "Mac OS", + "OCSP_MODE": "FAIL_OPEN" + ], + "SESSION_PARAMETERS": sessionParameters(for: authenticator) + ] + if authenticator != "SNOWFLAKE" { + data["AUTHENTICATOR"] = authenticator + } + for (key, value) in extra { + data[key] = value + } + + var queryItems = Self.trackingQueryItems() + if !params.warehouse.isEmpty { queryItems.append(URLQueryItem(name: "warehouse", value: params.warehouse)) } + if !params.database.isEmpty { queryItems.append(URLQueryItem(name: "databaseName", value: params.database)) } + if !params.schema.isEmpty { queryItems.append(URLQueryItem(name: "schemaName", value: params.schema)) } + if !params.role.isEmpty { queryItems.append(URLQueryItem(name: "roleName", value: params.role)) } + + let response = try await postJSON( + path: "/session/v1/login-request", + queryItems: queryItems, + body: ["data": data], + token: nil + ) + + guard (response["success"] as? Bool) == true, + let responseData = response["data"] as? [String: Any], + let token = responseData["token"] as? String else { + let message = response["message"] as? String ?? "Authentication failed" + let code = Self.codeString(response["code"]) + if code == "394507" || code == "394508" { + throw SnowflakeError.loginFailed( + code: code, + message: "\(message) Open the connection settings and refresh the MFA Passcode (TOTP) with a current code from your authenticator." + ) + } + throw SnowflakeError.loginFailed(code: code, message: message) + } + + lock.withLock { + sessionToken = token + renewalToken = responseData["masterToken"] as? String + } + if let mfaToken = responseData["mfaToken"] as? String, !mfaToken.isEmpty { + SnowflakeMFATokenStore.store(mfaToken, account: params.account, user: params.user) + Self.logger.info("Login succeeded (\(authenticator, privacy: .public)); MFA token cached for reuse") + } else if authenticator == "SNOWFLAKE" || authenticator == "USERNAME_PASSWORD_MFA" { + Self.logger.info("Login succeeded (\(authenticator, privacy: .public)); no mfaToken returned — ALLOW_CLIENT_MFA_CACHING may be disabled on this account") + } + applySessionInfo(responseData["sessionInfo"] as? [String: Any]) + } + + private func renewSession() async throws { + let (oldToken, renewal) = lock.withLock { (sessionToken, renewalToken) } + guard let renewal else { throw SnowflakeError.notConnected } + + let response = try await postJSON( + path: "/session/token-request", + queryItems: Self.trackingQueryItems(), + body: ["oldSessionToken": oldToken ?? "", "requestType": "RENEW"], + token: renewal + ) + + guard (response["success"] as? Bool) == true, + let data = response["data"] as? [String: Any], + let newToken = data["sessionToken"] as? String else { + let message = response["message"] as? String ?? "Session renewal failed" + throw SnowflakeError.loginFailed(code: Self.codeString(response["code"]), message: message) + } + + lock.withLock { + sessionToken = newToken + if let newRenewalToken = data["masterToken"] as? String { + renewalToken = newRenewalToken + } + } + } + + private func sessionParameters(for authenticator: String) -> [String: Any] { + var parameters: [String: Any] = ["CLIENT_STORE_TEMPORARY_CREDENTIAL": false] + if authenticator == "SNOWFLAKE" || authenticator == "USERNAME_PASSWORD_MFA" { + parameters["CLIENT_REQUEST_MFA_TOKEN"] = true + } + return parameters + } + + private func postLogout(token: String?) async throws { + guard token != nil else { return } + _ = try? await postJSON( + path: "/session/logout-request", + queryItems: Self.trackingQueryItems(), + body: [:], + token: token + ) + } + + // MARK: - Query Execution + + func query(_ sql: String) async throws -> SnowflakeQueryResult { + do { + return try await performQuery(sql) + } catch SnowflakeError.queryFailed(let code, _) where code == "390112" { + try await renewSession() + return try await performQuery(sql) + } + } + + func cancelCurrentQuery() { + let (requestID, token) = lock.withLock { (currentRequestID, sessionToken) } + guard let requestID, let token else { return } + Task { [weak self] in + _ = try? await self?.postJSON( + path: "/queries/v1/abort-request", + queryItems: Self.trackingQueryItems(), + body: ["requestId": requestID], + token: token + ) + } + } + + private func performQuery(_ sql: String) async throws -> SnowflakeQueryResult { + guard let token = lock.withLock({ sessionToken }) else { + throw SnowflakeError.notConnected + } + + let requestID = UUID().uuidString.lowercased() + let sequence = lock.withLock { () -> Int in + sequenceId += 1 + currentRequestID = requestID + return sequenceId + } + defer { lock.withLock { currentRequestID = nil } } + + let body: [String: Any] = [ + "sqlText": sql, + "asyncExec": false, + "sequenceId": sequence, + "querySubmissionTime": Int(Date().timeIntervalSince1970 * 1_000) + ] + + var response = try await postJSON( + path: "/queries/v1/query-request", + queryItems: Self.trackingQueryItems(requestID: requestID), + body: body, + token: token + ) + + response = try await pollIfInProgress(response, token: token) + + guard (response["success"] as? Bool) == true else { + let message = response["message"] as? String ?? "Query failed" + let code = Self.codeString(response["code"]) + throw SnowflakeError.queryFailed(code: code, message: message) + } + + guard let data = response["data"] as? [String: Any] else { + throw SnowflakeError.invalidResponse("Query response had no data") + } + + applyFinalSessionInfo(data) + return try await buildResult(from: data, token: token) + } + + private static func trackingQueryItems(requestID: String = UUID().uuidString.lowercased()) -> [URLQueryItem] { + [ + URLQueryItem(name: "requestId", value: requestID), + URLQueryItem(name: "request_guid", value: UUID().uuidString.lowercased()) + ] + } + + private func pollIfInProgress(_ initial: [String: Any], token: String) async throws -> [String: Any] { + var response = initial + var attempts = 0 + while Self.isInProgress(response), attempts < 600 { + attempts += 1 + guard let data = response["data"] as? [String: Any], + let resultPath = data["getResultUrl"] as? String else { + break + } + try await Task.sleep(nanoseconds: 500_000_000) + response = try await getJSON(path: resultPath, token: token) + } + return response + } + + private func buildResult(from data: [String: Any], token: String) async throws -> SnowflakeQueryResult { + let columns = Self.parseColumns(data["rowtype"] as? [[String: Any]] ?? []) + + var rows: [[PluginCellValueBox]] = [] + if let inlineRowset = data["rowset"] as? [[Any]] { + rows = inlineRowset.map { row in row.map(Self.box) } + } + + let affectedRows = Self.extractAffectedRows(columns: columns, rows: rows) + + if let chunks = data["chunks"] as? [[String: Any]], !chunks.isEmpty { + var headers = data["chunkHeaders"] as? [String: String] ?? [:] + if headers.isEmpty, let qrmk = data["qrmk"] as? String { + headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256" + headers["x-amz-server-side-encryption-customer-key"] = qrmk + } + rows.append(contentsOf: try await downloadChunks(chunks, headers: headers)) + } + + return SnowflakeQueryResult( + columns: columns, + rows: rows, + affectedRows: affectedRows, + isTruncated: false, + statusMessage: nil + ) + } + + private func downloadChunks(_ chunks: [[String: Any]], headers: [String: String]) async throws -> [[PluginCellValueBox]] { + var allRows: [[PluginCellValueBox]] = [] + for chunk in chunks { + guard let urlString = chunk["url"] as? String, let url = URL(string: urlString) else { continue } + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + let (rawData, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw SnowflakeError.invalidResponse("Failed to download result chunk") + } + let jsonData = Self.gunzipIfNeeded(rawData) + let rows = try Self.parseChunkRows(jsonData) + allRows.append(contentsOf: rows) + } + return allRows + } + + // MARK: - USE / Session + + func switchDatabase(to database: String) async throws { + _ = try await query("USE DATABASE \(quoteIdentifier(database))") + lock.withLock { _currentDatabase = database } + } + + func switchSchema(to schema: String) async throws { + _ = try await query("USE SCHEMA \(quoteIdentifier(schema))") + lock.withLock { _currentSchema = schema } + } + + func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + // MARK: - HTTP Helpers + + private func postJSON( + path: String, + queryItems: [URLQueryItem], + body: [String: Any], + token: String?, + accept: String = "application/json" + ) async throws -> [String: Any] { + guard var components = URLComponents(string: "https://\(host)\(path)") else { + throw SnowflakeError.configuration("Invalid Snowflake host: \(host)") + } + components.queryItems = queryItems + guard let url = components.url else { + throw SnowflakeError.configuration("Could not build request URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data(withJSONObject: body) + applyCommonHeaders(&request, token: token, accept: accept) + + return try await send(request) + } + + private func getJSON(path: String, token: String?) async throws -> [String: Any] { + let urlString = path.hasPrefix("http") ? path : "https://\(host)\(path)" + guard let url = URL(string: urlString) else { + throw SnowflakeError.configuration("Invalid result URL") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + applyCommonHeaders(&request, token: token) + return try await send(request) + } + + private func applyCommonHeaders(_ request: inout URLRequest, token: String?, accept: String = "application/json") { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(accept, forHTTPHeaderField: "Accept") + request.setValue("\(Self.appName)/\(Self.appVersion)", forHTTPHeaderField: "User-Agent") + if let token { + request.setValue("Snowflake Token=\"\(token)\"", forHTTPHeaderField: "Authorization") + } + } + + private func send(_ request: URLRequest) async throws -> [String: Any] { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw SnowflakeError.invalidResponse("No HTTP response from Snowflake") + } + guard (200..<300).contains(http.statusCode) else { + let bodyText = String(data: data, encoding: .utf8) ?? "" + Self.logger.error( + "HTTP \(http.statusCode, privacy: .public) from \(request.url?.path ?? "?", privacy: .public): \(String(bodyText.prefix(160)), privacy: .public)" + ) + throw SnowflakeError.invalidResponse("Snowflake returned HTTP \(http.statusCode) for \(request.url?.path ?? "request"): \(bodyText.prefix(300))") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw SnowflakeError.invalidResponse("Snowflake returned a non-JSON response") + } + return json + } + + // MARK: - Parsing Helpers + + private func applySessionInfo(_ info: [String: Any]?) { + guard let info else { return } + lock.withLock { + if let value = info["databaseName"] as? String, !value.isEmpty { _currentDatabase = value } + if let value = info["schemaName"] as? String, !value.isEmpty { _currentSchema = value } + if let value = info["warehouseName"] as? String, !value.isEmpty { _currentWarehouse = value } + if let value = info["roleName"] as? String, !value.isEmpty { _currentRole = value } + } + } + + private func applyFinalSessionInfo(_ data: [String: Any]) { + lock.withLock { + if let value = data["finalDatabaseName"] as? String, !value.isEmpty { _currentDatabase = value } + if let value = data["finalSchemaName"] as? String, !value.isEmpty { _currentSchema = value } + if let value = data["finalWarehouseName"] as? String, !value.isEmpty { _currentWarehouse = value } + if let value = data["finalRoleName"] as? String, !value.isEmpty { _currentRole = value } + } + } + + private static func parseColumns(_ rowtype: [[String: Any]]) -> [SnowflakeColumnMeta] { + rowtype.map { entry in + SnowflakeColumnMeta( + name: entry["name"] as? String ?? "", + internalType: entry["type"] as? String ?? "text", + nullable: entry["nullable"] as? Bool ?? true, + precision: entry["precision"] as? Int, + scale: entry["scale"] as? Int, + length: entry["length"] as? Int + ) + } + } + + private static func box(_ value: Any) -> PluginCellValueBox { + if value is NSNull { return .null } + if let string = value as? String { return .text(string) } + if let number = value as? NSNumber { return .text(number.stringValue) } + return .text(String(describing: value)) + } + + private static func extractAffectedRows(columns: [SnowflakeColumnMeta], rows: [[PluginCellValueBox]]) -> Int { + guard columns.count == 1, + columns[0].name.lowercased().contains("number of rows"), + let firstRow = rows.first, + case .text(let value) = firstRow.first ?? .null, + let count = Int(value) else { + return 0 + } + return count + } + + private static func parseChunkRows(_ data: Data) throws -> [[PluginCellValueBox]] { + let candidates: [Data] = [data, Data("[".utf8) + data + Data("]".utf8)] + for candidate in candidates { + if let parsed = try? JSONSerialization.jsonObject(with: candidate) as? [[Any]] { + return parsed.map { row in row.map(box) } + } + } + throw SnowflakeError.invalidResponse("Could not parse result chunk JSON") + } + + private static func codeString(_ value: Any?) -> String { + if let string = value as? String { return string } + if let number = value as? NSNumber { return number.stringValue } + return "" + } + + private static func isInProgress(_ response: [String: Any]) -> Bool { + let code = codeString(response["code"]) + return code == "333333" || code == "333334" + } + + // MARK: - Gzip + + private static func gunzipIfNeeded(_ data: Data) -> Data { + guard data.count > 18, data[data.startIndex] == 0x1F, data[data.startIndex + 1] == 0x8B else { + return data + } + let bytes = [UInt8](data) + let flags = bytes[3] + var offset = 10 + if flags & 0x04 != 0, offset + 2 <= bytes.count { + let extraLen = Int(bytes[offset]) | (Int(bytes[offset + 1]) << 8) + offset += 2 + extraLen + } + if flags & 0x08 != 0 { + while offset < bytes.count, bytes[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x10 != 0 { + while offset < bytes.count, bytes[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x02 != 0 { offset += 2 } + guard offset < bytes.count - 8 else { return data } + + let deflateBytes = Array(bytes[offset..<(bytes.count - 8)]) + return inflate(deflateBytes) ?? data + } + + private static func inflate(_ deflate: [UInt8]) -> Data? { + guard !deflate.isEmpty else { return nil } + var capacity = max(deflate.count * 8, 65_536) + for _ in 0..<6 { + var output = Data(count: capacity) + let written = output.withUnsafeMutableBytes { (outPtr: UnsafeMutableRawBufferPointer) -> Int in + deflate.withUnsafeBufferPointer { (inPtr: UnsafeBufferPointer) -> Int in + guard let outBase = outPtr.bindMemory(to: UInt8.self).baseAddress, + let inBase = inPtr.baseAddress else { return 0 } + return compression_decode_buffer( + outBase, capacity, inBase, deflate.count, nil, COMPRESSION_ZLIB + ) + } + } + if written > 0, written < capacity { + output.removeSubrange(written.., _ tomlKey: String) { + if self[keyPath: keyPath].isEmpty, let value = toml[tomlKey], !value.isEmpty { + self[keyPath: keyPath] = value + } + } + fill(\.account, "account") + fill(\.user, "user") + fill(\.password, "password") + fill(\.privateKeyPath, "private_key_path") + fill(\.privateKeyPath, "private_key_file") + fill(\.oauthToken, "token") + fill(\.warehouse, "warehouse") + fill(\.database, "database") + fill(\.schema, "schema") + fill(\.role, "role") + if let authenticator = toml["authenticator"], authMethod == "password" { + switch authenticator.lowercased() { + case "snowflake_jwt": authMethod = "keyPair" + case "oauth": authMethod = "oauth" + case "externalbrowser": authMethod = "externalBrowser" + default: break + } + } + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift new file mode 100644 index 000000000..e8e42a4b4 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift @@ -0,0 +1,54 @@ +// +// SnowflakeError.swift +// SnowflakeDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum SnowflakeError: Error, LocalizedError { + case notConnected + case authFailed(String) + case loginFailed(code: String, message: String) + case queryFailed(code: String, message: String) + case invalidResponse(String) + case timeout(String) + case cancelled + case configuration(String) + + var errorDescription: String? { + switch self { + case .notConnected: + return String(localized: "Not connected to Snowflake") + case .authFailed(let detail): + return detail + case .loginFailed(let code, let message): + return code.isEmpty ? message : "\(message) (\(code))" + case .queryFailed(let code, let message): + return code.isEmpty ? message : "\(message) (\(code))" + case .invalidResponse(let detail): + return detail + case .timeout(let detail): + return detail + case .cancelled: + return String(localized: "Query was cancelled") + case .configuration(let detail): + return detail + } + } +} + +extension SnowflakeError: PluginDriverError { + var pluginErrorMessage: String { + errorDescription ?? String(localized: "Unknown Snowflake error") + } + + var pluginErrorCode: Int? { + switch self { + case .loginFailed(let code, _), .queryFailed(let code, _): + return Int(code) + default: + return nil + } + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift new file mode 100644 index 000000000..b96e831d8 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift @@ -0,0 +1,83 @@ +// +// SnowflakeMFATokenStore.swift +// SnowflakeDriverPlugin +// +// Caches the MFA token Snowflake issues after a successful TOTP login +// (CLIENT_REQUEST_MFA_TOKEN), so subsequent connects don't need a fresh +// passcode until the token expires. Mirrors the official connectors. +// + +import Foundation +import os +import Security + +enum SnowflakeMFATokenStore { + private static let lock = NSLock() + private static var cache: [String: String] = [:] + private static let service = "com.TablePro.SnowflakeDriverPlugin.mfaToken" + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeMFATokenStore") + + static func token(account: String, user: String) -> String? { + let key = cacheKey(account: account, user: user) + if let cached = lock.withLock({ cache[key] }) { + return cached + } + guard let stored = readKeychain(key: key) else { return nil } + lock.withLock { cache[key] = stored } + return stored + } + + static func store(_ token: String, account: String, user: String) { + let key = cacheKey(account: account, user: user) + lock.withLock { cache[key] = token } + writeKeychain(token, key: key) + } + + static func clear(account: String, user: String) { + let key = cacheKey(account: account, user: user) + _ = lock.withLock { cache.removeValue(forKey: key) } + deleteKeychain(key: key) + } + + private static func cacheKey(account: String, user: String) -> String { + "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" + } + + private static func baseQuery(key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + } + + private static func readKeychain(key: String) -> String? { + var query = baseQuery(key: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func writeKeychain(_ value: String, key: String) { + var addQuery = baseQuery(key: key) + addQuery[kSecValueData as String] = Data(value.utf8) + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + var status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + status = SecItemUpdate( + baseQuery(key: key) as CFDictionary, + [kSecValueData as String: Data(value.utf8)] as CFDictionary + ) + } + if status != errSecSuccess { + logger.warning("Could not persist MFA token (OSStatus \(status)); it will be cached in memory only") + } + } + + private static func deleteKeychain(key: String) { + SecItemDelete(baseQuery(key: key) as CFDictionary) + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift new file mode 100644 index 000000000..50ffc6b2f --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -0,0 +1,327 @@ +// +// SnowflakePlugin.swift +// SnowflakeDriverPlugin +// +// Snowflake driver plugin via the Snowflake connector REST protocol. +// Supports password, key-pair (.p8), external browser SSO, and OAuth token +// authentication, plus ~/.snowflake/connections.toml integration. +// + +import Foundation +import os +import TableProPluginKit + +final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Snowflake Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Snowflake cloud data warehouse support via the connector REST protocol" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Snowflake" + static let databaseDisplayName = "Snowflake" + static let iconName = "snowflake-icon" + static let defaultPort = 443 + static let systemDatabaseNames: [String] = ["SNOWFLAKE", "SNOWFLAKE_SAMPLE_DATA"] + static let systemSchemaNames: [String] = ["INFORMATION_SCHEMA"] + static let isDownloadable = true + static let defaultSchemaName = "PUBLIC" + + static let connectionMode: ConnectionMode = .apiOnly + static let navigationModel: NavigationModel = .standard + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = true + static let urlSchemes: [String] = [] + static let brandColorHex = "#29B5E8" + static let queryLanguageName = "SQL" + static let editorLanguage: EditorLanguage = .sql + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = true + static let supportsSchemaSwitching = true + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + static let supportsImport = false + static let supportsExport = true + static let supportsSSH = false + static let supportsSSL = false + static let tableEntityName = "Tables" + static let supportsForeignKeyDisable = false + static let supportsReadOnlyMode = true + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let defaultGroupName = "default" + static let defaultPrimaryKeyColumn: String? = nil + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] + static let supportsCascadeDrop = true + static let supportsDropDatabase = true + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "snowflakeAccount", + label: String(localized: "Account Identifier"), + placeholder: "xy12345.us-east-1 or myorg-myaccount", + required: true, + section: .authentication + ), + ConnectionField( + id: "snowflakeAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "password", + fieldType: .dropdown(options: [ + .init(value: "password", label: "Username & Password"), + .init(value: "keyPair", label: "Key Pair (.p8)"), + .init(value: "externalBrowser", label: "Single Sign-On (Browser)"), + .init(value: "oauth", label: "OAuth Token") + ]), + section: .authentication + ), + ConnectionField( + id: "snowflakeUser", + label: String(localized: "Username"), + placeholder: "JANE_DOE", + section: .authentication, + visibleWhen: FieldVisibilityRule( + fieldId: "snowflakeAuthMethod", + values: ["password", "keyPair", "externalBrowser"] + ) + ), + ConnectionField( + id: "snowflakePassword", + label: String(localized: "Password"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["password"]) + ), + ConnectionField( + id: "snowflakeMFAPasscode", + label: String(localized: "MFA Passcode (TOTP)"), + placeholder: "Current code from your authenticator, if MFA is enforced", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["password"]) + ), + ConnectionField( + id: "snowflakePrivateKeyPath", + label: String(localized: "Private Key File"), + placeholder: "~/.snowflake/rsa_key.p8", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["keyPair"]) + ), + ConnectionField( + id: "snowflakePrivateKeyPassphrase", + label: String(localized: "Private Key Passphrase"), + placeholder: "Optional", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["keyPair"]) + ), + ConnectionField( + id: "snowflakeOAuthToken", + label: String(localized: "OAuth Token"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["oauth"]) + ), + ConnectionField( + id: "snowflakeWarehouse", + label: String(localized: "Warehouse"), + placeholder: "COMPUTE_WH", + section: .authentication + ), + ConnectionField( + id: "snowflakeSchema", + label: String(localized: "Schema"), + placeholder: "PUBLIC", + section: .authentication + ), + ConnectionField( + id: "snowflakeRole", + label: String(localized: "Role"), + placeholder: "Optional (e.g. SYSADMIN)", + section: .advanced + ), + ConnectionField( + id: "snowflakeConnectionName", + label: String(localized: "CLI Connection Name"), + placeholder: "Optional: name in ~/.snowflake/connections.toml", + section: .advanced + ) + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "ACCOUNT", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", + "AT", "BEFORE", "BEGIN", "BETWEEN", "BY", "CALL", "CASE", + "CAST", "CHECK", "CLONE", "CLUSTER", "COLUMN", "COMMENT", "COMMIT", + "CONNECT", "CONNECTION", "CONSTRAINT", "COPY", "CREATE", "CROSS", "CURRENT", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "DATABASE", "DELETE", "DESC", + "DESCRIBE", "DISTINCT", "DROP", "DYNAMIC", "ELSE", "END", "EXECUTE", + "EXISTS", "EXPLAIN", "EXTERNAL", "FALSE", "FILE", "FIRST", "FLATTEN", + "FOLLOWING", "FOR", "FORMAT", "FROM", "FULL", "FUNCTION", "GET", + "GRANT", "GROUP", "HAVING", "HYBRID", "ICEBERG", "ILIKE", "IMMEDIATE", + "IN", "INCREMENT", "INDEX", "INNER", "INSERT", "INTEGRATION", "INTERSECT", + "INTERVAL", "INTO", "IS", "JOIN", "LAST", "LATERAL", "LEFT", + "LIKE", "LIMIT", "LIST", "LOCALTIME", "LOCALTIMESTAMP", "MASKING", "MATCHED", + "MATERIALIZED", "MERGE", "MINUS", "MONITOR", "NATURAL", "NETWORK", "NOT", + "NULL", "NULLS", "OF", "OFFSET", "ON", "OR", "ORDER", + "ORGANIZATION", "OVER", "OWNERSHIP", "PARTITION", "PIPE", "PIVOT", "POLICY", + "PRECEDING", "PROCEDURE", "PUT", "QUALIFY", "RANGE", "RECURSIVE", "REGEXP", + "REMOVE", "REPLACE", "RESOURCE", "RESUME", "REVOKE", "RIGHT", "RLIKE", + "ROLE", "ROLLBACK", "ROW", "ROWS", "SAMPLE", "SCHEMA", "SECRET", + "SECURE", "SELECT", "SEQUENCE", "SERVICE", "SESSION", "SET", "SHARE", + "SHOW", "SOME", "STAGE", "START", "STATEMENT", "STORAGE", "STREAM", + "STREAMLIT", "SUSPEND", "SWAP", "TABLE", "TABLESAMPLE", "TAG", "TASK", + "TEMPORARY", "THEN", "TO", "TOP", "TRANSIENT", "TRIGGER", "TRUE", + "TRY_CAST", "UNBOUNDED", "UNDROP", "UNION", "UNIQUE", "UNPIVOT", "UNSET", + "UPDATE", "USAGE", "USE", "USING", "VALUES", "VIEW", "WAREHOUSE", + "WHEN", "WHERE", "WINDOW", "WITH" + ], + functions: [ + "ABS", "ACOS", "ACOSH", "ADD_MONTHS", "ANY_VALUE", "APPROX_COUNT_DISTINCT", "APPROX_PERCENTILE", + "APPROX_TOP_K", "APPROXIMATE_JACCARD_INDEX", "APPROXIMATE_SIMILARITY", "ARRAY_AGG", "ARRAY_APPEND", "ARRAY_CAT", "ARRAY_COMPACT", + "ARRAY_CONSTRUCT", "ARRAY_CONSTRUCT_COMPACT", "ARRAY_CONTAINS", "ARRAY_DISTINCT", "ARRAY_EXCEPT", "ARRAY_FLATTEN", "ARRAY_GENERATE_RANGE", + "ARRAY_INSERT", "ARRAY_INTERSECTION", "ARRAY_MAX", "ARRAY_MIN", "ARRAY_POSITION", "ARRAY_PREPEND", "ARRAY_REMOVE", + "ARRAY_REMOVE_AT", "ARRAY_REVERSE", "ARRAY_SIZE", "ARRAY_SLICE", "ARRAY_SORT", "ARRAY_TO_STRING", "ARRAY_UNION_AGG", + "ARRAY_UNIQUE_AGG", "ARRAYS_OVERLAP", "ARRAYS_TO_OBJECT", "ARRAYS_ZIP", "AS_ARRAY", "AS_BINARY", "AS_BOOLEAN", + "AS_CHAR", "AS_VARCHAR", "AS_DATE", "AS_DECIMAL", "AS_NUMBER", "AS_DOUBLE", "AS_REAL", + "AS_INTEGER", "AS_OBJECT", "AS_TIME", "AS_TIMESTAMP", "ASCII", "ASIN", "ASINH", + "ATAN", "ATAN2", "ATANH", "AVG", "BASE64_DECODE_BINARY", "BASE64_DECODE_STRING", "BASE64_ENCODE", + "BITAND", "BITAND_AGG", "BITNOT", "BITOR", "BITOR_AGG", "BITSHIFTLEFT", "BITSHIFTRIGHT", + "BITXOR", "BITXOR_AGG", "BOOLAND", "BOOLAND_AGG", "BOOLNOT", "BOOLOR", "BOOLOR_AGG", + "BOOLXOR", "BOOLXOR_AGG", "CAST", "CBRT", "CEIL", "CHARINDEX", "CHECK_JSON", + "CHECK_XML", "CHR", "CHAR", "COALESCE", "COLLATE", "COLLATION", "COMPRESS", + "CONCAT", "CONCAT_WS", "CONDITIONAL_CHANGE_EVENT", "CONDITIONAL_TRUE_EVENT", "CONTAINS", "CONVERT_TIMEZONE", "CORR", + "COS", "COSH", "COT", "COUNT", "COUNT_IF", "COVAR_POP", "COVAR_SAMP", + "CUME_DIST", "CURRENT_ACCOUNT", "CURRENT_AVAILABLE_ROLES", "CURRENT_CLIENT", "CURRENT_DATABASE", "CURRENT_DATE", "CURRENT_REGION", + "CURRENT_ROLE", "CURRENT_ROLE_TYPE", "CURRENT_SCHEMA", "CURRENT_SCHEMAS", "CURRENT_SECONDARY_ROLES", "CURRENT_SESSION", "CURRENT_STATEMENT", + "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_TRANSACTION", "CURRENT_USER", "CURRENT_VERSION", "CURRENT_WAREHOUSE", "DATE_FROM_PARTS", + "DATE_PART", "DATE_TRUNC", "DATEADD", "DATEDIFF", "DAYNAME", "DECODE", "DECOMPRESS_BINARY", + "DECOMPRESS_STRING", "DECRYPT", "DECRYPT_RAW", "DEGREES", "DENSE_RANK", "DIV0", "DIV0NULL", + "EDITDISTANCE", "ENCRYPT", "ENCRYPT_RAW", "ENDSWITH", "EQUAL_NULL", "EXP", "EXTRACT", + "FACTORIAL", "FIRST_VALUE", "FLATTEN", "FLOOR", "GENERATOR", "GET", "GET_DDL", + "GET_IGNORE_CASE", "GET_PATH", "GETBIT", "GETDATE", "GREATEST", "GREATEST_IGNORE_NULLS", "GROUPING", + "GROUPING_ID", "HASH", "HASH_AGG", "HAVERSINE", "HEX_DECODE_BINARY", "HEX_DECODE_STRING", "HEX_ENCODE", + "HLL", "HLL_ACCUMULATE", "HLL_COMBINE", "HLL_ESTIMATE", "IFF", "IFNULL", "INITCAP", + "INSERT", "IS_ARRAY", "IS_BINARY", "IS_BOOLEAN", "IS_DATE", "IS_DECIMAL", "IS_DOUBLE", + "IS_INTEGER", "IS_NULL_VALUE", "IS_OBJECT", "IS_TIME", "IS_TIMESTAMP", "IS_VARCHAR", "JSON_EXTRACT_PATH_TEXT", + "KURTOSIS", "LAG", "LAST_DAY", "LAST_VALUE", "LEAD", "LEAST", "LEAST_IGNORE_NULLS", + "LEFT", "LENGTH", "LISTAGG", "LN", "LOG", "LOWER", "LPAD", + "LTRIM", "MAP_VALUES", "MAX", "MD5", "MD5_HEX", "MD5_BINARY", "MEDIAN", + "MIN", "MINHASH", "MINHASH_COMBINE", "MINHASH_ESTIMATE", "MOD", "MODE", "MONTHNAME", + "MONTHS_BETWEEN", "NTILE", "NULLIF", "NULLIFZERO", "NVL", "NVL2", "OBJECT_AGG", + "OBJECT_CONSTRUCT", "OBJECT_CONSTRUCT_KEEP_NULL", "OBJECT_DELETE", "OBJECT_INSERT", "OBJECT_KEYS", "OBJECT_PICK", "OBJECT_VALUES", + "OCTET_LENGTH", "PARSE_JSON", "PARSE_URL", "PARSE_XML", "PERCENTILE_CONT", "PERCENTILE_DISC", "PERCENT_RANK", + "PI", "POSITION", "POW", "POWER", "QUOTE", "RADIANS", "RANDOM", + "RANK", "RATIO_TO_REPORT", "REGEXP_COUNT", "REGEXP_INSTR", "REGEXP_LIKE", "REGEXP_REPLACE", "REGEXP_SUBSTR", + "REPEAT", "REPLACE", "RESULT_SCAN", "REVERSE", "RIGHT", "ROUND", "ROW_NUMBER", + "RPAD", "RTRIM", "SEQ1", "SEQ2", "SEQ4", "SEQ8", "SHA1", + "SHA1_BINARY", "SHA1_HEX", "SHA2", "SHA2_BINARY", "SHA2_HEX", "SIGN", "SIN", + "SINH", "SKEW", "SOUNDEX", "SPACE", "SPLIT", "SPLIT_PART", "SPLIT_TO_TABLE", + "SQRT", "SQUARE", "STARTSWITH", "STDDEV", "STDDEV_POP", "STDDEV_SAMP", "STRTOK", + "STRTOK_TO_ARRAY", "STRTOK_SPLIT_TO_TABLE", "SUBSTR", "SUBSTRING", "SUM", "SYSDATE", "TAN", + "TANH", "TIME_FROM_PARTS", "TIME_SLICE", "TIMEADD", "TIMEDIFF", "TIMEOFDAY", "TIMESTAMP_FROM_PARTS", + "TIMESTAMP_LTZ_FROM_PARTS", "TIMESTAMP_NTZ_FROM_PARTS", "TIMESTAMP_TZ_FROM_PARTS", "TIMESTAMPADD", "TIMESTAMPDIFF", "TIMEZONE_OFFSET", "TO_ARRAY", + "TO_BINARY", "TO_BOOLEAN", "TO_CHAR", "TO_DATE", "TO_DECIMAL", "TO_DOUBLE", "TO_GEOGRAPHY", + "TO_INTEGER", "TO_JSON", "TO_NUMBER", "TO_OBJECT", "TO_TIME", "TO_TIMESTAMP", "TO_TIMESTAMP_LTZ", + "TO_TIMESTAMP_NTZ", "TO_TIMESTAMP_TZ", "TO_VARCHAR", "TO_VARIANT", "TO_XML", "TRANSLATE", "TRIM", + "TRUNC", "TRUNCATE", "TRY_BASE64_DECODE_STRING", "TRY_CAST", "TRY_HEX_DECODE_STRING", "TRY_PARSE_JSON", "TRY_TO_BINARY", + "TRY_TO_BOOLEAN", "TRY_TO_DATE", "TRY_TO_DECIMAL", "TRY_TO_DOUBLE", "TRY_TO_NUMBER", "TRY_TO_TIME", "TRY_TO_TIMESTAMP", + "TRY_TO_TIMESTAMP_LTZ", "TRY_TO_TIMESTAMP_NTZ", "TRY_TO_TIMESTAMP_TZ", "TYPEOF", "UNICODE", "UNIFORM", "UPPER", + "URL_DECODE_STRING", "URL_ENCODE", "UUID_STRING", "VAR_POP", "VAR_SAMP", "VARIANCE", "VARIANCE_POP", + "VARIANCE_SAMP", "WEEK", "WEEKISO", "WEEKOFYEAR", "WIDTH_BUCKET", "XMLGET", "YEAR", + "YEAROFWEEK", "ZEROIFNULL", "DAYOFMONTH", "DAYOFWEEK", "DAYOFWEEKISO", "DAYOFYEAR", "HOUR", + "MINUTE", "SECOND", "QUARTER", "MONTH" + ], + dataTypes: [ + "NUMBER", "DECIMAL", "DEC", "NUMERIC", "INT", "INTEGER", "BIGINT", + "SMALLINT", "TINYINT", "BYTEINT", "FLOAT", "FLOAT4", "FLOAT8", "DOUBLE", + "REAL", "DECFLOAT", "VARCHAR", "CHAR", "CHARACTER", "STRING", "TEXT", + "VARCHAR2", "NVARCHAR", "NVARCHAR2", "NCHAR", "BINARY", "VARBINARY", "BOOLEAN", + "DATE", "DATETIME", "TIME", "TIMESTAMP", "TIMESTAMP_LTZ", "TIMESTAMP_NTZ", "TIMESTAMP_TZ", + "VARIANT", "OBJECT", "ARRAY", "MAP", "GEOGRAPHY", "GEOMETRY", "VECTOR", + "FILE" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "text", label: "Explain (Text)", sqlPrefix: "EXPLAIN USING TEXT") + ] + + static let columnTypesByCategory: [String: [String]] = [ + "Number": ["NUMBER", "DECIMAL", "INT", "INTEGER", "BIGINT", "SMALLINT", "FLOAT", "DOUBLE", "REAL"], + "String": ["VARCHAR", "CHAR", "STRING", "TEXT"], + "Binary": ["BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN"], + "Date/Time": ["DATE", "TIME", "TIMESTAMP", "TIMESTAMP_LTZ", "TIMESTAMP_NTZ", "TIMESTAMP_TZ"], + "Semi-structured": ["VARIANT", "OBJECT", "ARRAY"], + "Geospatial": ["GEOGRAPHY", "GEOMETRY"] + ] + + static var statementCompletions: [CompletionEntry] { + [ + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "SELECT DISTINCT", insertText: "SELECT DISTINCT"), + CompletionEntry(label: "INSERT INTO", insertText: "INSERT INTO"), + CompletionEntry(label: "UPDATE", insertText: "UPDATE"), + CompletionEntry(label: "DELETE FROM", insertText: "DELETE FROM"), + CompletionEntry(label: "MERGE INTO", insertText: "MERGE INTO"), + CompletionEntry(label: "COPY INTO", insertText: "COPY INTO"), + CompletionEntry(label: "CREATE OR REPLACE TABLE", insertText: "CREATE OR REPLACE TABLE"), + CompletionEntry(label: "CREATE OR REPLACE VIEW", insertText: "CREATE OR REPLACE VIEW"), + CompletionEntry(label: "CREATE OR REPLACE DYNAMIC TABLE", insertText: "CREATE OR REPLACE DYNAMIC TABLE"), + CompletionEntry(label: "CREATE OR REPLACE MATERIALIZED VIEW", insertText: "CREATE OR REPLACE MATERIALIZED VIEW"), + CompletionEntry(label: "CREATE OR REPLACE STREAM", insertText: "CREATE OR REPLACE STREAM"), + CompletionEntry(label: "CREATE OR REPLACE TASK", insertText: "CREATE OR REPLACE TASK"), + CompletionEntry(label: "CREATE OR REPLACE FILE FORMAT", insertText: "CREATE OR REPLACE FILE FORMAT"), + CompletionEntry(label: "DROP TABLE", insertText: "DROP TABLE"), + CompletionEntry(label: "UNDROP TABLE", insertText: "UNDROP TABLE"), + CompletionEntry(label: "SHOW TABLES", insertText: "SHOW TABLES"), + CompletionEntry(label: "SHOW SCHEMAS", insertText: "SHOW SCHEMAS"), + CompletionEntry(label: "SHOW WAREHOUSES", insertText: "SHOW WAREHOUSES"), + CompletionEntry(label: "DESCRIBE TABLE", insertText: "DESCRIBE TABLE"), + CompletionEntry(label: "USE DATABASE", insertText: "USE DATABASE"), + CompletionEntry(label: "USE SCHEMA", insertText: "USE SCHEMA"), + CompletionEntry(label: "USE WAREHOUSE", insertText: "USE WAREHOUSE"), + CompletionEntry(label: "USE ROLE", insertText: "USE ROLE"), + CompletionEntry(label: "ALTER SESSION SET", insertText: "ALTER SESSION SET"), + CompletionEntry(label: "WHERE", insertText: "WHERE"), + CompletionEntry(label: "GROUP BY", insertText: "GROUP BY"), + CompletionEntry(label: "ORDER BY", insertText: "ORDER BY"), + CompletionEntry(label: "QUALIFY", insertText: "QUALIFY"), + CompletionEntry(label: "LIMIT", insertText: "LIMIT"), + CompletionEntry(label: "JOIN", insertText: "JOIN"), + CompletionEntry(label: "LEFT JOIN", insertText: "LEFT JOIN"), + CompletionEntry(label: "LATERAL FLATTEN", insertText: "LATERAL FLATTEN"), + CompletionEntry(label: "PARSE_JSON", insertText: "PARSE_JSON"), + CompletionEntry(label: "TRY_PARSE_JSON", insertText: "TRY_PARSE_JSON"), + CompletionEntry(label: "GET_PATH", insertText: "GET_PATH"), + CompletionEntry(label: "OBJECT_CONSTRUCT", insertText: "OBJECT_CONSTRUCT"), + CompletionEntry(label: "ARRAY_AGG", insertText: "ARRAY_AGG"), + CompletionEntry(label: "LISTAGG", insertText: "LISTAGG"), + CompletionEntry(label: "DATE_TRUNC", insertText: "DATE_TRUNC"), + CompletionEntry(label: "DATEADD", insertText: "DATEADD"), + CompletionEntry(label: "DATEDIFF", insertText: "DATEDIFF"), + CompletionEntry(label: "CONVERT_TIMEZONE", insertText: "CONVERT_TIMEZONE"), + CompletionEntry(label: "TO_TIMESTAMP", insertText: "TO_TIMESTAMP"), + CompletionEntry(label: "TRY_TO_NUMBER", insertText: "TRY_TO_NUMBER"), + CompletionEntry(label: "ROW_NUMBER", insertText: "ROW_NUMBER"), + CompletionEntry(label: "RESULT_SCAN", insertText: "RESULT_SCAN"), + CompletionEntry(label: "GENERATOR", insertText: "GENERATOR"), + CompletionEntry(label: "ZEROIFNULL", insertText: "ZEROIFNULL"), + CompletionEntry(label: "IFF", insertText: "IFF"), + CompletionEntry(label: "NVL", insertText: "NVL"), + CompletionEntry(label: "DECODE", insertText: "DECODE"), + CompletionEntry(label: "CREATE TABLE", insertText: "CREATE TABLE") + ] + } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + SnowflakePluginDriver(config: config) + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift new file mode 100644 index 000000000..2a0265d9b --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -0,0 +1,391 @@ +// +// SnowflakePluginDriver.swift +// SnowflakeDriverPlugin +// +// PluginDatabaseDriver implementation backed by SnowflakeConnection. +// + +import Foundation +import os +import TableProPluginKit + +final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private let lock = NSLock() + private var _connection: SnowflakeConnection? + private var _serverVersion: String? + + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakePluginDriver") + + private var connection: SnowflakeConnection? { + lock.withLock { _connection } + } + + init(config: DriverConnectionConfig) { + self.config = config + } + + var capabilities: PluginCapabilities { + [.multiSchema, .transactions, .truncateTable, .cancelQuery] + } + + func cancelQuery() throws { + connection?.cancelCurrentQuery() + } + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { true } + var serverVersion: String? { lock.withLock { _serverVersion } } + var currentSchema: String? { connection?.currentSchema } + var parameterStyle: ParameterStyle { .questionMark } + + // MARK: - Lifecycle + + func connect() async throws { + let conn = SnowflakeConnection(config: config) + try await conn.connect() + lock.withLock { _connection = conn } + + if let result = try? await conn.query("SELECT CURRENT_VERSION()"), + let first = result.rows.first?.first, case .text(let version) = first { + lock.withLock { _serverVersion = "Snowflake \(version)" } + } else { + lock.withLock { _serverVersion = "Snowflake" } + } + } + + func disconnect() { + lock.withLock { + _connection?.disconnect() + _connection = nil + } + } + + func ping() async throws { + guard let conn = connection else { throw SnowflakeError.notConnected } + try await conn.ping() + } + + // MARK: - Transactions + + func beginTransaction() async throws { _ = try await execute(query: "BEGIN") } + func commitTransaction() async throws { _ = try await execute(query: "COMMIT") } + func rollbackTransaction() async throws { _ = try await execute(query: "ROLLBACK") } + + // MARK: - Query Execution + + func execute(query: String) async throws -> PluginQueryResult { + guard let conn = connection else { throw SnowflakeError.notConnected } + let startTime = Date() + let result = try await conn.query(query) + let executionTime = Date().timeIntervalSince(startTime) + + if result.columns.isEmpty { + return PluginQueryResult( + columns: ["status"], + columnTypeNames: ["TEXT"], + rows: [[.text("Statement executed")]], + rowsAffected: result.affectedRows, + executionTime: executionTime, + statusMessage: result.statusMessage + ) + } + + return PluginQueryResult( + columns: result.columns.map(\.name), + columnTypeNames: result.columns.map(SnowflakeTypeMapper.displayType), + rows: result.rows.map { row in row.map(Self.cellValue) }, + rowsAffected: result.affectedRows, + executionTime: executionTime, + isTruncated: result.isTruncated, + statusMessage: result.statusMessage + ) + } + + // MARK: - Schema Navigation + + func fetchDatabases() async throws -> [String] { + let result = try await rawQuery("SHOW DATABASES") + return namedValues(in: result, column: "name") + } + + func fetchSchemas() async throws -> [String] { + let result = try await rawQuery("SHOW SCHEMAS") + return namedValues(in: result, column: "name") + } + + func switchDatabase(to database: String) async throws { + guard let conn = connection else { throw SnowflakeError.notConnected } + try await conn.switchDatabase(to: database) + } + + func switchSchema(to schema: String) async throws { + guard let conn = connection else { throw SnowflakeError.notConnected } + try await conn.switchSchema(to: schema) + } + + // MARK: - Schema Introspection + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return [] } + + let sql = """ + SELECT TABLE_NAME, TABLE_TYPE + FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '\(escapeStringLiteral(targetSchema))' + ORDER BY TABLE_NAME + """ + let result = try await rawQuery(sql) + return result.rows.compactMap { row in + guard let name = Self.text(row, 0) else { return nil } + let rawType = (Self.text(row, 1) ?? "BASE TABLE").uppercased() + let type: String + switch rawType { + case "VIEW": type = "VIEW" + case "MATERIALIZED VIEW": type = "MATERIALIZED_VIEW" + default: type = "TABLE" + } + return PluginTableInfo(name: name, type: type, schema: targetSchema) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return [] } + + let primaryKeys = try await fetchPrimaryKeyColumns(table: table, schema: targetSchema, database: database) + + let sql = """ + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COMMENT, + NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH + FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '\(escapeStringLiteral(targetSchema))' + AND TABLE_NAME = '\(escapeStringLiteral(table))' + ORDER BY ORDINAL_POSITION + """ + let result = try await rawQuery(sql) + return result.rows.compactMap { row in + guard let name = Self.text(row, 0) else { return nil } + let dataType = Self.text(row, 1) ?? "TEXT" + let nullable = (Self.text(row, 2) ?? "YES").uppercased() == "YES" + let defaultValue = Self.text(row, 3) + let comment = Self.text(row, 4) + return PluginColumnInfo( + name: name, + dataType: dataType, + isNullable: nullable, + isPrimaryKey: primaryKeys.contains(name.uppercased()), + defaultValue: defaultValue, + comment: comment + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return nil } + let sql = """ + SELECT ROW_COUNT + FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '\(escapeStringLiteral(targetSchema))' + AND TABLE_NAME = '\(escapeStringLiteral(table))' + """ + let result = try await rawQuery(sql) + guard let value = Self.text(result.rows.first ?? [], 0) else { return nil } + return Int(value) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { + return PluginTableMetadata(tableName: table) + } + let sql = """ + SELECT ROW_COUNT, BYTES, COMMENT + FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '\(escapeStringLiteral(targetSchema))' + AND TABLE_NAME = '\(escapeStringLiteral(table))' + """ + let result = try await rawQuery(sql) + let row = result.rows.first ?? [] + let rowCount = Self.text(row, 0).flatMap { Int64($0) } + let bytes = Self.text(row, 1).flatMap { Int64($0) } + let comment = Self.text(row, 2) + return PluginTableMetadata( + tableName: table, + dataSize: bytes, + totalSize: bytes, + rowCount: rowCount, + comment: comment?.isEmpty == true ? nil : comment + ) + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + let isSystem = SnowflakePlugin.systemDatabaseNames.contains(database.uppercased()) + return PluginDatabaseMetadata(name: database, isSystemDatabase: isSystem) + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + try await ddl(objectType: "TABLE", name: table, schema: schema) + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + try await ddl(objectType: "VIEW", name: view, schema: schema) + } + + // MARK: - SQL Generation Helpers + + func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + func escapeStringLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + } + + func castColumnToText(_ column: String) -> String { + "TO_VARCHAR(\(column))" + } + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN USING TEXT \(sql)" + } + + func createViewTemplate() -> String? { + "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func defaultExportQuery(table: String) -> String? { + defaultExportQuery(table: table, schema: nil) + } + + func defaultExportQuery(table: String, schema: String?) -> String? { + "SELECT * FROM \(qualifiedName(table: table, schema: schema))" + } + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + ["TRUNCATE TABLE \(qualifiedName(table: table, schema: schema))"] + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + let suffix = cascade ? " CASCADE" : "" + return "DROP \(objectType.uppercased()) IF EXISTS \(qualifiedName(table: name, schema: schema))\(suffix)" + } + + // MARK: - Streaming + + func streamRows(query: String) -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + do { + guard let conn = self.connection else { throw SnowflakeError.notConnected } + let trimmed = query.replacingOccurrences(of: ";\\s*\\z", with: "", options: .regularExpression) + let result = try await conn.query(trimmed) + continuation.yield(.header(PluginStreamHeader( + columns: result.columns.map(\.name), + columnTypeNames: result.columns.map(SnowflakeTypeMapper.displayType), + estimatedRowCount: result.rows.count + ))) + if !result.rows.isEmpty { + continuation.yield(.rows(result.rows.map { row in row.map(Self.cellValue) })) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + // MARK: - Private Helpers + + private func qualifiedName(table: String, schema: String?) -> String { + let targetSchema = schema ?? connection?.currentSchema + if let database = connection?.currentDatabase, let targetSchema { + return "\(quoteIdentifier(database)).\(quoteIdentifier(targetSchema)).\(quoteIdentifier(table))" + } + if let targetSchema { + return "\(quoteIdentifier(targetSchema)).\(quoteIdentifier(table))" + } + return quoteIdentifier(table) + } + + private func ddl(objectType: String, name: String, schema: String?) async throws -> String { + let fqn = qualifiedNameUnquoted(table: name, schema: schema) + let sql = "SELECT GET_DDL('\(objectType)', '\(escapeStringLiteral(fqn))', true)" + let result = try await rawQuery(sql) + guard let ddl = Self.text(result.rows.first ?? [], 0) else { + throw SnowflakeError.invalidResponse("No DDL returned for \(name)") + } + return ddl + } + + private func qualifiedNameUnquoted(table: String, schema: String?) -> String { + let targetSchema = schema ?? connection?.currentSchema + if let database = connection?.currentDatabase, let targetSchema { + return "\(database).\(targetSchema).\(table)" + } + if let targetSchema { + return "\(targetSchema).\(table)" + } + return table + } + + private func fetchPrimaryKeyColumns(table: String, schema: String, database: String) async throws -> Set { + let fqn = "\(quoteIdentifier(database)).\(quoteIdentifier(schema)).\(quoteIdentifier(table))" + guard let result = try? await rawQuery("SHOW PRIMARY KEYS IN TABLE \(fqn)") else { + return [] + } + guard let columnIndex = result.columns.firstIndex(where: { $0.name.lowercased() == "column_name" }) else { + return [] + } + var keys: Set = [] + for row in result.rows { + if let value = Self.text(row, columnIndex) { + keys.insert(value.uppercased()) + } + } + return keys + } + + private func rawQuery(_ sql: String) async throws -> SnowflakeQueryResult { + guard let conn = connection else { throw SnowflakeError.notConnected } + return try await conn.query(sql) + } + + private func namedValues(in result: SnowflakeQueryResult, column: String) -> [String] { + guard let index = result.columns.firstIndex(where: { $0.name.lowercased() == column.lowercased() }) else { + return [] + } + return result.rows.compactMap { Self.text($0, index) } + } + + private static func cellValue(_ box: PluginCellValueBox) -> PluginCellValue { + switch box { + case .null: return .null + case .text(let value): return .text(value) + } + } + + private static func text(_ row: [PluginCellValueBox], _ index: Int) -> String? { + guard index >= 0, index < row.count else { return nil } + if case .text(let value) = row[index] { return value } + return nil + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift new file mode 100644 index 000000000..d10091d56 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift @@ -0,0 +1,90 @@ +// +// SnowflakeTypeMapper.swift +// SnowflakeDriverPlugin +// +// Maps Snowflake's internal row metadata types to display type names and +// decodes JSON cell values into PluginCellValue. +// + +import Foundation +import TableProPluginKit + +struct SnowflakeColumnMeta: Sendable { + let name: String + let internalType: String + let nullable: Bool + let precision: Int? + let scale: Int? + let length: Int? +} + +enum SnowflakeTypeMapper { + static func displayType(for column: SnowflakeColumnMeta) -> String { + switch column.internalType.lowercased() { + case "fixed": + if let scale = column.scale, scale > 0 { + let precision = column.precision ?? 38 + return "NUMBER(\(precision),\(scale))" + } + return "NUMBER" + case "real": + return "FLOAT" + case "text": + if let length = column.length, length > 0 { + return "VARCHAR(\(length))" + } + return "VARCHAR" + case "binary": + return "BINARY" + case "boolean": + return "BOOLEAN" + case "date": + return "DATE" + case "time": + return "TIME" + case "timestamp_ntz": + return "TIMESTAMP_NTZ" + case "timestamp_ltz": + return "TIMESTAMP_LTZ" + case "timestamp_tz": + return "TIMESTAMP_TZ" + case "variant": + return "VARIANT" + case "object": + return "OBJECT" + case "array": + return "ARRAY" + case "geography": + return "GEOGRAPHY" + case "geometry": + return "GEOMETRY" + default: + return column.internalType.uppercased() + } + } + + /// Snowflake's v1 query protocol returns every cell as a JSON string or null. + static func cellValue(from json: Any?) -> PluginCellValue { + switch json { + case nil, is NSNull: + return .null + case let string as String: + return .text(string) + case let number as NSNumber: + return .text(number.stringValue) + case let bool as Bool: + return .text(bool ? "true" : "false") + default: + return .text(String(describing: json ?? "")) + } + } + + static func columnInfo(from column: SnowflakeColumnMeta, primaryKeys: Set) -> PluginColumnInfo { + PluginColumnInfo( + name: column.name, + dataType: displayType(for: column), + isNullable: column.nullable, + isPrimaryKey: primaryKeys.contains(column.name.uppercased()) + ) + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 927b392db..f5e57cd71 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3BE6FC2F97DB0000611C1F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -75,6 +77,13 @@ 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; + 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; + 703C8C18A7F43E262695F557 /* SnowflakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */; }; + 8163B172478A511171523320 /* SnowflakeTypeMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */; }; + 952FCF406F3DE2575166802B /* SnowflakeBrowserAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */; }; + B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */; }; + EE6F98F0581A8B96C6FE45E9 /* SnowflakeMFATokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -292,6 +301,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnection.swift; sourceTree = ""; }; + 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeMFATokenStore.swift; sourceTree = ""; }; + 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeTypeMapper.swift; sourceTree = ""; }; + 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SnowflakeDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePluginDriver.swift; sourceTree = ""; }; 5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tablepro-mcp"; sourceTree = BUILT_PRODUCTS_DIR; }; 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibSQLDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -343,6 +357,10 @@ 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdStatementGenerator.swift; sourceTree = ""; }; 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; + 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBrowserAuthServer.swift; sourceTree = ""; }; + 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePlugin.swift; sourceTree = ""; }; + F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeAuth.swift; sourceTree = ""; }; + FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -550,6 +568,8 @@ }; 5A32BC012F9D5F1300BAEB5F /* mcp-server */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = "mcp-server"; sourceTree = ""; }; @@ -716,6 +736,8 @@ }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = TableProTests; sourceTree = ""; }; @@ -729,6 +751,8 @@ }; 5AF00A122FB9000000000001 /* TableProUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = TableProUITests; sourceTree = ""; }; @@ -962,9 +986,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C49F7C0CBE979B96ACFB9ABC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1DFD9D7F8FEC30E3858A6B5F /* Plugins */ = { + isa = PBXGroup; + children = ( + 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, + ); + name = Plugins; + sourceTree = ""; + }; 5A05FBC72F3EDF7500819CD7 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -976,9 +1016,9 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( - 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */, - 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */, - 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, + 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */, + 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */, + 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, 5A1091C92EF17EDC0055EA7C /* TablePro */, @@ -1007,6 +1047,7 @@ 5A1091C82EF17EDC0055EA7C /* Products */, 5A05FBC72F3EDF7500819CD7 /* Recovered References */, 5AEA8B482F6808E90040461A /* Frameworks */, + 1DFD9D7F8FEC30E3858A6B5F /* Plugins */, ); sourceTree = ""; }; @@ -1041,11 +1082,12 @@ 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */, 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */, 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */, + 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; }; - 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */ = { + 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */ = { isa = PBXGroup; children = ( 5ABQR00200000000000000A1 /* BigQueryAuth.swift */, @@ -1060,7 +1102,7 @@ path = Plugins/BigQueryDriverPlugin; sourceTree = ""; }; - 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */ = { + 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */ = { isa = PBXGroup; children = ( 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */, @@ -1075,7 +1117,7 @@ path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; }; - 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { + 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */ = { isa = PBXGroup; children = ( 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */, @@ -1091,10 +1133,34 @@ 5AEA8B482F6808E90040461A /* Frameworks */ = { isa = PBXGroup; children = ( + 665759805EBF5C4BF064ADAB /* OS X */, ); name = Frameworks; sourceTree = ""; }; + 665759805EBF5C4BF064ADAB /* OS X */ = { + isa = PBXGroup; + children = ( + ); + name = "OS X"; + sourceTree = ""; + }; + 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */ = { + isa = PBXGroup; + children = ( + F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */, + 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */, + 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */, + FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */, + 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */, + 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */, + 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */, + 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */, + ); + name = SnowflakeDriverPlugin; + path = Plugins/SnowflakeDriverPlugin; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1175,8 +1241,6 @@ 5A32BC012F9D5F1300BAEB5F /* mcp-server */, ); name = "mcp-server"; - packageProductDependencies = ( - ); productName = "mcp-server"; productReference = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; productType = "com.apple.product-type.tool"; @@ -1197,8 +1261,6 @@ 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, ); name = LibSQLDriverPlugin; - packageProductDependencies = ( - ); productName = LibSQLDriverPlugin; productReference = 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1624,8 +1686,6 @@ dependencies = ( ); name = BigQueryDriverPlugin; - packageProductDependencies = ( - ); productName = BigQueryDriverPlugin; productReference = 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1643,8 +1703,6 @@ dependencies = ( ); name = DynamoDBDriverPlugin; - packageProductDependencies = ( - ); productName = DynamoDBDriverPlugin; productReference = 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1665,8 +1723,6 @@ 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, ); name = CloudflareD1DriverPlugin; - packageProductDependencies = ( - ); productName = CloudflareD1DriverPlugin; productReference = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1684,8 +1740,6 @@ dependencies = ( ); name = EtcdDriverPlugin; - packageProductDependencies = ( - ); productName = EtcdDriverPlugin; productReference = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1711,6 +1765,23 @@ productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */; + buildPhases = ( + 329606C9C47DD1FA4F8F5350 /* Sources */, + C49F7C0CBE979B96ACFB9ABC /* Frameworks */, + 0C6BE79D05A236139BEB2632 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SnowflakeDriverPlugin; + productName = SnowflakeDriverPlugin; + productReference = 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1806,11 +1877,11 @@ mainGroup = 5A1091BE2EF17EDC0055EA7C; minimizedProjectReferenceProxies = 1; packageReferences = ( - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */, + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */, 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, ); preferredProjectObjectVersion = 77; @@ -1846,11 +1917,19 @@ 5A3BE6F72F97DA8100611C1F /* LibSQLDriverPlugin */, 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */, 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */, + FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 0C6BE79D05A236139BEB2632 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A1091C52EF17EDC0055EA7C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2043,6 +2122,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 329606C9C47DD1FA4F8F5350 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */, + 952FCF406F3DE2575166802B /* SnowflakeBrowserAuthServer.swift in Sources */, + B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */, + 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */, + 703C8C18A7F43E262695F557 /* SnowflakePlugin.swift in Sources */, + 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */, + 8163B172478A511171523320 /* SnowflakeTypeMapper.swift in Sources */, + EE6F98F0581A8B96C6FE45E9 /* SnowflakeMFATokenStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A1091C32EF17EDC0055EA7C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2377,6 +2471,55 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 2804EF6483DBBF8D2A3510F9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/SnowflakeDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SnowflakePlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SnowflakeDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 3058898FDC3763CFD887A1E1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/SnowflakeDriverPlugin/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SnowflakePlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SnowflakeDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5A1091D02EF17EDC0055EA7C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4151,6 +4294,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3058898FDC3763CFD887A1E1 /* Release */, + 2804EF6483DBBF8D2A3510F9 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5A1091C22EF17EDC0055EA7C /* Build configuration list for PBXProject "TablePro" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4415,15 +4567,15 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditLanguages; }; - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */ = { + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditSourceEditor; }; - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = { + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/TableProCore; }; @@ -4490,7 +4642,7 @@ }; 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; productName = TableProMSSQLCore; }; /* End XCSwiftPackageProductDependency section */ diff --git a/TablePro/Assets.xcassets/snowflake-icon.imageset/Contents.json b/TablePro/Assets.xcassets/snowflake-icon.imageset/Contents.json new file mode 100644 index 000000000..a67b40c94 --- /dev/null +++ b/TablePro/Assets.xcassets/snowflake-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "snowflake.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/TablePro/Assets.xcassets/snowflake-icon.imageset/snowflake.svg b/TablePro/Assets.xcassets/snowflake-icon.imageset/snowflake.svg new file mode 100644 index 000000000..551e9d821 --- /dev/null +++ b/TablePro/Assets.xcassets/snowflake-icon.imageset/snowflake.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 6aa848f4a..9909d7742 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -339,8 +339,315 @@ extension PluginMetadataRegistry { category: .analytical, tagline: String(localized: "Google Cloud serverless data warehouse") ) + )), + ("Snowflake", PluginMetadataSnapshot( + displayName: "Snowflake", iconName: "snowflake-icon", defaultPort: 443, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "text", label: "Explain (Text)", sqlPrefix: "EXPLAIN USING TEXT") + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#29B5E8", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "PUBLIC", + defaultGroupName: "default", + tableEntityName: "Tables", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["SNOWFLAKE", "SNOWFLAKE_SAMPLE_DATA"], + systemSchemaNames: ["INFORMATION_SCHEMA"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "ACCOUNT", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", + "AT", "BEFORE", "BEGIN", "BETWEEN", "BY", "CALL", "CASE", + "CAST", "CHECK", "CLONE", "CLUSTER", "COLUMN", "COMMENT", "COMMIT", + "CONNECT", "CONNECTION", "CONSTRAINT", "COPY", "CREATE", "CROSS", "CURRENT", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "DATABASE", "DELETE", "DESC", + "DESCRIBE", "DISTINCT", "DROP", "DYNAMIC", "ELSE", "END", "EXECUTE", + "EXISTS", "EXPLAIN", "EXTERNAL", "FALSE", "FILE", "FIRST", "FLATTEN", + "FOLLOWING", "FOR", "FORMAT", "FROM", "FULL", "FUNCTION", "GET", + "GRANT", "GROUP", "HAVING", "HYBRID", "ICEBERG", "ILIKE", "IMMEDIATE", + "IN", "INCREMENT", "INDEX", "INNER", "INSERT", "INTEGRATION", "INTERSECT", + "INTERVAL", "INTO", "IS", "JOIN", "LAST", "LATERAL", "LEFT", + "LIKE", "LIMIT", "LIST", "LOCALTIME", "LOCALTIMESTAMP", "MASKING", "MATCHED", + "MATERIALIZED", "MERGE", "MINUS", "MONITOR", "NATURAL", "NETWORK", "NOT", + "NULL", "NULLS", "OF", "OFFSET", "ON", "OR", "ORDER", + "ORGANIZATION", "OVER", "OWNERSHIP", "PARTITION", "PIPE", "PIVOT", "POLICY", + "PRECEDING", "PROCEDURE", "PUT", "QUALIFY", "RANGE", "RECURSIVE", "REGEXP", + "REMOVE", "REPLACE", "RESOURCE", "RESUME", "REVOKE", "RIGHT", "RLIKE", + "ROLE", "ROLLBACK", "ROW", "ROWS", "SAMPLE", "SCHEMA", "SECRET", + "SECURE", "SELECT", "SEQUENCE", "SERVICE", "SESSION", "SET", "SHARE", + "SHOW", "SOME", "STAGE", "START", "STATEMENT", "STORAGE", "STREAM", + "STREAMLIT", "SUSPEND", "SWAP", "TABLE", "TABLESAMPLE", "TAG", "TASK", + "TEMPORARY", "THEN", "TO", "TOP", "TRANSIENT", "TRIGGER", "TRUE", + "TRY_CAST", "UNBOUNDED", "UNDROP", "UNION", "UNIQUE", "UNPIVOT", "UNSET", + "UPDATE", "USAGE", "USE", "USING", "VALUES", "VIEW", "WAREHOUSE", + "WHEN", "WHERE", "WINDOW", "WITH" + ], + functions: [ + "ABS", "ACOS", "ACOSH", "ADD_MONTHS", "ANY_VALUE", "APPROX_COUNT_DISTINCT", "APPROX_PERCENTILE", + "APPROX_TOP_K", "APPROXIMATE_JACCARD_INDEX", "APPROXIMATE_SIMILARITY", "ARRAY_AGG", "ARRAY_APPEND", "ARRAY_CAT", "ARRAY_COMPACT", + "ARRAY_CONSTRUCT", "ARRAY_CONSTRUCT_COMPACT", "ARRAY_CONTAINS", "ARRAY_DISTINCT", "ARRAY_EXCEPT", "ARRAY_FLATTEN", "ARRAY_GENERATE_RANGE", + "ARRAY_INSERT", "ARRAY_INTERSECTION", "ARRAY_MAX", "ARRAY_MIN", "ARRAY_POSITION", "ARRAY_PREPEND", "ARRAY_REMOVE", + "ARRAY_REMOVE_AT", "ARRAY_REVERSE", "ARRAY_SIZE", "ARRAY_SLICE", "ARRAY_SORT", "ARRAY_TO_STRING", "ARRAY_UNION_AGG", + "ARRAY_UNIQUE_AGG", "ARRAYS_OVERLAP", "ARRAYS_TO_OBJECT", "ARRAYS_ZIP", "AS_ARRAY", "AS_BINARY", "AS_BOOLEAN", + "AS_CHAR", "AS_VARCHAR", "AS_DATE", "AS_DECIMAL", "AS_NUMBER", "AS_DOUBLE", "AS_REAL", + "AS_INTEGER", "AS_OBJECT", "AS_TIME", "AS_TIMESTAMP", "ASCII", "ASIN", "ASINH", + "ATAN", "ATAN2", "ATANH", "AVG", "BASE64_DECODE_BINARY", "BASE64_DECODE_STRING", "BASE64_ENCODE", + "BITAND", "BITAND_AGG", "BITNOT", "BITOR", "BITOR_AGG", "BITSHIFTLEFT", "BITSHIFTRIGHT", + "BITXOR", "BITXOR_AGG", "BOOLAND", "BOOLAND_AGG", "BOOLNOT", "BOOLOR", "BOOLOR_AGG", + "BOOLXOR", "BOOLXOR_AGG", "CAST", "CBRT", "CEIL", "CHARINDEX", "CHECK_JSON", + "CHECK_XML", "CHR", "CHAR", "COALESCE", "COLLATE", "COLLATION", "COMPRESS", + "CONCAT", "CONCAT_WS", "CONDITIONAL_CHANGE_EVENT", "CONDITIONAL_TRUE_EVENT", "CONTAINS", "CONVERT_TIMEZONE", "CORR", + "COS", "COSH", "COT", "COUNT", "COUNT_IF", "COVAR_POP", "COVAR_SAMP", + "CUME_DIST", "CURRENT_ACCOUNT", "CURRENT_AVAILABLE_ROLES", "CURRENT_CLIENT", "CURRENT_DATABASE", "CURRENT_DATE", "CURRENT_REGION", + "CURRENT_ROLE", "CURRENT_ROLE_TYPE", "CURRENT_SCHEMA", "CURRENT_SCHEMAS", "CURRENT_SECONDARY_ROLES", "CURRENT_SESSION", "CURRENT_STATEMENT", + "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_TRANSACTION", "CURRENT_USER", "CURRENT_VERSION", "CURRENT_WAREHOUSE", "DATE_FROM_PARTS", + "DATE_PART", "DATE_TRUNC", "DATEADD", "DATEDIFF", "DAYNAME", "DECODE", "DECOMPRESS_BINARY", + "DECOMPRESS_STRING", "DECRYPT", "DECRYPT_RAW", "DEGREES", "DENSE_RANK", "DIV0", "DIV0NULL", + "EDITDISTANCE", "ENCRYPT", "ENCRYPT_RAW", "ENDSWITH", "EQUAL_NULL", "EXP", "EXTRACT", + "FACTORIAL", "FIRST_VALUE", "FLATTEN", "FLOOR", "GENERATOR", "GET", "GET_DDL", + "GET_IGNORE_CASE", "GET_PATH", "GETBIT", "GETDATE", "GREATEST", "GREATEST_IGNORE_NULLS", "GROUPING", + "GROUPING_ID", "HASH", "HASH_AGG", "HAVERSINE", "HEX_DECODE_BINARY", "HEX_DECODE_STRING", "HEX_ENCODE", + "HLL", "HLL_ACCUMULATE", "HLL_COMBINE", "HLL_ESTIMATE", "IFF", "IFNULL", "INITCAP", + "INSERT", "IS_ARRAY", "IS_BINARY", "IS_BOOLEAN", "IS_DATE", "IS_DECIMAL", "IS_DOUBLE", + "IS_INTEGER", "IS_NULL_VALUE", "IS_OBJECT", "IS_TIME", "IS_TIMESTAMP", "IS_VARCHAR", "JSON_EXTRACT_PATH_TEXT", + "KURTOSIS", "LAG", "LAST_DAY", "LAST_VALUE", "LEAD", "LEAST", "LEAST_IGNORE_NULLS", + "LEFT", "LENGTH", "LISTAGG", "LN", "LOG", "LOWER", "LPAD", + "LTRIM", "MAP_VALUES", "MAX", "MD5", "MD5_HEX", "MD5_BINARY", "MEDIAN", + "MIN", "MINHASH", "MINHASH_COMBINE", "MINHASH_ESTIMATE", "MOD", "MODE", "MONTHNAME", + "MONTHS_BETWEEN", "NTILE", "NULLIF", "NULLIFZERO", "NVL", "NVL2", "OBJECT_AGG", + "OBJECT_CONSTRUCT", "OBJECT_CONSTRUCT_KEEP_NULL", "OBJECT_DELETE", "OBJECT_INSERT", "OBJECT_KEYS", "OBJECT_PICK", "OBJECT_VALUES", + "OCTET_LENGTH", "PARSE_JSON", "PARSE_URL", "PARSE_XML", "PERCENTILE_CONT", "PERCENTILE_DISC", "PERCENT_RANK", + "PI", "POSITION", "POW", "POWER", "QUOTE", "RADIANS", "RANDOM", + "RANK", "RATIO_TO_REPORT", "REGEXP_COUNT", "REGEXP_INSTR", "REGEXP_LIKE", "REGEXP_REPLACE", "REGEXP_SUBSTR", + "REPEAT", "REPLACE", "RESULT_SCAN", "REVERSE", "RIGHT", "ROUND", "ROW_NUMBER", + "RPAD", "RTRIM", "SEQ1", "SEQ2", "SEQ4", "SEQ8", "SHA1", + "SHA1_BINARY", "SHA1_HEX", "SHA2", "SHA2_BINARY", "SHA2_HEX", "SIGN", "SIN", + "SINH", "SKEW", "SOUNDEX", "SPACE", "SPLIT", "SPLIT_PART", "SPLIT_TO_TABLE", + "SQRT", "SQUARE", "STARTSWITH", "STDDEV", "STDDEV_POP", "STDDEV_SAMP", "STRTOK", + "STRTOK_TO_ARRAY", "STRTOK_SPLIT_TO_TABLE", "SUBSTR", "SUBSTRING", "SUM", "SYSDATE", "TAN", + "TANH", "TIME_FROM_PARTS", "TIME_SLICE", "TIMEADD", "TIMEDIFF", "TIMEOFDAY", "TIMESTAMP_FROM_PARTS", + "TIMESTAMP_LTZ_FROM_PARTS", "TIMESTAMP_NTZ_FROM_PARTS", "TIMESTAMP_TZ_FROM_PARTS", "TIMESTAMPADD", "TIMESTAMPDIFF", "TIMEZONE_OFFSET", "TO_ARRAY", + "TO_BINARY", "TO_BOOLEAN", "TO_CHAR", "TO_DATE", "TO_DECIMAL", "TO_DOUBLE", "TO_GEOGRAPHY", + "TO_INTEGER", "TO_JSON", "TO_NUMBER", "TO_OBJECT", "TO_TIME", "TO_TIMESTAMP", "TO_TIMESTAMP_LTZ", + "TO_TIMESTAMP_NTZ", "TO_TIMESTAMP_TZ", "TO_VARCHAR", "TO_VARIANT", "TO_XML", "TRANSLATE", "TRIM", + "TRUNC", "TRUNCATE", "TRY_BASE64_DECODE_STRING", "TRY_CAST", "TRY_HEX_DECODE_STRING", "TRY_PARSE_JSON", "TRY_TO_BINARY", + "TRY_TO_BOOLEAN", "TRY_TO_DATE", "TRY_TO_DECIMAL", "TRY_TO_DOUBLE", "TRY_TO_NUMBER", "TRY_TO_TIME", "TRY_TO_TIMESTAMP", + "TRY_TO_TIMESTAMP_LTZ", "TRY_TO_TIMESTAMP_NTZ", "TRY_TO_TIMESTAMP_TZ", "TYPEOF", "UNICODE", "UNIFORM", "UPPER", + "URL_DECODE_STRING", "URL_ENCODE", "UUID_STRING", "VAR_POP", "VAR_SAMP", "VARIANCE", "VARIANCE_POP", + "VARIANCE_SAMP", "WEEK", "WEEKISO", "WEEKOFYEAR", "WIDTH_BUCKET", "XMLGET", "YEAR", + "YEAROFWEEK", "ZEROIFNULL", "DAYOFMONTH", "DAYOFWEEK", "DAYOFWEEKISO", "DAYOFYEAR", "HOUR", + "MINUTE", "SECOND", "QUARTER", "MONTH" + ], + dataTypes: [ + "NUMBER", "DECIMAL", "DEC", "NUMERIC", "INT", "INTEGER", "BIGINT", + "SMALLINT", "TINYINT", "BYTEINT", "FLOAT", "FLOAT4", "FLOAT8", "DOUBLE", + "REAL", "DECFLOAT", "VARCHAR", "CHAR", "CHARACTER", "STRING", "TEXT", + "VARCHAR2", "NVARCHAR", "NVARCHAR2", "NCHAR", "BINARY", "VARBINARY", "BOOLEAN", + "DATE", "DATETIME", "TIME", "TIMESTAMP", "TIMESTAMP_LTZ", "TIMESTAMP_NTZ", "TIMESTAMP_TZ", + "VARIANT", "OBJECT", "ARRAY", "MAP", "GEOGRAPHY", "GEOMETRY", "VECTOR", + "FILE" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ), + statementCompletions: [ + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "SELECT DISTINCT", insertText: "SELECT DISTINCT"), + CompletionEntry(label: "INSERT INTO", insertText: "INSERT INTO"), + CompletionEntry(label: "UPDATE", insertText: "UPDATE"), + CompletionEntry(label: "DELETE FROM", insertText: "DELETE FROM"), + CompletionEntry(label: "MERGE INTO", insertText: "MERGE INTO"), + CompletionEntry(label: "COPY INTO", insertText: "COPY INTO"), + CompletionEntry(label: "CREATE OR REPLACE TABLE", insertText: "CREATE OR REPLACE TABLE"), + CompletionEntry(label: "CREATE OR REPLACE VIEW", insertText: "CREATE OR REPLACE VIEW"), + CompletionEntry(label: "CREATE OR REPLACE DYNAMIC TABLE", insertText: "CREATE OR REPLACE DYNAMIC TABLE"), + CompletionEntry(label: "CREATE OR REPLACE MATERIALIZED VIEW", insertText: "CREATE OR REPLACE MATERIALIZED VIEW"), + CompletionEntry(label: "CREATE OR REPLACE STREAM", insertText: "CREATE OR REPLACE STREAM"), + CompletionEntry(label: "CREATE OR REPLACE TASK", insertText: "CREATE OR REPLACE TASK"), + CompletionEntry(label: "CREATE OR REPLACE FILE FORMAT", insertText: "CREATE OR REPLACE FILE FORMAT"), + CompletionEntry(label: "DROP TABLE", insertText: "DROP TABLE"), + CompletionEntry(label: "UNDROP TABLE", insertText: "UNDROP TABLE"), + CompletionEntry(label: "SHOW TABLES", insertText: "SHOW TABLES"), + CompletionEntry(label: "SHOW SCHEMAS", insertText: "SHOW SCHEMAS"), + CompletionEntry(label: "SHOW WAREHOUSES", insertText: "SHOW WAREHOUSES"), + CompletionEntry(label: "DESCRIBE TABLE", insertText: "DESCRIBE TABLE"), + CompletionEntry(label: "USE DATABASE", insertText: "USE DATABASE"), + CompletionEntry(label: "USE SCHEMA", insertText: "USE SCHEMA"), + CompletionEntry(label: "USE WAREHOUSE", insertText: "USE WAREHOUSE"), + CompletionEntry(label: "USE ROLE", insertText: "USE ROLE"), + CompletionEntry(label: "ALTER SESSION SET", insertText: "ALTER SESSION SET"), + CompletionEntry(label: "WHERE", insertText: "WHERE"), + CompletionEntry(label: "GROUP BY", insertText: "GROUP BY"), + CompletionEntry(label: "ORDER BY", insertText: "ORDER BY"), + CompletionEntry(label: "QUALIFY", insertText: "QUALIFY"), + CompletionEntry(label: "LIMIT", insertText: "LIMIT"), + CompletionEntry(label: "JOIN", insertText: "JOIN"), + CompletionEntry(label: "LEFT JOIN", insertText: "LEFT JOIN"), + CompletionEntry(label: "LATERAL FLATTEN", insertText: "LATERAL FLATTEN"), + CompletionEntry(label: "PARSE_JSON", insertText: "PARSE_JSON"), + CompletionEntry(label: "TRY_PARSE_JSON", insertText: "TRY_PARSE_JSON"), + CompletionEntry(label: "GET_PATH", insertText: "GET_PATH"), + CompletionEntry(label: "OBJECT_CONSTRUCT", insertText: "OBJECT_CONSTRUCT"), + CompletionEntry(label: "ARRAY_AGG", insertText: "ARRAY_AGG"), + CompletionEntry(label: "LISTAGG", insertText: "LISTAGG"), + CompletionEntry(label: "DATE_TRUNC", insertText: "DATE_TRUNC"), + CompletionEntry(label: "DATEADD", insertText: "DATEADD"), + CompletionEntry(label: "DATEDIFF", insertText: "DATEDIFF"), + CompletionEntry(label: "CONVERT_TIMEZONE", insertText: "CONVERT_TIMEZONE"), + CompletionEntry(label: "TO_TIMESTAMP", insertText: "TO_TIMESTAMP"), + CompletionEntry(label: "TRY_TO_NUMBER", insertText: "TRY_TO_NUMBER"), + CompletionEntry(label: "ROW_NUMBER", insertText: "ROW_NUMBER"), + CompletionEntry(label: "RESULT_SCAN", insertText: "RESULT_SCAN"), + CompletionEntry(label: "GENERATOR", insertText: "GENERATOR"), + CompletionEntry(label: "ZEROIFNULL", insertText: "ZEROIFNULL"), + CompletionEntry(label: "IFF", insertText: "IFF"), + CompletionEntry(label: "NVL", insertText: "NVL"), + CompletionEntry(label: "DECODE", insertText: "DECODE") + ], + columnTypesByCategory: [ + "Number": ["NUMBER", "DECIMAL", "INT", "INTEGER", "BIGINT", "SMALLINT", "FLOAT", "DOUBLE", "REAL"], + "String": ["VARCHAR", "CHAR", "STRING", "TEXT"], + "Binary": ["BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN"], + "Date/Time": ["DATE", "TIME", "TIMESTAMP", "TIMESTAMP_LTZ", "TIMESTAMP_NTZ", "TIMESTAMP_TZ"], + "Semi-structured": ["VARIANT", "OBJECT", "ARRAY"], + "Geospatial": ["GEOGRAPHY", "GEOMETRY"] + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: snowflakeConnectionFields(), + category: .analytical, + tagline: String(localized: "Cloud data warehouse") + ) )) ] } // swiftlint:enable function_body_length } + +func snowflakeConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: "snowflakeAccount", + label: String(localized: "Account Identifier"), + placeholder: "xy12345.us-east-1 or myorg-myaccount", + required: true, + section: .authentication + ), + ConnectionField( + id: "snowflakeAuthMethod", + label: String(localized: "Auth Method"), + defaultValue: "password", + fieldType: .dropdown(options: [ + .init(value: "password", label: "Username & Password"), + .init(value: "keyPair", label: "Key Pair (.p8)"), + .init(value: "externalBrowser", label: "Single Sign-On (Browser)"), + .init(value: "oauth", label: "OAuth Token") + ]), + section: .authentication + ), + ConnectionField( + id: "snowflakeUser", + label: String(localized: "Username"), + placeholder: "JANE_DOE", + section: .authentication, + visibleWhen: FieldVisibilityRule( + fieldId: "snowflakeAuthMethod", + values: ["password", "keyPair", "externalBrowser"] + ) + ), + ConnectionField( + id: "snowflakePassword", + label: String(localized: "Password"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["password"]) + ), + ConnectionField( + id: "snowflakeMFAPasscode", + label: String(localized: "MFA Passcode (TOTP)"), + placeholder: "Current code from your authenticator, if MFA is enforced", + fieldType: .secure, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["password"]) + ), + ConnectionField( + id: "snowflakePrivateKeyPath", + label: String(localized: "Private Key File"), + placeholder: "~/.snowflake/rsa_key.p8", + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["keyPair"]) + ), + ConnectionField( + id: "snowflakePrivateKeyPassphrase", + label: String(localized: "Private Key Passphrase"), + placeholder: "Optional", + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["keyPair"]) + ), + ConnectionField( + id: "snowflakeOAuthToken", + label: String(localized: "OAuth Token"), + fieldType: .secure, + section: .authentication, + hidesPassword: true, + visibleWhen: FieldVisibilityRule(fieldId: "snowflakeAuthMethod", values: ["oauth"]) + ), + ConnectionField( + id: "snowflakeWarehouse", + label: String(localized: "Warehouse"), + placeholder: "COMPUTE_WH", + section: .authentication + ), + ConnectionField( + id: "snowflakeSchema", + label: String(localized: "Schema"), + placeholder: "PUBLIC", + section: .authentication + ), + ConnectionField( + id: "snowflakeRole", + label: String(localized: "Role"), + placeholder: "Optional (e.g. SYSADMIN)", + section: .advanced + ), + ConnectionField( + id: "snowflakeConnectionName", + label: String(localized: "CLI Connection Name"), + placeholder: "Optional: name in ~/.snowflake/connections.toml", + section: .advanced + ) + ] +} diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx new file mode 100644 index 000000000..9964d1717 --- /dev/null +++ b/docs/databases/snowflake.mdx @@ -0,0 +1,93 @@ +--- +title: Snowflake +description: Connect to Snowflake with password, key-pair, SSO, or OAuth auth +--- + +# Snowflake Connections + +TablePro connects to Snowflake over its connector REST protocol (the same API used by the official drivers). Browse databases, schemas, and tables, run Snowflake SQL, and edit rows in the data grid. The plugin auto-installs when you pick **Snowflake**, or grab it from **Settings** > **Plugins** > **Browse**. + +## Quick Setup + +Click **New Connection**, select **Snowflake**, enter your **Account Identifier**, pick an auth method, and connect. + +The account identifier is the part before `.snowflakecomputing.com`: + +- Org-account format: `myorg-myaccount` +- Legacy locator format: `xy12345.us-east-1` (locator + region) + +## Authentication + +**Username & Password**: Standard Snowflake login with your username and password. + +**Key Pair (.p8)**: Point to an RSA private key file in PKCS#8 (`.p8`) format and, if the key is encrypted, supply its passphrase. Register the matching public key on your user first: + +```sql +ALTER USER jane_doe SET RSA_PUBLIC_KEY='MIIBIjANBgkq...'; +``` + +**Single Sign-On (Browser)**: Authenticate through your identity provider (Okta, Entra ID, etc.) in the browser. TablePro opens your IdP's login page and captures the response automatically (`authenticator=externalbrowser`). + +**OAuth Token**: Paste a valid OAuth access token issued for your Snowflake account. + + +Already use the Snowflake CLI? Set **CLI Connection Name** (Advanced) to a connection defined in `~/.snowflake/connections.toml` and TablePro fills in the account, user, auth method, warehouse, database, schema, and role for you. + + +## Connection Settings + +| Field | Required | Notes | +|-------|----------|-------| +| **Account Identifier** | Yes | e.g. `myorg-myaccount` or `xy12345.us-east-1` | +| **Auth Method** | Yes | Password, Key Pair, SSO (Browser), or OAuth Token | +| **Username** | Most | Required for password, key-pair, and SSO | +| **Password** | Password only | Account password | +| **MFA Passcode (TOTP)** | If MFA enforced | Current code from your authenticator app; refresh it before each connect | +| **Private Key File** | Key Pair only | Path to an RSA `.p8` key (PKCS#8) | +| **Private Key Passphrase** | Key Pair only | Only if the key is encrypted | +| **OAuth Token** | OAuth only | A valid Snowflake OAuth access token | +| **Warehouse** | No | Compute warehouse, e.g. `COMPUTE_WH` | +| **Database** | No | Default database | +| **Schema** | No | Default schema (defaults to `PUBLIC`) | +| **Role** | No | Session role (Advanced), e.g. `SYSADMIN` | +| **CLI Connection Name** | No | Name in `~/.snowflake/connections.toml` (Advanced) | + +## Features + +**Database & Schema Browsing**: The sidebar groups objects by database and schema. Switch the active database or schema with `USE DATABASE` / `USE SCHEMA`, or by selecting them in the UI. + +**Snowflake SQL** ([docs](https://docs.snowflake.com/en/sql-reference-commands)): + +```sql +SELECT * FROM my_db.public.orders LIMIT 100; + +-- Semi-structured data +SELECT value:id::int AS id, value:name::string AS name +FROM raw, LATERAL FLATTEN(input => raw.payload); + +-- Time travel +SELECT * FROM events AT(OFFSET => -60*5); +``` + +**Data Types**: NUMBER, FLOAT, VARCHAR, BINARY, BOOLEAN, DATE, TIME, TIMESTAMP_NTZ/LTZ/TZ, VARIANT, OBJECT, ARRAY, GEOGRAPHY, GEOMETRY. Semi-structured values display as JSON text. + +**DDL**: View object DDL via `GET_DDL`. Create or replace views, truncate and drop tables. + +**Export**: CSV, JSON, SQL, XLSX formats. + +## Troubleshooting + +**Auth failed**: Verify the account identifier and credentials. For key-pair auth, confirm the public key is registered on the user and the passphrase is correct. + +**MFA with TOTP is required (394508)**: Your account enforces MFA on password logins. Enter a current code from your authenticator in **MFA Passcode (TOTP)** — it expires quickly, so refresh it right before connecting. Key-pair or SSO auth avoids the prompt entirely. + +**JWT token is invalid**: The account name in the JWT issuer must match your account locator. Make sure your machine clock is accurate (JWTs are time-sensitive). + +**No databases listed**: Ensure the connecting role has `USAGE` on the databases and a warehouse is set so metadata queries can run. + +**Browser SSO didn't return**: The login must complete within 2 minutes; the local callback listens on `127.0.0.1`. Disable popup blockers and retry. + +## Limitations + +- No SSH tunneling (HTTPS only to the Snowflake endpoint) +- Foreign keys are informational only and are not displayed diff --git a/docs/docs.json b/docs/docs.json index f0ade3654..9171f1e86 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,7 @@ "pages": [ "databases/dynamodb", "databases/bigquery", + "databases/snowflake", "databases/cloudflare-d1" ] }, diff --git a/scripts/add-snowflake-to-xcode.rb b/scripts/add-snowflake-to-xcode.rb new file mode 100644 index 000000000..cda7bb47f --- /dev/null +++ b/scripts/add-snowflake-to-xcode.rb @@ -0,0 +1,73 @@ +#!/usr/bin/env ruby +# Adds the SnowflakeDriverPlugin .tableplugin target to the Xcode project, +# cloning the BigQueryDriverPlugin target's build settings so the bundle is +# produced and signed identically. Idempotent. +# Usage: ruby scripts/add-snowflake-to-xcode.rb + +require 'xcodeproj' + +project_path = File.join(__dir__, '..', 'TablePro.xcodeproj') +proj = Xcodeproj::Project.open(project_path) + +TARGET_NAME = 'SnowflakeDriverPlugin' +PLUGIN_DIR = 'Plugins/SnowflakeDriverPlugin' +BUNDLE_ID = 'com.TablePro.SnowflakeDriverPlugin' +PRINCIPAL_CLASS = '$(PRODUCT_MODULE_NAME).SnowflakePlugin' + +if proj.targets.any? { |t| t.name == TARGET_NAME } + puts "⏭️ Target #{TARGET_NAME} already exists" + exit 0 +end + +template = proj.targets.find { |t| t.name == 'BigQueryDriverPlugin' } +abort 'BigQueryDriverPlugin target not found (needed as a template)' unless template + +framework_ref = proj.files.find { |f| f.display_name == 'TableProPluginKit.framework' } +abort 'TableProPluginKit.framework reference not found' unless framework_ref + +target = proj.new_target(:bundle, TARGET_NAME, :osx, '14.0', proj.products_group, :swift) + +# Mirror the product wrapper so build/CI scripts find .tableplugin +product = target.product_reference +product.path = "#{TARGET_NAME}.tableplugin" +product.explicit_file_type = 'wrapper.cfbundle' +product.include_in_index = '0' + +# Clone the template's build settings, then override identity-specific keys +template.build_configurations.each do |template_cfg| + cfg = target.build_configurations.find { |c| c.name == template_cfg.name } + next unless cfg + settings = template_cfg.build_settings.dup + settings['INFOPLIST_FILE'] = "#{PLUGIN_DIR}/Info.plist" + settings['PRODUCT_BUNDLE_IDENTIFIER'] = BUNDLE_ID + settings['INFOPLIST_KEY_NSPrincipalClass'] = PRINCIPAL_CLASS + settings['PRODUCT_NAME'] = '$(TARGET_NAME)' + cfg.build_settings = settings +end + +# Explicit (non-synchronized) group holding the plugin sources +group = proj.main_group.find_subpath(PLUGIN_DIR, true) +group.set_source_tree('') +group.path = PLUGIN_DIR + +source_files = Dir.glob(File.join(__dir__, '..', PLUGIN_DIR, '*.swift')).sort +abort 'No Swift sources found for the Snowflake plugin' if source_files.empty? + +refs = source_files.map { |path| group.new_reference(File.basename(path)) } +target.add_file_references(refs) + +# new_target(:osx) auto-links Cocoa.framework; the other driver plugins link +# only TableProPluginKit, so clear the phase first to match them exactly. +target.frameworks_build_phase.files_references.dup.each do |ref| + target.frameworks_build_phase.remove_file_reference(ref) +end +# new_target also creates a stray Cocoa.framework file reference; drop it so the +# project stays identical to the other hand-authored plugin targets. +proj.files.select { |f| f.display_name == 'Cocoa.framework' }.each(&:remove_from_project) +target.frameworks_build_phase.add_file_reference(framework_ref) + +proj.save + +puts "🎉 Added #{TARGET_NAME} target with #{refs.length} source files" +puts ' Sources:' +refs.each { |r| puts " - #{r.path}" } diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index 8db2bf85c..639526311 100755 --- a/scripts/release-all-plugins.sh +++ b/scripts/release-all-plugins.sh @@ -36,6 +36,7 @@ PLUGINS=( cloudflare-d1 dynamodb bigquery + snowflake libsql ) From 4e90b05ba28c9de10b5977dc0dbff91d93e14c88 Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Thu, 4 Jun 2026 00:58:25 -0500 Subject: [PATCH 02/14] feat(plugins): allow loading locally built plugins in DEBUG builds Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Plugins/PluginCodeSignatureVerifier.swift | 8 ++++++++ TablePro/Core/Storage/KeychainHelper.swift | 20 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift index d9efb19f9..76612e2ff 100644 --- a/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift +++ b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift @@ -20,6 +20,14 @@ enum PluginCodeSignatureVerifier { }() static func verify(bundle: Bundle) throws { + #if DEBUG + if ProcessInfo.processInfo.environment["TABLEPRO_ALLOW_UNSIGNED_PLUGINS"] == "1" { + logger.warning( + "Skipping code-signature verification for \(bundle.bundleURL.lastPathComponent): TABLEPRO_ALLOW_UNSIGNED_PLUGINS=1" + ) + return + } + #endif var staticCode: SecStaticCode? let createStatus = SecStaticCodeCreateWithPath( bundle.bundleURL as CFURL, diff --git a/TablePro/Core/Storage/KeychainHelper.swift b/TablePro/Core/Storage/KeychainHelper.swift index 2f323a97a..1c268c2c7 100644 --- a/TablePro/Core/Storage/KeychainHelper.swift +++ b/TablePro/Core/Storage/KeychainHelper.swift @@ -176,15 +176,31 @@ final class KeychainHelper: KeychainStoring { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecUseDataProtectionKeychain as String: true + kSecAttrAccount as String: key ] + if Self.canUseDataProtectionKeychain { + query[kSecUseDataProtectionKeychain as String] = true + } if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } return query } + private static let canUseDataProtectionKeychain: Bool = { + #if DEBUG + guard let task = SecTaskCreateFromSelf(nil), + SecTaskCopyValueForEntitlement(task, "com.apple.application-identifier" as CFString, nil) != nil + else { + logger.warning("No application-identifier entitlement; falling back to the file-based keychain (DEBUG build)") + return false + } + return true + #else + return true + #endif + }() + private func accessibility(forSync synchronizable: Bool) -> CFString { synchronizable ? kSecAttrAccessibleAfterFirstUnlock From aabf4c7dfec1b06007558aafa10dbb99a4c46fc0 Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Thu, 4 Jun 2026 18:30:40 -0500 Subject: [PATCH 03/14] fix(plugins): implement create and drop database in the Snowflake driver Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SnowflakePluginDriver.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 2a0265d9b..2a951d6d4 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -124,6 +124,20 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await conn.switchSchema(to: schema) } + // MARK: - Database Management + + func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { + PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + } + + func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { + _ = try await rawQuery("CREATE DATABASE \(quoteIdentifier(request.name))") + } + + func dropDatabase(name: String) async throws { + _ = try await rawQuery("DROP DATABASE IF EXISTS \(quoteIdentifier(name))") + } + // MARK: - Schema Introspection func fetchTables(schema: String?) async throws -> [PluginTableInfo] { From c4d6f869a61a61e9969dc08d34d154dea0c3198a Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Thu, 4 Jun 2026 19:06:23 -0500 Subject: [PATCH 04/14] fix(plugins): resolve the schema for schema-unaware metadata calls in the Snowflake driver Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SnowflakePluginDriver.swift | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 2a951d6d4..1491709b9 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -14,6 +14,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let lock = NSLock() private var _connection: SnowflakeConnection? private var _serverVersion: String? + private var resolvedSchemaCache: [String: String] = [:] private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakePluginDriver") @@ -167,7 +168,13 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { let database = connection?.currentDatabase - let targetSchema = schema ?? connection?.currentSchema + var targetSchema = schema ?? connection?.currentSchema + if targetSchema == nil, let database { + targetSchema = await resolveSchema(for: table, database: database) + } + Self.logger.debug( + "fetchColumns table=\(table, privacy: .public) schemaParam=\(schema ?? "nil", privacy: .public) currentSchema=\(self.connection?.currentSchema ?? "nil", privacy: .public) currentDatabase=\(database ?? "nil", privacy: .public)" + ) guard let database, let targetSchema else { return [] } let primaryKeys = try await fetchPrimaryKeyColumns(table: table, schema: targetSchema, database: database) @@ -181,6 +188,9 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ORDER BY ORDINAL_POSITION """ let result = try await rawQuery(sql) + Self.logger.debug( + "fetchColumns table=\(table, privacy: .public) schema=\(targetSchema, privacy: .public) database=\(database, privacy: .public) rows=\(result.rows.count, privacy: .public)" + ) return result.rows.compactMap { row in guard let name = Self.text(row, 0) else { return nil } let dataType = Self.text(row, 1) ?? "TEXT" @@ -208,7 +218,10 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { let database = connection?.currentDatabase - let targetSchema = schema ?? connection?.currentSchema + var targetSchema = schema ?? connection?.currentSchema + if targetSchema == nil, let database, let resolved = await resolveSchema(for: table, database: database) { + targetSchema = resolved + } guard let database, let targetSchema else { return nil } let sql = """ SELECT ROW_COUNT @@ -330,7 +343,18 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Private Helpers private func qualifiedName(table: String, schema: String?) -> String { - let targetSchema = schema ?? connection?.currentSchema + qualifiedName(table: table, resolvedSchema: schema ?? connection?.currentSchema) + } + + private func qualifiedNameResolvingSchema(table: String, schema: String?) async -> String { + var targetSchema = schema ?? connection?.currentSchema + if targetSchema == nil, let database = connection?.currentDatabase { + targetSchema = await resolveSchema(for: table, database: database) + } + return qualifiedName(table: table, resolvedSchema: targetSchema) + } + + private func qualifiedName(table: String, resolvedSchema targetSchema: String?) -> String { if let database = connection?.currentDatabase, let targetSchema { return "\(quoteIdentifier(database)).\(quoteIdentifier(targetSchema)).\(quoteIdentifier(table))" } @@ -341,7 +365,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func ddl(objectType: String, name: String, schema: String?) async throws -> String { - let fqn = qualifiedNameUnquoted(table: name, schema: schema) + let fqn = await qualifiedNameResolvingSchema(table: name, schema: schema) let sql = "SELECT GET_DDL('\(objectType)', '\(escapeStringLiteral(fqn))', true)" let result = try await rawQuery(sql) guard let ddl = Self.text(result.rows.first ?? [], 0) else { @@ -350,17 +374,6 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ddl } - private func qualifiedNameUnquoted(table: String, schema: String?) -> String { - let targetSchema = schema ?? connection?.currentSchema - if let database = connection?.currentDatabase, let targetSchema { - return "\(database).\(targetSchema).\(table)" - } - if let targetSchema { - return "\(targetSchema).\(table)" - } - return table - } - private func fetchPrimaryKeyColumns(table: String, schema: String, database: String) async throws -> Set { let fqn = "\(quoteIdentifier(database)).\(quoteIdentifier(schema)).\(quoteIdentifier(table))" guard let result = try? await rawQuery("SHOW PRIMARY KEYS IN TABLE \(fqn)") else { @@ -378,6 +391,31 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return keys } + /// Resolves the schema for a table when the caller didn't provide one and no + /// schema is active — common for hierarchical browsing where app-side calls + /// are schema-unaware. Prefers a unique INFORMATION_SCHEMA match, then PUBLIC. + private func resolveSchema(for table: String, database: String) async -> String? { + if let current = connection?.currentSchema { return current } + let cacheKey = "\(database).\(table)" + if let cached = lock.withLock({ resolvedSchemaCache[cacheKey] }) { return cached } + + let sql = """ + SELECT TABLE_SCHEMA + FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = '\(escapeStringLiteral(table))' + """ + guard let result = try? await rawQuery(sql) else { return nil } + let schemas = result.rows.compactMap { Self.text($0, 0) } + let resolved = schemas.count == 1 + ? schemas[0] + : (schemas.first { $0 == "PUBLIC" } ?? schemas.first) + if let resolved { + lock.withLock { resolvedSchemaCache[cacheKey] = resolved } + Self.logger.debug("resolveSchema table=\(table, privacy: .public) -> \(resolved, privacy: .public) (candidates=\(schemas.count, privacy: .public))") + } + return resolved + } + private func rawQuery(_ sql: String) async throws -> SnowflakeQueryResult { guard let conn = connection else { throw SnowflakeError.notConnected } return try await conn.query(sql) From 75c3f125f1a1f3cc297048407fa61eda7820643a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 03:17:59 +0700 Subject: [PATCH 05/14] fix(plugin-snowflake): poll long queries with backoff up to 45 minutes and add unit tests for auth parsing and type mapping --- .../SnowflakeDriverPlugin/SnowflakeAuth.swift | 8 +- .../SnowflakeConnection.swift | 18 ++- .../SnowflakePluginDriver.swift | 2 + .../SnowflakeTypeMapper.swift | 29 +--- TablePro.xcodeproj/project.pbxproj | 40 ++--- .../PluginTestSources/SnowflakeAuth.swift | 1 + .../PluginTestSources/SnowflakeError.swift | 1 + .../SnowflakeTypeMapper.swift | 1 + .../Plugins/SnowflakeAuthTests.swift | 140 ++++++++++++++++++ .../Plugins/SnowflakeTypeMapperTests.swift | 59 ++++++++ docs/databases/snowflake.mdx | 2 +- docs/index.mdx | 1 + 12 files changed, 239 insertions(+), 63 deletions(-) create mode 120000 TableProTests/PluginTestSources/SnowflakeAuth.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeError.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeTypeMapper.swift create mode 100644 TableProTests/Plugins/SnowflakeAuthTests.swift create mode 100644 TableProTests/Plugins/SnowflakeTypeMapperTests.swift diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift index 3ea1a9565..a32bf485a 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift @@ -216,11 +216,13 @@ enum SnowflakeConnectionsTOML { } private static func stripComment(_ line: String) -> String { - var inString = false + var inDoubleQuotes = false + var inSingleQuotes = false var result = "" for char in line { - if char == "\"" { inString.toggle() } - if char == "#" && !inString { break } + if char == "\"" && !inSingleQuotes { inDoubleQuotes.toggle() } + if char == "'" && !inDoubleQuotes { inSingleQuotes.toggle() } + if char == "#" && !inDoubleQuotes && !inSingleQuotes { break } result.append(char) } return result diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index 9505c6901..d28aeb883 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -412,18 +412,28 @@ final class SnowflakeConnection: @unchecked Sendable { ] } + private static let queryPollTimeout: TimeInterval = 2_700 + private static let queryPollMaxInterval: UInt64 = 5_000_000_000 + private func pollIfInProgress(_ initial: [String: Any], token: String) async throws -> [String: Any] { var response = initial - var attempts = 0 - while Self.isInProgress(response), attempts < 600 { - attempts += 1 + let deadline = Date().addingTimeInterval(Self.queryPollTimeout) + var interval: UInt64 = 500_000_000 + while Self.isInProgress(response) { guard let data = response["data"] as? [String: Any], let resultPath = data["getResultUrl"] as? String else { break } - try await Task.sleep(nanoseconds: 500_000_000) + guard Date() < deadline else { + throw SnowflakeError.timeout("Query did not finish within 45 minutes") + } + try await Task.sleep(nanoseconds: interval) + interval = min(interval * 2, Self.queryPollMaxInterval) response = try await getJSON(path: resultPath, token: token) } + if Self.isInProgress(response) { + throw SnowflakeError.invalidResponse("Query is still running but Snowflake returned no result URL") + } return response } diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 1491709b9..2f0ed9136 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -118,11 +118,13 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func switchDatabase(to database: String) async throws { guard let conn = connection else { throw SnowflakeError.notConnected } try await conn.switchDatabase(to: database) + lock.withLock { resolvedSchemaCache.removeAll() } } func switchSchema(to schema: String) async throws { guard let conn = connection else { throw SnowflakeError.notConnected } try await conn.switchSchema(to: schema) + lock.withLock { resolvedSchemaCache.removeAll() } } // MARK: - Database Management diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift index d10091d56..d99fc42b3 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift @@ -2,12 +2,10 @@ // SnowflakeTypeMapper.swift // SnowflakeDriverPlugin // -// Maps Snowflake's internal row metadata types to display type names and -// decodes JSON cell values into PluginCellValue. +// Maps Snowflake's internal row metadata types to display type names. // import Foundation -import TableProPluginKit struct SnowflakeColumnMeta: Sendable { let name: String @@ -62,29 +60,4 @@ enum SnowflakeTypeMapper { return column.internalType.uppercased() } } - - /// Snowflake's v1 query protocol returns every cell as a JSON string or null. - static func cellValue(from json: Any?) -> PluginCellValue { - switch json { - case nil, is NSNull: - return .null - case let string as String: - return .text(string) - case let number as NSNumber: - return .text(number.stringValue) - case let bool as Bool: - return .text(bool ? "true" : "false") - default: - return .text(String(describing: json ?? "")) - } - } - - static func columnInfo(from column: SnowflakeColumnMeta, primaryKeys: Set) -> PluginColumnInfo { - PluginColumnInfo( - name: column.name, - dataType: displayType(for: column), - isNullable: column.nullable, - isPrimaryKey: primaryKeys.contains(column.name.uppercased()) - ) - } } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 84c934a16..4d8884e7d 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -592,8 +592,6 @@ }; 5A32BC012F9D5F1300BAEB5F /* mcp-server */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = "mcp-server"; sourceTree = ""; }; @@ -769,8 +767,6 @@ }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TableProTests; sourceTree = ""; }; @@ -784,8 +780,6 @@ }; 5AF00A122FB9000000000001 /* TableProUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TableProUITests; sourceTree = ""; }; @@ -1057,9 +1051,9 @@ 5A1091BE2EF17EDC0055EA7C = { isa = PBXGroup; children = ( - 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */, - 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */, - 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */, + 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */, + 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */, + 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, 5A1091C92EF17EDC0055EA7C /* TablePro */, @@ -1130,7 +1124,7 @@ name = Products; sourceTree = ""; }; - 5ABQR00500000000000000B0 /* BigQueryDriverPlugin */ = { + 5ABQR00500000000000000B0 /* Plugins/BigQueryDriverPlugin */ = { isa = PBXGroup; children = ( 5ABQR00200000000000000A1 /* BigQueryAuth.swift */, @@ -1145,7 +1139,7 @@ path = Plugins/BigQueryDriverPlugin; sourceTree = ""; }; - 5ADDB00500000000000000B0 /* DynamoDBDriverPlugin */ = { + 5ADDB00500000000000000B0 /* Plugins/DynamoDBDriverPlugin */ = { isa = PBXGroup; children = ( 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */, @@ -1159,7 +1153,7 @@ path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; }; - 5AEA8B412F6808CA0040461A /* EtcdDriverPlugin */ = { + 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */ = { isa = PBXGroup; children = ( 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */, @@ -1175,18 +1169,10 @@ 5AEA8B482F6808E90040461A /* Frameworks */ = { isa = PBXGroup; children = ( - 665759805EBF5C4BF064ADAB /* OS X */, ); name = Frameworks; sourceTree = ""; }; - 665759805EBF5C4BF064ADAB /* OS X */ = { - isa = PBXGroup; - children = ( - ); - name = "OS X"; - sourceTree = ""; - }; 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */ = { isa = PBXGroup; children = ( @@ -1940,11 +1926,11 @@ mainGroup = 5A1091BE2EF17EDC0055EA7C; minimizedProjectReferenceProxies = 1; packageReferences = ( - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */, + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */, 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */, + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */, + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, ); preferredProjectObjectVersion = 77; @@ -4704,15 +4690,15 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "CodeEditLanguages" */ = { + 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditLanguages; }; - 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "CodeEditSourceEditor" */ = { + 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */ = { isa = XCLocalSwiftPackageReference; relativePath = LocalPackages/CodeEditSourceEditor; }; - 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */ = { + 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/TableProCore; }; @@ -4779,7 +4765,7 @@ }; 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { isa = XCSwiftPackageProductDependency; - package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "TableProCore" */; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; /* End XCSwiftPackageProductDependency section */ diff --git a/TableProTests/PluginTestSources/SnowflakeAuth.swift b/TableProTests/PluginTestSources/SnowflakeAuth.swift new file mode 120000 index 000000000..66e2e7ebc --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeAuth.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeError.swift b/TableProTests/PluginTestSources/SnowflakeError.swift new file mode 120000 index 000000000..0abf21f12 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeError.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeError.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeTypeMapper.swift b/TableProTests/PluginTestSources/SnowflakeTypeMapper.swift new file mode 120000 index 000000000..9229b5e0f --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeTypeMapper.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift \ No newline at end of file diff --git a/TableProTests/Plugins/SnowflakeAuthTests.swift b/TableProTests/Plugins/SnowflakeAuthTests.swift new file mode 100644 index 000000000..bc6910370 --- /dev/null +++ b/TableProTests/Plugins/SnowflakeAuthTests.swift @@ -0,0 +1,140 @@ +// +// SnowflakeAuthTests.swift +// TableProTests +// +// Tests for SnowflakeAccount, SnowflakeConnectionsTOML, and the SPKI +// wrapping used for key-pair JWT fingerprints (compiled via symlink from +// SnowflakeDriverPlugin). +// + +import Foundation +import Testing + +@Suite("Snowflake Account Parsing") +struct SnowflakeAccountTests { + @Test("Plain locator gets the Snowflake domain appended") + func testHostFromLocator() { + #expect(SnowflakeAccount.host(forAccount: "xy12345.us-east-1") == "xy12345.us-east-1.snowflakecomputing.com") + #expect(SnowflakeAccount.host(forAccount: "myorg-myaccount") == "myorg-myaccount.snowflakecomputing.com") + } + + @Test("Full hostnames pass through unchanged, case-insensitively") + func testHostPassthrough() { + #expect( + SnowflakeAccount.host(forAccount: "abc.snowflakecomputing.com") == "abc.snowflakecomputing.com" + ) + #expect( + SnowflakeAccount.host(forAccount: "Abc.SnowflakeComputing.Com") == "Abc.SnowflakeComputing.Com" + ) + } + + @Test("URL forms resolve to their host") + func testHostFromURL() { + #expect( + SnowflakeAccount.host(forAccount: "https://abc.snowflakecomputing.com/console") == + "abc.snowflakecomputing.com" + ) + } + + @Test("Whitespace is trimmed before resolution") + func testHostTrimsWhitespace() { + #expect(SnowflakeAccount.host(forAccount: " abc \n") == "abc.snowflakecomputing.com") + } + + @Test("Issuer account name drops domain and region, then uppercases") + func testIssuerAccountName() { + #expect(SnowflakeAccount.issuerAccountName(forAccount: "xy12345.us-east-1") == "XY12345") + #expect( + SnowflakeAccount.issuerAccountName(forAccount: "xy12345.us-east-1.snowflakecomputing.com") == "XY12345" + ) + #expect(SnowflakeAccount.issuerAccountName(forAccount: "myorg-myaccount") == "MYORG-MYACCOUNT") + } +} + +@Suite("Snowflake Connections TOML") +struct SnowflakeConnectionsTOMLTests { + @Test("Parses sections with key-value pairs") + func testBasicSection() { + let toml = """ + [default] + account = "xy12345" + user = jane + """ + let parsed = SnowflakeConnectionsTOML.parse(toml) + #expect(parsed["default"]?["account"] == "xy12345") + #expect(parsed["default"]?["user"] == "jane") + } + + @Test("Strips the connections. prefix from config.toml sections") + func testConnectionsPrefix() { + let parsed = SnowflakeConnectionsTOML.parse("[connections.dev]\naccount = 'abc'\n") + #expect(parsed["dev"]?["account"] == "abc") + } + + @Test("Quoted section names are unquoted") + func testQuotedSectionName() { + let parsed = SnowflakeConnectionsTOML.parse("[connections.\"my conn\"]\nrole = \"ADMIN\"\n") + #expect(parsed["my conn"]?["role"] == "ADMIN") + } + + @Test("Comments are stripped outside strings and kept inside both quote styles") + func testCommentHandling() { + let toml = """ + [default] + account = "abc" # trailing comment + password = "p#ss" + token = 'a#b' + """ + let parsed = SnowflakeConnectionsTOML.parse(toml) + #expect(parsed["default"]?["account"] == "abc") + #expect(parsed["default"]?["password"] == "p#ss") + #expect(parsed["default"]?["token"] == "a#b") + } + + @Test("Key-value pairs before any section are ignored") + func testKeysOutsideSectionIgnored() { + let parsed = SnowflakeConnectionsTOML.parse("account = \"abc\"\n[dev]\nuser = \"u\"\n") + #expect(parsed.count == 1) + #expect(parsed["dev"]?["user"] == "u") + } +} + +@Suite("Snowflake SPKI Wrapping") +struct SnowflakeSPKIWrappingTests { + private static let rsaAlgorithmID: [UInt8] = [ + 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 + ] + + @Test("Short keys use single-byte DER lengths") + func testShortFormLength() { + let pkcs1 = Data((0..<10).map { UInt8($0) }) + let spki = [UInt8](SnowflakeKeyPairAuth.wrapPKCS1IntoSPKI(pkcs1)) + + #expect(spki[0] == 0x30) + #expect(Int(spki[1]) == spki.count - 2) + #expect(Array(spki[2..<17]) == Self.rsaAlgorithmID) + #expect(spki[17] == 0x03) + #expect(Int(spki[18]) == pkcs1.count + 1) + #expect(spki[19] == 0x00) + #expect(Array(spki.suffix(pkcs1.count)) == [UInt8](pkcs1)) + } + + @Test("Keys past 127 bytes use long-form DER lengths") + func testLongFormLength() { + let pkcs1 = Data(repeating: 0xAB, count: 270) + let spki = [UInt8](SnowflakeKeyPairAuth.wrapPKCS1IntoSPKI(pkcs1)) + + #expect(spki[0] == 0x30) + #expect(spki[1] == 0x82) + let bodyLength = (Int(spki[2]) << 8) | Int(spki[3]) + #expect(bodyLength == spki.count - 4) + #expect(Array(spki[4..<19]) == Self.rsaAlgorithmID) + #expect(spki[19] == 0x03) + #expect(spki[20] == 0x82) + let bitStringLength = (Int(spki[21]) << 8) | Int(spki[22]) + #expect(bitStringLength == pkcs1.count + 1) + #expect(spki[23] == 0x00) + #expect(Array(spki.suffix(pkcs1.count)) == [UInt8](pkcs1)) + } +} diff --git a/TableProTests/Plugins/SnowflakeTypeMapperTests.swift b/TableProTests/Plugins/SnowflakeTypeMapperTests.swift new file mode 100644 index 000000000..d936961d8 --- /dev/null +++ b/TableProTests/Plugins/SnowflakeTypeMapperTests.swift @@ -0,0 +1,59 @@ +// +// SnowflakeTypeMapperTests.swift +// TableProTests +// +// Tests for SnowflakeTypeMapper (compiled via symlink from SnowflakeDriverPlugin). +// + +import Foundation +import Testing + +@Suite("Snowflake Type Mapper") +struct SnowflakeTypeMapperTests { + private func column( + _ type: String, + precision: Int? = nil, + scale: Int? = nil, + length: Int? = nil + ) -> SnowflakeColumnMeta { + SnowflakeColumnMeta( + name: "c", + internalType: type, + nullable: true, + precision: precision, + scale: scale, + length: length + ) + } + + @Test("Fixed with scale renders precision and scale") + func testFixedWithScale() { + #expect(SnowflakeTypeMapper.displayType(for: column("fixed", precision: 10, scale: 2)) == "NUMBER(10,2)") + #expect(SnowflakeTypeMapper.displayType(for: column("fixed", scale: 3)) == "NUMBER(38,3)") + } + + @Test("Fixed without scale renders plain NUMBER") + func testFixedWithoutScale() { + #expect(SnowflakeTypeMapper.displayType(for: column("fixed", precision: 38, scale: 0)) == "NUMBER") + #expect(SnowflakeTypeMapper.displayType(for: column("FIXED")) == "NUMBER") + } + + @Test("Text renders VARCHAR with optional length") + func testText() { + #expect(SnowflakeTypeMapper.displayType(for: column("text", length: 255)) == "VARCHAR(255)") + #expect(SnowflakeTypeMapper.displayType(for: column("text")) == "VARCHAR") + } + + @Test("Real maps to FLOAT and timestamps keep their zone variant") + func testRealAndTimestamps() { + #expect(SnowflakeTypeMapper.displayType(for: column("real")) == "FLOAT") + #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ntz")) == "TIMESTAMP_NTZ") + #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ltz")) == "TIMESTAMP_LTZ") + #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_tz")) == "TIMESTAMP_TZ") + } + + @Test("Unknown internal types fall back to their uppercased name") + func testUnknownType() { + #expect(SnowflakeTypeMapper.displayType(for: column("vector")) == "VECTOR") + } +} diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index 9964d1717..a8dab09a8 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -79,7 +79,7 @@ SELECT * FROM events AT(OFFSET => -60*5); **Auth failed**: Verify the account identifier and credentials. For key-pair auth, confirm the public key is registered on the user and the passphrase is correct. -**MFA with TOTP is required (394508)**: Your account enforces MFA on password logins. Enter a current code from your authenticator in **MFA Passcode (TOTP)** — it expires quickly, so refresh it right before connecting. Key-pair or SSO auth avoids the prompt entirely. +**MFA with TOTP is required (394508)**: Your account enforces MFA on password logins. Enter a current code from your authenticator in **MFA Passcode (TOTP)**. It expires quickly, so refresh it right before connecting. Key-pair or SSO auth avoids the prompt entirely. **JWT token is invalid**: The account name in the JWT issuer must match your account locator. Make sure your machine clock is accurate (JWTs are time-sensitive). diff --git a/docs/index.mdx b/docs/index.mdx index a7ce035cb..3ad35ee40 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -77,6 +77,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | Cloudflare D1 | N/A (API-based) | Plugin | | DynamoDB | N/A (API-based) | Plugin | | BigQuery | N/A (API-based) | Plugin | +| Snowflake | N/A (API-based) | Plugin | | libSQL / Turso | N/A (API-based) | Plugin | ## System Requirements From 57a9f3b2736108cb0b9ac42df2eef5013643c326 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 03:44:31 +0700 Subject: [PATCH 06/14] fix(plugin-snowflake): trust the login session info for database and schema instead of the form values --- .../SnowflakeDriverPlugin/SnowflakeConnection.swift | 13 +++++++++---- docs/databases/snowflake.mdx | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index d28aeb883..1dec555e8 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -568,13 +568,18 @@ final class SnowflakeConnection: @unchecked Sendable { private func applySessionInfo(_ info: [String: Any]?) { guard let info else { return } lock.withLock { - if let value = info["databaseName"] as? String, !value.isEmpty { _currentDatabase = value } - if let value = info["schemaName"] as? String, !value.isEmpty { _currentSchema = value } - if let value = info["warehouseName"] as? String, !value.isEmpty { _currentWarehouse = value } - if let value = info["roleName"] as? String, !value.isEmpty { _currentRole = value } + _currentDatabase = Self.nonEmptyString(info["databaseName"]) + _currentSchema = Self.nonEmptyString(info["schemaName"]) + _currentWarehouse = Self.nonEmptyString(info["warehouseName"]) + _currentRole = Self.nonEmptyString(info["roleName"]) } } + private static func nonEmptyString(_ value: Any?) -> String? { + guard let string = value as? String, !string.isEmpty else { return nil } + return string + } + private func applyFinalSessionInfo(_ data: [String: Any]) { lock.withLock { if let value = data["finalDatabaseName"] as? String, !value.isEmpty { _currentDatabase = value } diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index a8dab09a8..35e4b9067 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -81,6 +81,8 @@ SELECT * FROM events AT(OFFSET => -60*5); **MFA with TOTP is required (394508)**: Your account enforces MFA on password logins. Enter a current code from your authenticator in **MFA Passcode (TOTP)**. It expires quickly, so refresh it right before connecting. Key-pair or SSO auth avoids the prompt entirely. +**TOTP Invalid (394507) while browsing after a successful connect**: TablePro opens extra sessions for browsing, and each new login revalidates the saved passcode, which expires within a minute. Enable MFA token caching on the account so one fresh code covers all later sessions: run `ALTER ACCOUNT SET ALLOW_CLIENT_MFA_CACHING = TRUE;` (needs ACCOUNTADMIN), then reconnect with a current code. TablePro caches the returned MFA token in the Keychain and skips the passcode from then on. + **JWT token is invalid**: The account name in the JWT issuer must match your account locator. Make sure your machine clock is accurate (JWTs are time-sensitive). **No databases listed**: Ensure the connecting role has `USAGE` on the databases and a warehouse is set so metadata queries can run. From e9b2d32a72cbc41a8415a6c4f62ef7b18fb5b3ba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 04:23:18 +0700 Subject: [PATCH 07/14] feat(plugin-snowflake): server-side bindings, session heartbeat, SSO token cache, transport retry, and cancel hardening --- .../SnowflakeBindingEncoder.swift | 39 +++++ .../SnowflakeConnection.swift | 164 +++++++++++++----- .../SnowflakeError.swift | 10 ++ .../SnowflakeHTTPRetry.swift | 82 +++++++++ .../SnowflakeHeartbeat.swift | 37 ++++ .../SnowflakeIdTokenStore.swift | 84 +++++++++ .../SnowflakePlugin.swift | 9 +- .../SnowflakePluginDriver.swift | 19 +- TablePro.xcodeproj/project.pbxproj | 18 ++ ...PluginMetadataRegistry+CloudDefaults.swift | 4 +- .../Core/Services/ColumnTypeClassifier.swift | 4 + .../SnowflakeBindingEncoder.swift | 1 + .../SnowflakeHTTPRetry.swift | 1 + .../SnowflakeHeartbeat.swift | 1 + .../Plugins/SnowflakeProtocolTests.swift | 119 +++++++++++++ 15 files changed, 545 insertions(+), 47 deletions(-) create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeIdTokenStore.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeBindingEncoder.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeHTTPRetry.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeHeartbeat.swift create mode 100644 TableProTests/Plugins/SnowflakeProtocolTests.swift diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift new file mode 100644 index 000000000..bd2ab521c --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift @@ -0,0 +1,39 @@ +// +// SnowflakeBindingEncoder.swift +// SnowflakeDriverPlugin +// +// Encodes PluginCellValue parameters into the Snowflake v1 query-request +// "bindings" payload: 1-based string keys, TEXT for scalar values (the server +// coerces into the target column type), BINARY as hex, null as a typed null. +// + +import Foundation +import TableProPluginKit + +enum SnowflakeBindingEncoder { + static func encode(_ parameters: [PluginCellValue]) -> [String: [String: Any]] { + var bindings: [String: [String: Any]] = [:] + bindings.reserveCapacity(parameters.count) + for (index, parameter) in parameters.enumerated() { + bindings[String(index + 1)] = binding(for: parameter) + } + return bindings + } + + private static func binding(for value: PluginCellValue) -> [String: Any] { + switch value { + case .null: + return ["type": "TEXT", "value": NSNull()] + case .text(let text): + return ["type": "TEXT", "value": text] + case .bytes(let data): + return ["type": "BINARY", "value": hex(data)] + default: + return ["type": "TEXT", "value": NSNull()] + } + } + + private static func hex(_ data: Data) -> String { + data.map { String(format: "%02X", $0) }.joined() + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index 1dec555e8..84be80d66 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -48,9 +48,10 @@ final class SnowflakeConnection: @unchecked Sendable { private let session: URLSession private let lock = NSLock() + private let heartbeat = SnowflakeHeartbeat() private var sessionToken: String? private var renewalToken: String? - private var currentRequestID: String? + private var activeRequestIDs: Set = [] private var sequenceId = 0 private var _currentDatabase: String? @@ -135,9 +136,10 @@ final class SnowflakeConnection: @unchecked Sendable { lock.withLock { sessionToken = nil renewalToken = nil - currentRequestID = nil + activeRequestIDs.removeAll() } Task { [weak self] in + await self?.heartbeat.stop() try? await self?.postLogout(token: token) } } @@ -186,6 +188,16 @@ final class SnowflakeConnection: @unchecked Sendable { } private func loginWithExternalBrowser() async throws { + if let idToken = SnowflakeIdTokenStore.token(account: params.account, user: params.user) { + do { + try await login(authenticator: "ID_TOKEN", extra: ["TOKEN": idToken]) + return + } catch { + SnowflakeIdTokenStore.clear(account: params.account, user: params.user) + Self.logger.info("Cached SSO id token rejected; falling back to browser authentication") + } + } + let server = SnowflakeBrowserAuthServer() let port = try await server.start() @@ -289,9 +301,40 @@ final class SnowflakeConnection: @unchecked Sendable { SnowflakeMFATokenStore.store(mfaToken, account: params.account, user: params.user) Self.logger.info("Login succeeded (\(authenticator, privacy: .public)); MFA token cached for reuse") } else if authenticator == "SNOWFLAKE" || authenticator == "USERNAME_PASSWORD_MFA" { - Self.logger.info("Login succeeded (\(authenticator, privacy: .public)); no mfaToken returned — ALLOW_CLIENT_MFA_CACHING may be disabled on this account") + Self.logger.info("Login succeeded (\(authenticator, privacy: .public)); no mfaToken returned; ALLOW_CLIENT_MFA_CACHING may be disabled on this account") + } + if let idToken = responseData["idToken"] as? String, !idToken.isEmpty { + SnowflakeIdTokenStore.store(idToken, account: params.account, user: params.user) + Self.logger.info("SSO id token cached; subsequent connects skip the browser") } applySessionInfo(responseData["sessionInfo"] as? [String: Any]) + startHeartbeat(masterValiditySeconds: responseData["masterValidityInSeconds"] as? Double ?? 14_400) + } + + private func startHeartbeat(masterValiditySeconds: Double) { + let interval = SnowflakeHeartbeat.interval(masterValiditySeconds: masterValiditySeconds) + Task { [weak self] in + await self?.heartbeat.start(interval: interval) { [weak self] in + await self?.sendHeartbeat() + } + } + } + + private func sendHeartbeat() async { + guard let token = lock.withLock({ sessionToken }) else { return } + do { + let response = try await postJSON( + path: "/session/heartbeat", + queryItems: Self.trackingQueryItems(), + body: [:], + token: token + ) + if Self.codeString(response["code"]) == "390112" { + try await renewSession() + } + } catch { + Self.logger.warning("Session heartbeat failed: \(error.localizedDescription)") + } } private func renewSession() async throws { @@ -321,7 +364,11 @@ final class SnowflakeConnection: @unchecked Sendable { } private func sessionParameters(for authenticator: String) -> [String: Any] { - var parameters: [String: Any] = ["CLIENT_STORE_TEMPORARY_CREDENTIAL": false] + var parameters: [String: Any] = [ + "CLIENT_STORE_TEMPORARY_CREDENTIAL": true, + "CLIENT_SESSION_KEEP_ALIVE": true, + "QUERY_TAG": Self.appName + ] if authenticator == "SNOWFLAKE" || authenticator == "USERNAME_PASSWORD_MFA" { parameters["CLIENT_REQUEST_MFA_TOKEN"] = true } @@ -340,29 +387,35 @@ final class SnowflakeConnection: @unchecked Sendable { // MARK: - Query Execution - func query(_ sql: String) async throws -> SnowflakeQueryResult { + func query(_ sql: String, parameters: [PluginCellValue] = []) async throws -> SnowflakeQueryResult { do { - return try await performQuery(sql) - } catch SnowflakeError.queryFailed(let code, _) where code == "390112" { - try await renewSession() - return try await performQuery(sql) + return try await performQuery(sql, parameters: parameters) + } catch SnowflakeError.queryFailed(let code, _) where SnowflakeError.isReauthenticationCode(code) { + do { + try await renewSession() + } catch { + try await connect() + } + return try await performQuery(sql, parameters: parameters) } } - func cancelCurrentQuery() { - let (requestID, token) = lock.withLock { (currentRequestID, sessionToken) } - guard let requestID, let token else { return } + func cancelAllQueries() { + let (requestIDs, token) = lock.withLock { (activeRequestIDs, sessionToken) } + guard !requestIDs.isEmpty, let token else { return } Task { [weak self] in - _ = try? await self?.postJSON( - path: "/queries/v1/abort-request", - queryItems: Self.trackingQueryItems(), - body: ["requestId": requestID], - token: token - ) + for requestID in requestIDs { + _ = try? await self?.postJSON( + path: "/queries/v1/abort-request", + queryItems: Self.trackingQueryItems(), + body: ["requestId": requestID], + token: token + ) + } } } - private func performQuery(_ sql: String) async throws -> SnowflakeQueryResult { + private func performQuery(_ sql: String, parameters: [PluginCellValue] = []) async throws -> SnowflakeQueryResult { guard let token = lock.withLock({ sessionToken }) else { throw SnowflakeError.notConnected } @@ -370,17 +423,22 @@ final class SnowflakeConnection: @unchecked Sendable { let requestID = UUID().uuidString.lowercased() let sequence = lock.withLock { () -> Int in sequenceId += 1 - currentRequestID = requestID + activeRequestIDs.insert(requestID) return sequenceId } - defer { lock.withLock { currentRequestID = nil } } + defer { + lock.withLock { _ = activeRequestIDs.remove(requestID) } + } - let body: [String: Any] = [ + var body: [String: Any] = [ "sqlText": sql, "asyncExec": false, "sequenceId": sequence, "querySubmissionTime": Int(Date().timeIntervalSince1970 * 1_000) ] + if !parameters.isEmpty { + body["bindings"] = SnowflakeBindingEncoder.encode(parameters) + } var response = try await postJSON( path: "/queries/v1/query-request", @@ -413,12 +471,10 @@ final class SnowflakeConnection: @unchecked Sendable { } private static let queryPollTimeout: TimeInterval = 2_700 - private static let queryPollMaxInterval: UInt64 = 5_000_000_000 private func pollIfInProgress(_ initial: [String: Any], token: String) async throws -> [String: Any] { var response = initial let deadline = Date().addingTimeInterval(Self.queryPollTimeout) - var interval: UInt64 = 500_000_000 while Self.isInProgress(response) { guard let data = response["data"] as? [String: Any], let resultPath = data["getResultUrl"] as? String else { @@ -427,8 +483,6 @@ final class SnowflakeConnection: @unchecked Sendable { guard Date() < deadline else { throw SnowflakeError.timeout("Query did not finish within 45 minutes") } - try await Task.sleep(nanoseconds: interval) - interval = min(interval * 2, Self.queryPollMaxInterval) response = try await getJSON(path: resultPath, token: token) } if Self.isInProgress(response) { @@ -465,23 +519,50 @@ final class SnowflakeConnection: @unchecked Sendable { ) } + private static let chunkDownloadWorkers = 4 + private func downloadChunks(_ chunks: [[String: Any]], headers: [String: String]) async throws -> [[PluginCellValueBox]] { - var allRows: [[PluginCellValueBox]] = [] - for chunk in chunks { - guard let urlString = chunk["url"] as? String, let url = URL(string: urlString) else { continue } - var request = URLRequest(url: url) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) + let urls = chunks.compactMap { ($0["url"] as? String).flatMap(URL.init(string:)) } + guard !urls.isEmpty else { return [] } + + var rowsByChunk = [[[PluginCellValueBox]]](repeating: [], count: urls.count) + try await withThrowingTaskGroup(of: (Int, [[PluginCellValueBox]]).self) { group in + var nextIndex = 0 + while nextIndex < min(Self.chunkDownloadWorkers, urls.count) { + let index = nextIndex + group.addTask { [session] in + (index, try await Self.downloadChunk(urls[index], headers: headers, session: session)) + } + nextIndex += 1 } - let (rawData, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw SnowflakeError.invalidResponse("Failed to download result chunk") + while let (index, rows) = try await group.next() { + rowsByChunk[index] = rows + if nextIndex < urls.count { + let next = nextIndex + group.addTask { [session] in + (next, try await Self.downloadChunk(urls[next], headers: headers, session: session)) + } + nextIndex += 1 + } } - let jsonData = Self.gunzipIfNeeded(rawData) - let rows = try Self.parseChunkRows(jsonData) - allRows.append(contentsOf: rows) } - return allRows + return rowsByChunk.flatMap { $0 } + } + + private static func downloadChunk( + _ url: URL, + headers: [String: String], + session: URLSession + ) async throws -> [[PluginCellValueBox]] { + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + let (rawData, http) = try await SnowflakeHTTPClient.send(request, session: session) + guard http.statusCode == 200 else { + throw SnowflakeError.invalidResponse("Failed to download result chunk") + } + return try parseChunkRows(gunzipIfNeeded(rawData)) } // MARK: - USE / Session @@ -546,10 +627,7 @@ final class SnowflakeConnection: @unchecked Sendable { } private func send(_ request: URLRequest) async throws -> [String: Any] { - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw SnowflakeError.invalidResponse("No HTTP response from Snowflake") - } + let (data, http) = try await SnowflakeHTTPClient.send(request, session: session) guard (200..<300).contains(http.statusCode) else { let bodyText = String(data: data, encoding: .utf8) ?? "" Self.logger.error( diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift index e8e42a4b4..811d67651 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift @@ -38,6 +38,16 @@ enum SnowflakeError: Error, LocalizedError { } } +extension SnowflakeError { + static let reauthenticationCodes: Set = [ + "390110", "390112", "390113", "390114", "390115", "390195" + ] + + static func isReauthenticationCode(_ code: String) -> Bool { + reauthenticationCodes.contains(code) + } +} + extension SnowflakeError: PluginDriverError { var pluginErrorMessage: String { errorDescription ?? String(localized: "Unknown Snowflake error") diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift new file mode 100644 index 000000000..dbfa04c11 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift @@ -0,0 +1,82 @@ +// +// SnowflakeHTTPRetry.swift +// SnowflakeDriverPlugin +// +// Transport retry matching the official Snowflake connectors: transient HTTP +// statuses retry with decorrelated jitter, each attempt tags the URL with +// retryCount, retryReason, and clientStartTime, and regenerates request_guid +// while keeping requestId stable so the server can deduplicate. +// + +import Foundation +import os + +enum SnowflakeRetryPolicy { + static let maxAttempts = 5 + static let baseDelay: Double = 1.0 + static let maxDelay: Double = 16.0 + + static func isTransient(statusCode: Int) -> Bool { + statusCode == 429 || statusCode == 408 || (500...599).contains(statusCode) + } + + static func nextDelay(after previous: Double, using generator: inout some RandomNumberGenerator) -> Double { + let upper = max(baseDelay, previous * 3) + return min(maxDelay, Double.random(in: baseDelay...upper, using: &generator)) + } + + static func retriedURL(_ url: URL, retryCount: Int, retryReason: Int, clientStartTime: Int) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + var items = (components.queryItems ?? []).filter { item in + !["retryCount", "retryReason", "clientStartTime", "request_guid"].contains(item.name) + } + items.append(URLQueryItem(name: "retryCount", value: String(retryCount))) + items.append(URLQueryItem(name: "retryReason", value: String(retryReason))) + items.append(URLQueryItem(name: "clientStartTime", value: String(clientStartTime))) + items.append(URLQueryItem(name: "request_guid", value: UUID().uuidString.lowercased())) + components.queryItems = items + return components.url ?? url + } +} + +enum SnowflakeHTTPClient { + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeHTTPClient") + + static func send(_ request: URLRequest, session: URLSession) async throws -> (Data, HTTPURLResponse) { + let clientStartTime = Int(Date().timeIntervalSince1970 * 1_000) + var generator = SystemRandomNumberGenerator() + var delay = SnowflakeRetryPolicy.baseDelay + var lastError: Error? + var lastReason = 0 + + for attempt in 0.. 0, let url = request.url { + attemptRequest.url = SnowflakeRetryPolicy.retriedURL( + url, retryCount: attempt, retryReason: lastReason, clientStartTime: clientStartTime + ) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + delay = SnowflakeRetryPolicy.nextDelay(after: delay, using: &generator) + } + + do { + let (data, response) = try await session.data(for: attemptRequest) + guard let http = response as? HTTPURLResponse else { + throw SnowflakeError.invalidResponse("No HTTP response from Snowflake") + } + guard SnowflakeRetryPolicy.isTransient(statusCode: http.statusCode) else { + return (data, http) + } + lastReason = http.statusCode + lastError = SnowflakeError.invalidResponse("Snowflake returned HTTP \(http.statusCode)") + logger.warning("Transient HTTP \(http.statusCode, privacy: .public); attempt \(attempt + 1, privacy: .public) of \(SnowflakeRetryPolicy.maxAttempts, privacy: .public)") + } catch let error as URLError where error.code != .cancelled { + lastReason = 0 + lastError = error + logger.warning("Transport error \(error.code.rawValue, privacy: .public); attempt \(attempt + 1, privacy: .public) of \(SnowflakeRetryPolicy.maxAttempts, privacy: .public)") + } + } + + throw lastError ?? SnowflakeError.invalidResponse("Snowflake request failed after retries") + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift new file mode 100644 index 000000000..67dace937 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift @@ -0,0 +1,37 @@ +// +// SnowflakeHeartbeat.swift +// SnowflakeDriverPlugin +// +// Keeps the Snowflake session alive while the connection is idle, mirroring +// CLIENT_SESSION_KEEP_ALIVE in the official drivers: one heartbeat every +// quarter of the master token validity, clamped to 15 to 60 minutes. +// + +import Foundation + +actor SnowflakeHeartbeat { + static let minimumInterval: TimeInterval = 900 + static let maximumInterval: TimeInterval = 3_600 + + private var task: Task? + + static func interval(masterValiditySeconds: TimeInterval) -> TimeInterval { + min(maximumInterval, max(minimumInterval, masterValiditySeconds / 4)) + } + + func start(interval: TimeInterval, beat: @escaping @Sendable () async -> Void) { + task?.cancel() + task = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + guard !Task.isCancelled else { return } + await beat() + } + } + } + + func stop() { + task?.cancel() + task = nil + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeIdTokenStore.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeIdTokenStore.swift new file mode 100644 index 000000000..e080da4e0 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeIdTokenStore.swift @@ -0,0 +1,84 @@ +// +// SnowflakeIdTokenStore.swift +// SnowflakeDriverPlugin +// +// Caches the idToken Snowflake issues after a successful external-browser SSO +// login (CLIENT_STORE_TEMPORARY_CREDENTIAL), so subsequent connects use the +// ID_TOKEN authenticator instead of reopening the browser. Mirrors the +// official connectors' temporary credential cache, stored in the Keychain. +// + +import Foundation +import os +import Security + +enum SnowflakeIdTokenStore { + private static let lock = NSLock() + private static var cache: [String: String] = [:] + private static let service = "com.TablePro.SnowflakeDriverPlugin.idToken" + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeIdTokenStore") + + static func token(account: String, user: String) -> String? { + let key = cacheKey(account: account, user: user) + if let cached = lock.withLock({ cache[key] }) { + return cached + } + guard let stored = readKeychain(key: key) else { return nil } + lock.withLock { cache[key] = stored } + return stored + } + + static func store(_ token: String, account: String, user: String) { + let key = cacheKey(account: account, user: user) + lock.withLock { cache[key] = token } + writeKeychain(token, key: key) + } + + static func clear(account: String, user: String) { + let key = cacheKey(account: account, user: user) + _ = lock.withLock { cache.removeValue(forKey: key) } + deleteKeychain(key: key) + } + + private static func cacheKey(account: String, user: String) -> String { + "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" + } + + private static func baseQuery(key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + } + + private static func readKeychain(key: String) -> String? { + var query = baseQuery(key: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func writeKeychain(_ value: String, key: String) { + var addQuery = baseQuery(key: key) + addQuery[kSecValueData as String] = Data(value.utf8) + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + var status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + status = SecItemUpdate( + baseQuery(key: key) as CFDictionary, + [kSecValueData as String: Data(value.utf8)] as CFDictionary + ) + } + if status != errSecSuccess { + logger.warning("Could not persist SSO id token (OSStatus \(status)); it will be cached in memory only") + } + } + + private static func deleteKeychain(key: String) { + SecItemDelete(baseQuery(key: key) as CFDictionary) + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 50ffc6b2f..e42249307 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -36,6 +36,13 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let editorLanguage: EditorLanguage = .sql static let supportsForeignKeys = false static let supportsSchemaEditing = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let supportsHealthMonitor = false static let supportsDatabaseSwitching = true static let supportsSchemaSwitching = true static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] @@ -49,7 +56,7 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema static let defaultGroupName = "default" static let defaultPrimaryKeyColumn: String? = nil - static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] static let supportsCascadeDrop = true static let supportsDropDatabase = true diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 2f0ed9136..51d35f751 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -31,7 +31,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func cancelQuery() throws { - connection?.cancelCurrentQuery() + connection?.cancelAllQueries() } var supportsSchemas: Bool { true } @@ -39,6 +39,23 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var serverVersion: String? { lock.withLock { _serverVersion } } var currentSchema: String? { connection?.currentSchema } var parameterStyle: ParameterStyle { .questionMark } + var requiresBackslashEscapingInLiterals: Bool { true } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + guard !parameters.isEmpty else { return try await execute(query: query) } + guard let conn = connection else { throw SnowflakeError.notConnected } + let startTime = Date() + let result = try await conn.query(query, parameters: parameters) + return PluginQueryResult( + columns: result.columns.map(\.name), + columnTypeNames: result.columns.map(SnowflakeTypeMapper.displayType), + rows: result.rows.map { row in row.map(Self.cellValue) }, + rowsAffected: result.affectedRows, + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated, + statusMessage: result.statusMessage + ) + } // MARK: - Lifecycle diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 4d8884e7d..eebeee34b 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; }; + 5AC8FF6F28A24FFBBBFF008F /* SnowflakeBindingEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */; }; + FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */; }; + 8F6387B85F8B4AEE8210A6F1 /* SnowflakeIdTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */; }; 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -77,6 +81,7 @@ 5AEA8B462F6808CA0040461A /* EtcdQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3F2F6808CA0040461A /* EtcdQueryBuilder.swift */; }; 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AEC4DB12FD36700002191A2 /* SnowflakeDriverPlugin.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; @@ -278,6 +283,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 5AEC4DB12FD36700002191A2 /* SnowflakeDriverPlugin.tableplugin in Copy Plug-Ins (12 items) */, 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, @@ -310,6 +316,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = ""; }; + C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; + 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHeartbeat.swift; sourceTree = ""; }; + 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnection.swift; sourceTree = ""; }; 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeMFATokenStore.swift; sourceTree = ""; }; 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeTypeMapper.swift; sourceTree = ""; }; @@ -1177,6 +1187,10 @@ isa = PBXGroup; children = ( F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */, + 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */, + C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */, + 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */, + 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */, 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */, 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */, FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */, @@ -2184,6 +2198,10 @@ buildActionMask = 2147483647; files = ( 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */, + 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */, + 5AC8FF6F28A24FFBBBFF008F /* SnowflakeBindingEncoder.swift in Sources */, + FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */, + 8F6387B85F8B4AEE8210A6F1 /* SnowflakeIdTokenStore.swift in Sources */, 952FCF406F3DE2575166802B /* SnowflakeBrowserAuthServer.swift in Sources */, B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */, 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index fa09ff50a..64747ecaa 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -348,7 +348,7 @@ extension PluginMetadataRegistry { ExplainVariant(id: "text", label: "Explain (Text)", sqlPrefix: "EXPLAIN USING TEXT") ], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: [], + supportsHealthMonitor: false, urlSchemes: [], postConnectActions: [.selectSchemaFromLastSession], brandColorHex: "#29B5E8", queryLanguageName: "SQL", editorLanguage: .sql, @@ -377,7 +377,7 @@ extension PluginMetadataRegistry { systemSchemaNames: ["INFORMATION_SCHEMA"], fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment] + structureColumnFields: [.name, .type, .nullable, .defaultValue] ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: SQLDialectDescriptor( diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index f6927a030..0e204b586 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -24,6 +24,10 @@ struct ColumnTypeClassifier { return factory(rawTypeName) } + if params == nil, ["VARIANT", "OBJECT", "ARRAY"].contains(upper) { + return .json(rawType: rawTypeName) + } + return classifyByPattern(upper: upper, rawTypeName: rawTypeName) } diff --git a/TableProTests/PluginTestSources/SnowflakeBindingEncoder.swift b/TableProTests/PluginTestSources/SnowflakeBindingEncoder.swift new file mode 120000 index 000000000..23d449d60 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeBindingEncoder.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeHTTPRetry.swift b/TableProTests/PluginTestSources/SnowflakeHTTPRetry.swift new file mode 120000 index 000000000..ff7ed0cd4 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeHTTPRetry.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeHeartbeat.swift b/TableProTests/PluginTestSources/SnowflakeHeartbeat.swift new file mode 120000 index 000000000..8768ce397 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeHeartbeat.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift \ No newline at end of file diff --git a/TableProTests/Plugins/SnowflakeProtocolTests.swift b/TableProTests/Plugins/SnowflakeProtocolTests.swift new file mode 100644 index 000000000..e2060ba00 --- /dev/null +++ b/TableProTests/Plugins/SnowflakeProtocolTests.swift @@ -0,0 +1,119 @@ +// +// SnowflakeProtocolTests.swift +// TableProTests +// +// Tests for the Snowflake binding encoder, HTTP retry policy, re-auth code +// classification, and heartbeat interval (compiled via symlinks from +// SnowflakeDriverPlugin). +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Snowflake Binding Encoder") +struct SnowflakeBindingEncoderTests { + @Test("Keys are 1-based string indices") + func testKeysAreOneBased() { + let bindings = SnowflakeBindingEncoder.encode([.text("a"), .text("b")]) + #expect(Set(bindings.keys) == ["1", "2"]) + } + + @Test("Text values bind as TEXT") + func testTextBinding() { + let bindings = SnowflakeBindingEncoder.encode([.text("O'Brien \\ path")]) + #expect(bindings["1"]?["type"] as? String == "TEXT") + #expect(bindings["1"]?["value"] as? String == "O'Brien \\ path") + } + + @Test("Bytes bind as BINARY hex") + func testBinaryBinding() { + let bindings = SnowflakeBindingEncoder.encode([.bytes(Data([0xAB, 0x01, 0xFF]))]) + #expect(bindings["1"]?["type"] as? String == "BINARY") + #expect(bindings["1"]?["value"] as? String == "AB01FF") + } + + @Test("Null binds as a typed null") + func testNullBinding() { + let bindings = SnowflakeBindingEncoder.encode([.null]) + #expect(bindings["1"]?["type"] as? String == "TEXT") + #expect(bindings["1"]?["value"] is NSNull) + } + + @Test("Encoded payload serializes as JSON") + func testJSONSerializable() { + let bindings = SnowflakeBindingEncoder.encode([.text("x"), .null, .bytes(Data([0x00]))]) + #expect(JSONSerialization.isValidJSONObject(bindings)) + } +} + +@Suite("Snowflake Retry Policy") +struct SnowflakeRetryPolicyTests { + @Test("Transient statuses are retried") + func testTransientStatuses() { + #expect(SnowflakeRetryPolicy.isTransient(statusCode: 500)) + #expect(SnowflakeRetryPolicy.isTransient(statusCode: 503)) + #expect(SnowflakeRetryPolicy.isTransient(statusCode: 429)) + #expect(SnowflakeRetryPolicy.isTransient(statusCode: 408)) + } + + @Test("Application errors are not retried") + func testTerminalStatuses() { + #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 200)) + #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 400)) + #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 401)) + #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 403)) + } + + @Test("Retried URL tags the attempt and keeps the request id") + func testRetriedURLTagging() throws { + let url = try #require(URL(string: "https://x.snowflakecomputing.com/queries/v1/query-request?requestId=abc&request_guid=old")) + let retried = SnowflakeRetryPolicy.retriedURL(url, retryCount: 2, retryReason: 503, clientStartTime: 1_700_000) + let components = try #require(URLComponents(url: retried, resolvingAgainstBaseURL: false)) + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(items["requestId"] == "abc") + #expect(items["retryCount"] == "2") + #expect(items["retryReason"] == "503") + #expect(items["clientStartTime"] == "1700000") + #expect(items["request_guid"] != "old") + } + + @Test("Delay stays within the jitter bounds") + func testDelayBounds() { + var generator = SystemRandomNumberGenerator() + var delay = SnowflakeRetryPolicy.baseDelay + for _ in 0..<20 { + delay = SnowflakeRetryPolicy.nextDelay(after: delay, using: &generator) + #expect(delay >= SnowflakeRetryPolicy.baseDelay) + #expect(delay <= SnowflakeRetryPolicy.maxDelay) + } + } +} + +@Suite("Snowflake Re-Auth Classification") +struct SnowflakeReAuthTests { + @Test("Session and token expiry codes trigger re-authentication") + func testReauthCodes() { + for code in ["390110", "390112", "390113", "390114", "390115", "390195"] { + #expect(SnowflakeError.isReauthenticationCode(code)) + } + } + + @Test("MFA and credential failures are terminal") + func testTerminalCodes() { + for code in ["394507", "394508", "394512", "390100", ""] { + #expect(!SnowflakeError.isReauthenticationCode(code)) + } + } +} + +@Suite("Snowflake Heartbeat Interval") +struct SnowflakeHeartbeatIntervalTests { + @Test("Interval is a quarter of master validity, clamped to 15 to 60 minutes") + func testIntervalClamping() { + #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 14_400) == 3_600) + #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 7_200) == 1_800) + #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 600) == 900) + #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 100_000) == 3_600) + } +} From 39900296d6e7a0f62481c6778319f083d1e57dc5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 04:41:30 +0700 Subject: [PATCH 08/14] feat(plugin-snowflake): warehouse-free metadata, key introspection, structure editing, import, streaming, and multi-statement scripts --- CHANGELOG.md | 2 +- .../SnowflakeConnection.swift | 135 ++++++++++- .../SnowflakeDDLGenerator.swift | 154 ++++++++++++ .../SnowflakePlugin.swift | 17 +- .../SnowflakePluginDriver+DDL.swift | 74 ++++++ .../SnowflakePluginDriver.swift | 226 +++++++++++++++--- .../SnowflakeSchemaQueries.swift | 86 +++++++ .../SnowflakeStatementGenerator.swift | 135 +++++++++++ TablePro.xcodeproj/project.pbxproj | 32 ++- ...PluginMetadataRegistry+CloudDefaults.swift | 6 +- .../SnowflakeDDLGenerator.swift | 1 + .../SnowflakeSchemaQueries.swift | 1 + .../SnowflakeStatementGenerator.swift | 1 + .../Plugins/SnowflakeGeneratorTests.swift | 222 +++++++++++++++++ docs/databases/snowflake.mdx | 8 +- 15 files changed, 1037 insertions(+), 63 deletions(-) create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+DDL.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeDDLGenerator.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeSchemaQueries.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeStatementGenerator.swift create mode 100644 TableProTests/Plugins/SnowflakeGeneratorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b31b1ebef..a76bd9ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Snowflake support: connect with username & password (including MFA TOTP passcodes and cached MFA tokens), key-pair (.p8), browser SSO, or a programmatic access token; browse databases, schemas, and tables; run Snowflake SQL with full result sets, query cancellation, and automatic session renewal. Connections defined in the Snowflake CLI's config files can be reused by name. (#1420) +- Snowflake support: connect with username & password (including MFA TOTP passcodes and cached MFA tokens), key-pair (.p8), browser SSO with a cached sign-in token, or a programmatic access token; browse databases, schemas, and tables without waking a warehouse; edit rows with server-side bind parameters, including VARIANT, OBJECT, and ARRAY cells through the JSON editor; import CSV and JSON files; edit table structure within Snowflake's rules (add, rename, widen, drop columns, primary keys, comments); see declared primary keys, foreign keys, and clustering keys; run multi-statement scripts; sessions stay alive with a background heartbeat. Connections defined in the Snowflake CLI's config files can be reused by name. (#1420) - Import data from CSV and TSV files into a table: map columns to an existing table or create a new one, with options for delimiter, quote character, encoding, header row, and empty/NULL handling. (#1568) - SQL autocomplete completes database, schema, and table names at each segment of qualified names for schema-organized connections (Snowflake, BigQuery), fetches tables of unopened schemas on demand, resolves alias columns for schema-qualified tables, and suggests the active connection's full dialect function list. - Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index 84be80d66..3f3273b27 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -388,15 +388,21 @@ final class SnowflakeConnection: @unchecked Sendable { // MARK: - Query Execution func query(_ sql: String, parameters: [PluginCellValue] = []) async throws -> SnowflakeQueryResult { + try await withReauthentication { + try await performQuery(sql, parameters: parameters) + } + } + + private func withReauthentication(_ operation: () async throws -> T) async throws -> T { do { - return try await performQuery(sql, parameters: parameters) + return try await operation() } catch SnowflakeError.queryFailed(let code, _) where SnowflakeError.isReauthenticationCode(code) { do { try await renewSession() } catch { try await connect() } - return try await performQuery(sql, parameters: parameters) + return try await operation() } } @@ -416,6 +422,18 @@ final class SnowflakeConnection: @unchecked Sendable { } private func performQuery(_ sql: String, parameters: [PluginCellValue] = []) async throws -> SnowflakeQueryResult { + let (data, token) = try await submitQuery(sql, parameters: parameters) + if let resultIds = data["resultIds"] as? String, !resultIds.isEmpty { + return try await collectMultiStatementResults(ids: resultIds, token: token) + } + applyFinalSessionInfo(data) + return try await buildResult(from: data, token: token) + } + + private func submitQuery( + _ sql: String, + parameters: [PluginCellValue] + ) async throws -> (data: [String: Any], token: String) { guard let token = lock.withLock({ sessionToken }) else { throw SnowflakeError.notConnected } @@ -438,6 +456,8 @@ final class SnowflakeConnection: @unchecked Sendable { ] if !parameters.isEmpty { body["bindings"] = SnowflakeBindingEncoder.encode(parameters) + } else if SnowflakeSchemaQueries.isLikelyMultiStatement(sql) { + body["parameters"] = ["MULTI_STATEMENT_COUNT": 0] } var response = try await postJSON( @@ -458,9 +478,30 @@ final class SnowflakeConnection: @unchecked Sendable { guard let data = response["data"] as? [String: Any] else { throw SnowflakeError.invalidResponse("Query response had no data") } + return (data, token) + } - applyFinalSessionInfo(data) - return try await buildResult(from: data, token: token) + private func collectMultiStatementResults(ids: String, token: String) async throws -> SnowflakeQueryResult { + var combinedAffected = 0 + var last: SnowflakeQueryResult? + for id in ids.components(separatedBy: ",") where !id.isEmpty { + var response = try await getJSON(path: "/queries/\(id)/result", token: token) + response = try await pollIfInProgress(response, token: token) + guard (response["success"] as? Bool) == true, + let data = response["data"] as? [String: Any] else { + let message = response["message"] as? String ?? "Statement failed" + throw SnowflakeError.queryFailed(code: Self.codeString(response["code"]), message: message) + } + applyFinalSessionInfo(data) + let result = try await buildResult(from: data, token: token) + combinedAffected += result.affectedRows + last = result + } + guard var result = last else { + throw SnowflakeError.invalidResponse("Multi-statement response had no results") + } + result.affectedRows = combinedAffected + return result } private static func trackingQueryItems(requestID: String = UUID().uuidString.lowercased()) -> [URLQueryItem] { @@ -491,6 +532,15 @@ final class SnowflakeConnection: @unchecked Sendable { return response } + private static func chunkRequestHeaders(from data: [String: Any]) -> [String: String] { + var headers = data["chunkHeaders"] as? [String: String] ?? [:] + if headers.isEmpty, let qrmk = data["qrmk"] as? String { + headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256" + headers["x-amz-server-side-encryption-customer-key"] = qrmk + } + return headers + } + private func buildResult(from data: [String: Any], token: String) async throws -> SnowflakeQueryResult { let columns = Self.parseColumns(data["rowtype"] as? [[String: Any]] ?? []) @@ -502,12 +552,7 @@ final class SnowflakeConnection: @unchecked Sendable { let affectedRows = Self.extractAffectedRows(columns: columns, rows: rows) if let chunks = data["chunks"] as? [[String: Any]], !chunks.isEmpty { - var headers = data["chunkHeaders"] as? [String: String] ?? [:] - if headers.isEmpty, let qrmk = data["qrmk"] as? String { - headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256" - headers["x-amz-server-side-encryption-customer-key"] = qrmk - } - rows.append(contentsOf: try await downloadChunks(chunks, headers: headers)) + rows.append(contentsOf: try await downloadChunks(chunks, headers: Self.chunkRequestHeaders(from: data))) } return SnowflakeQueryResult( @@ -519,6 +564,76 @@ final class SnowflakeConnection: @unchecked Sendable { ) } + struct StreamedResult { + let columns: [SnowflakeColumnMeta] + let inlineRows: [[PluginCellValueBox]] + let estimatedRowCount: Int + let batches: AsyncThrowingStream<[[PluginCellValueBox]], Error> + } + + func queryStreamed(_ sql: String) async throws -> StreamedResult { + let (data, _) = try await withReauthentication { + try await submitQuery(sql, parameters: []) + } + applyFinalSessionInfo(data) + + let columns = Self.parseColumns(data["rowtype"] as? [[String: Any]] ?? []) + let inlineRows = (data["rowset"] as? [[Any]] ?? []).map { row in row.map(Self.box) } + let chunks = data["chunks"] as? [[String: Any]] ?? [] + let chunkRowCount = chunks.reduce(0) { $0 + (($1["rowCount"] as? Int) ?? 0) } + let headers = Self.chunkRequestHeaders(from: data) + let urls = chunks.compactMap { ($0["url"] as? String).flatMap(URL.init(string:)) } + + let stream = AsyncThrowingStream<[[PluginCellValueBox]], Error> { continuation in + guard !urls.isEmpty else { + continuation.finish() + return + } + let task = Task { [session] in + do { + var buffer: [Int: [[PluginCellValueBox]]] = [:] + var nextToYield = 0 + try await withThrowingTaskGroup(of: (Int, [[PluginCellValueBox]]).self) { group in + var nextIndex = 0 + while nextIndex < min(Self.chunkDownloadWorkers, urls.count) { + let index = nextIndex + group.addTask { + (index, try await Self.downloadChunk(urls[index], headers: headers, session: session)) + } + nextIndex += 1 + } + while let (index, rows) = try await group.next() { + buffer[index] = rows + while let ready = buffer[nextToYield] { + buffer[nextToYield] = nil + continuation.yield(ready) + nextToYield += 1 + } + if nextIndex < urls.count { + let next = nextIndex + group.addTask { + (next, try await Self.downloadChunk(urls[next], headers: headers, session: session)) + } + nextIndex += 1 + } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + + return StreamedResult( + columns: columns, + inlineRows: inlineRows, + estimatedRowCount: inlineRows.count + chunkRowCount, + batches: stream + ) + } + private static let chunkDownloadWorkers = 4 private func downloadChunks(_ chunks: [[String: Any]], headers: [String: String]) async throws -> [[PluginCellValueBox]] { diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift new file mode 100644 index 000000000..0b1eefddb --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift @@ -0,0 +1,154 @@ +// +// SnowflakeDDLGenerator.swift +// SnowflakeDriverPlugin +// +// Generates ALTER TABLE and CREATE TABLE DDL within Snowflake's documented +// limits: VARCHAR can only widen, NUMBER can only change precision (same +// scale), no cross-type changes. Unsupported changes return nil so the app +// reports them instead of failing server-side. +// + +import Foundation +import TableProPluginKit + +struct SnowflakeDDLGenerator { + let qualifiedTable: (String) -> String + + func addColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(qualifiedTable(table)) ADD COLUMN \(columnDefinitionSQL(column))" + } + + func dropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(qualifiedTable(table)) DROP COLUMN \(quoteIdentifier(columnName))" + } + + func modifyColumnSQL(table: String, old: PluginColumnDefinition, new: PluginColumnDefinition) -> String? { + let target = qualifiedTable(table) + var statements: [String] = [] + + if old.name != new.name { + statements.append( + "ALTER TABLE \(target) RENAME COLUMN \(quoteIdentifier(old.name)) TO \(quoteIdentifier(new.name))" + ) + } + + var actions: [String] = [] + let column = quoteIdentifier(new.name) + + if normalizedType(old.dataType) != normalizedType(new.dataType) { + guard isSupportedTypeChange(from: old.dataType, to: new.dataType) else { return nil } + actions.append("COLUMN \(column) SET DATA TYPE \(new.dataType)") + } + if old.isNullable != new.isNullable { + actions.append("COLUMN \(column) \(new.isNullable ? "DROP NOT NULL" : "SET NOT NULL")") + } + if old.comment != new.comment { + if let comment = new.comment, !comment.isEmpty { + actions.append("COLUMN \(column) COMMENT '\(escapeLiteral(comment))'") + } else { + actions.append("COLUMN \(column) UNSET COMMENT") + } + } + if old.defaultValue != new.defaultValue, new.defaultValue == nil { + actions.append("COLUMN \(column) DROP DEFAULT") + } else if old.defaultValue != new.defaultValue { + return nil + } + + if !actions.isEmpty { + statements.append("ALTER TABLE \(target) ALTER \(actions.joined(separator: ", "))") + } + guard !statements.isEmpty else { return nil } + return statements.joined(separator: ";\n") + } + + func modifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String]) -> [String]? { + let target = qualifiedTable(table) + var statements: [String] = [] + if !oldColumns.isEmpty { + statements.append("ALTER TABLE \(target) DROP PRIMARY KEY") + } + if !newColumns.isEmpty { + let columns = newColumns.map(quoteIdentifier).joined(separator: ", ") + statements.append("ALTER TABLE \(target) ADD PRIMARY KEY (\(columns))") + } + return statements.isEmpty ? nil : statements + } + + func createTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + var parts = definition.columns.map(columnDefinitionSQL) + let pkColumns = definition.primaryKeyColumns.isEmpty + ? definition.columns.filter(\.isPrimaryKey).map(\.name) + : definition.primaryKeyColumns + if !pkColumns.isEmpty { + parts.append("PRIMARY KEY (\(pkColumns.map(quoteIdentifier).joined(separator: ", ")))") + } + let ifNotExists = definition.ifNotExists ? "IF NOT EXISTS " : "" + return "CREATE TABLE \(ifNotExists)\(qualifiedTable(definition.tableName)) (\n \(parts.joined(separator: ",\n "))\n)" + } + + func columnDefinitionSQL(_ column: PluginColumnDefinition) -> String { + var definition = "\(quoteIdentifier(column.name)) \(column.dataType)" + if column.autoIncrement { + definition += " AUTOINCREMENT" + } else if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + definition += " DEFAULT \(defaultValue)" + } + if !column.isNullable { + definition += " NOT NULL" + } + if let comment = column.comment, !comment.isEmpty { + definition += " COMMENT '\(escapeLiteral(comment))'" + } + return definition + } + + static func isSupportedTypeChange(from oldType: String, to newType: String) -> Bool { + let old = parse(oldType) + let new = parse(newType) + guard old.base == new.base else { return false } + + switch old.base { + case "VARCHAR", "STRING", "TEXT", "CHAR", "CHARACTER": + let oldLength = old.arguments.first ?? 16_777_216 + let newLength = new.arguments.first ?? 16_777_216 + return newLength >= oldLength + case "NUMBER", "DECIMAL", "NUMERIC": + let oldScale = old.arguments.count > 1 ? old.arguments[1] : 0 + let newScale = new.arguments.count > 1 ? new.arguments[1] : 0 + return oldScale == newScale + default: + return false + } + } + + private func isSupportedTypeChange(from oldType: String, to newType: String) -> Bool { + Self.isSupportedTypeChange(from: oldType, to: newType) + } + + private static func parse(_ type: String) -> (base: String, arguments: [Int]) { + let upper = type.uppercased().trimmingCharacters(in: .whitespaces) + guard let parenIndex = upper.firstIndex(of: "("), upper.hasSuffix(")") else { + return (upper, []) + } + let base = String(upper[.. String { + type.uppercased().replacingOccurrences(of: " ", with: "") + } + + private func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private func escapeLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index e42249307..7d92ed25c 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -34,19 +34,20 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#29B5E8" static let queryLanguageName = "SQL" static let editorLanguage: EditorLanguage = .sql - static let supportsForeignKeys = false - static let supportsSchemaEditing = false - static let supportsAddColumn = false - static let supportsModifyColumn = false - static let supportsDropColumn = false + static let supportsForeignKeys = true + static let supportsSchemaEditing = true + static let supportsAddColumn = true + static let supportsModifyColumn = true + static let supportsDropColumn = true + static let supportsRenameColumn = true static let supportsAddIndex = false static let supportsDropIndex = false - static let supportsModifyPrimaryKey = false + static let supportsModifyPrimaryKey = true static let supportsHealthMonitor = false static let supportsDatabaseSwitching = true static let supportsSchemaSwitching = true static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] - static let supportsImport = false + static let supportsImport = true static let supportsExport = true static let supportsSSH = false static let supportsSSL = false @@ -56,7 +57,7 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema static let defaultGroupName = "default" static let defaultPrimaryKeyColumn: String? = nil - static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] static let supportsCascadeDrop = true static let supportsDropDatabase = true diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+DDL.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+DDL.swift new file mode 100644 index 000000000..af1783a1f --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+DDL.swift @@ -0,0 +1,74 @@ +// +// SnowflakePluginDriver+DDL.swift +// SnowflakeDriverPlugin +// +// DML statement generation for grid saves and DDL generation for the table +// structure editor. +// + +import Foundation +import TableProPluginKit + +extension SnowflakePluginDriver { + func generateStatements( + table: String, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let generator = SnowflakeStatementGenerator( + qualifiedTable: qualifiedName(table: table, schema: nil), + columns: columns, + columnTypeNames: columnTypeNames(for: table, columns: columns), + primaryKeyColumns: primaryKeyColumns + ) + return generator.generateStatements( + from: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + private var ddlGenerator: SnowflakeDDLGenerator { + SnowflakeDDLGenerator(qualifiedTable: { [weak self] table in + self?.qualifiedName(table: table, schema: nil) ?? table + }) + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + ddlGenerator.addColumnSQL(table: table, column: column) + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + ddlGenerator.modifyColumnSQL(table: table, old: oldColumn, new: newColumn) + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + ddlGenerator.dropColumnSQL(table: table, columnName: columnName) + } + + func generateModifyPrimaryKeySQL( + table: String, + oldColumns: [String], + newColumns: [String], + constraintName: String? + ) -> [String]? { + ddlGenerator.modifyPrimaryKeySQL(table: table, oldColumns: oldColumns, newColumns: newColumns) + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + ddlGenerator.createTableSQL(definition: definition) + } + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + ddlGenerator.columnDefinitionSQL(column) + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 51d35f751..139ac3086 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -15,6 +15,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var _connection: SnowflakeConnection? private var _serverVersion: String? private var resolvedSchemaCache: [String: String] = [:] + private var columnTypeCache: [String: [String: String]] = [:] private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakePluginDriver") @@ -27,7 +28,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } var capabilities: PluginCapabilities { - [.multiSchema, .transactions, .truncateTable, .cancelQuery] + [.multiSchema, .transactions, .truncateTable, .cancelQuery, .parameterizedQueries, .alterTableDDL] } func cancelQuery() throws { @@ -135,23 +136,52 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func switchDatabase(to database: String) async throws { guard let conn = connection else { throw SnowflakeError.notConnected } try await conn.switchDatabase(to: database) - lock.withLock { resolvedSchemaCache.removeAll() } + lock.withLock { + resolvedSchemaCache.removeAll() + columnTypeCache.removeAll() + } } func switchSchema(to schema: String) async throws { guard let conn = connection else { throw SnowflakeError.notConnected } try await conn.switchSchema(to: schema) - lock.withLock { resolvedSchemaCache.removeAll() } + lock.withLock { + resolvedSchemaCache.removeAll() + columnTypeCache.removeAll() + } } // MARK: - Database Management func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { - PluginCreateDatabaseFormSpec(fields: [], footnote: nil) + PluginCreateDatabaseFormSpec( + fields: [ + PluginCreateDatabaseFormSpec.Field( + id: "retentionDays", + label: String(localized: "Time Travel Retention"), + kind: .picker( + options: [ + .init(value: "1", label: String(localized: "1 day (default)")), + .init(value: "0", label: String(localized: "Disabled")), + .init(value: "7", label: String(localized: "7 days")), + .init(value: "14", label: String(localized: "14 days")), + .init(value: "30", label: String(localized: "30 days")), + .init(value: "90", label: String(localized: "90 days")) + ], + defaultValue: "1" + ) + ) + ], + footnote: String(localized: "Retention beyond 1 day requires Enterprise edition.") + ) } func createDatabase(_ request: PluginCreateDatabaseRequest) async throws { - _ = try await rawQuery("CREATE DATABASE \(quoteIdentifier(request.name))") + var sql = "CREATE DATABASE \(quoteIdentifier(request.name))" + if let retention = request.values["retentionDays"], let days = Int(retention), days != 1 { + sql += " DATA_RETENTION_TIME_IN_DAYS = \(days)" + } + _ = try await rawQuery(sql) } func dropDatabase(name: String) async throws { @@ -165,24 +195,65 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let targetSchema = schema ?? connection?.currentSchema guard let database, let targetSchema else { return [] } - let sql = """ - SELECT TABLE_NAME, TABLE_TYPE - FROM \(quoteIdentifier(database)).INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = '\(escapeStringLiteral(targetSchema))' - ORDER BY TABLE_NAME - """ - let result = try await rawQuery(sql) + let result = try await rawQuery(SnowflakeSchemaQueries.showObjects(database: database, schema: targetSchema)) + guard let nameIndex = columnIndex(of: "name", in: result) else { return [] } + let kindIndex = columnIndex(of: "kind", in: result) return result.rows.compactMap { row in - guard let name = Self.text(row, 0) else { return nil } - let rawType = (Self.text(row, 1) ?? "BASE TABLE").uppercased() - let type: String - switch rawType { - case "VIEW": type = "VIEW" - case "MATERIALIZED VIEW": type = "MATERIALIZED_VIEW" - default: type = "TABLE" - } - return PluginTableInfo(name: name, type: type, schema: targetSchema) + guard let name = Self.text(row, nameIndex) else { return nil } + let kind = kindIndex.flatMap { Self.text(row, $0) } ?? "TABLE" + return PluginTableInfo( + name: name, + type: SnowflakeSchemaQueries.objectType(forKind: kind), + schema: targetSchema + ) + }.sorted { $0.name < $1.name } + } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return [:] } + + let primaryKeys = await primaryKeysBySchema(database: database, schema: targetSchema) + let result = try await rawQuery(SnowflakeSchemaQueries.bulkColumns(database: database, schema: targetSchema)) + + var columnsByTable: [String: [PluginColumnInfo]] = [:] + for row in result.rows { + guard let table = Self.text(row, 0), let name = Self.text(row, 1) else { continue } + let column = PluginColumnInfo( + name: name, + dataType: Self.text(row, 2) ?? "TEXT", + isNullable: (Self.text(row, 3) ?? "YES").uppercased() == "YES", + isPrimaryKey: primaryKeys[table]?.contains(name.uppercased()) == true, + defaultValue: Self.text(row, 4), + comment: Self.text(row, 5) + ) + columnsByTable[table, default: []].append(column) } + for (table, columns) in columnsByTable { + cacheColumnTypes(table: table, columns: columns) + } + return columnsByTable + } + + private func primaryKeysBySchema(database: String, schema: String) async -> [String: Set] { + guard let result = try? await rawQuery( + SnowflakeSchemaQueries.showPrimaryKeysInSchema(database: database, schema: schema) + ) else { return [:] } + guard let tableIndex = columnIndex(of: "table_name", in: result), + let columnIndexInResult = columnIndex(of: "column_name", in: result) else { return [:] } + + var keys: [String: Set] = [:] + for row in result.rows { + guard let table = Self.text(row, tableIndex), + let column = Self.text(row, columnIndexInResult) else { continue } + keys[table, default: []].insert(column.uppercased()) + } + return keys + } + + private func columnIndex(of name: String, in result: SnowflakeQueryResult) -> Int? { + result.columns.firstIndex { $0.name.lowercased() == name.lowercased() } } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { @@ -210,7 +281,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { Self.logger.debug( "fetchColumns table=\(table, privacy: .public) schema=\(targetSchema, privacy: .public) database=\(database, privacy: .public) rows=\(result.rows.count, privacy: .public)" ) - return result.rows.compactMap { row in + let columns = result.rows.compactMap { row -> PluginColumnInfo? in guard let name = Self.text(row, 0) else { return nil } let dataType = Self.text(row, 1) ?? "TEXT" let nullable = (Self.text(row, 2) ?? "YES").uppercased() == "YES" @@ -225,14 +296,104 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { comment: comment ) } + cacheColumnTypes(table: table, columns: columns) + return columns + } + + func cacheColumnTypes(table: String, columns: [PluginColumnInfo]) { + let types = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0.dataType) }) + lock.withLock { columnTypeCache[table] = types } + } + + func columnTypeNames(for table: String, columns: [String]) -> [String] { + let types = lock.withLock { columnTypeCache[table] } ?? [:] + return columns.map { types[$0] ?? "TEXT" } } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - [] + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return [] } + + var indexes: [PluginIndexInfo] = [] + + let primaryKeys = try await fetchPrimaryKeyColumnsOrdered(table: table, schema: targetSchema, database: database) + if !primaryKeys.isEmpty { + indexes.append(PluginIndexInfo( + name: "PRIMARY KEY", + columns: primaryKeys, + isUnique: true, + isPrimary: true, + type: "CONSTRAINT" + )) + } + + if let clusterColumns = await clusteringKeyColumns(table: table, schema: targetSchema, database: database), + !clusterColumns.isEmpty { + indexes.append(PluginIndexInfo( + name: "CLUSTERING KEY", + columns: clusterColumns, + isUnique: false, + isPrimary: false, + type: "CLUSTERING" + )) + } + + return indexes + } + + private func fetchPrimaryKeyColumnsOrdered(table: String, schema: String, database: String) async throws -> [String] { + guard let result = try? await rawQuery( + SnowflakeSchemaQueries.showPrimaryKeysInTable(database: database, schema: schema, table: table) + ) else { return [] } + guard let columnIdx = columnIndex(of: "column_name", in: result) else { return [] } + let sequenceIdx = columnIndex(of: "key_sequence", in: result) + let entries = result.rows.compactMap { row -> (column: String, sequence: Int)? in + guard let column = Self.text(row, columnIdx) else { return nil } + let sequence = sequenceIdx.flatMap { Self.text(row, $0) }.flatMap(Int.init) ?? 0 + return (column, sequence) + } + return entries.sorted { $0.sequence < $1.sequence }.map(\.column) + } + + private func clusteringKeyColumns(table: String, schema: String, database: String) async -> [String]? { + guard let result = try? await rawQuery( + SnowflakeSchemaQueries.showTablesLike(database: database, schema: schema, table: table) + ) else { return nil } + guard let clusterIdx = columnIndex(of: "cluster_by", in: result), + let row = result.rows.first, + let clusterBy = Self.text(row, clusterIdx), + !clusterBy.isEmpty else { return nil } + return SnowflakeSchemaQueries.parseClusterBy(clusterBy) } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { - [] + let database = connection?.currentDatabase + let targetSchema = schema ?? connection?.currentSchema + guard let database, let targetSchema else { return [] } + + guard let result = try? await rawQuery( + SnowflakeSchemaQueries.showImportedKeys(database: database, schema: targetSchema, table: table) + ) else { return [] } + + guard let fkColumnIdx = columnIndex(of: "fk_column_name", in: result), + let pkTableIdx = columnIndex(of: "pk_table_name", in: result), + let pkColumnIdx = columnIndex(of: "pk_column_name", in: result) else { return [] } + let fkNameIdx = columnIndex(of: "fk_name", in: result) + let pkSchemaIdx = columnIndex(of: "pk_schema_name", in: result) + + return result.rows.compactMap { row in + guard let column = Self.text(row, fkColumnIdx), + let referencedTable = Self.text(row, pkTableIdx), + let referencedColumn = Self.text(row, pkColumnIdx) else { return nil } + return PluginForeignKeyInfo( + name: fkNameIdx.flatMap { Self.text(row, $0) } ?? "FK_\(column)", + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn, + referencedSchema: pkSchemaIdx.flatMap { Self.text(row, $0) } + ) + } } func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { @@ -341,14 +502,17 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { guard let conn = self.connection else { throw SnowflakeError.notConnected } let trimmed = query.replacingOccurrences(of: ";\\s*\\z", with: "", options: .regularExpression) - let result = try await conn.query(trimmed) + let streamed = try await conn.queryStreamed(trimmed) continuation.yield(.header(PluginStreamHeader( - columns: result.columns.map(\.name), - columnTypeNames: result.columns.map(SnowflakeTypeMapper.displayType), - estimatedRowCount: result.rows.count + columns: streamed.columns.map(\.name), + columnTypeNames: streamed.columns.map(SnowflakeTypeMapper.displayType), + estimatedRowCount: streamed.estimatedRowCount ))) - if !result.rows.isEmpty { - continuation.yield(.rows(result.rows.map { row in row.map(Self.cellValue) })) + if !streamed.inlineRows.isEmpty { + continuation.yield(.rows(streamed.inlineRows.map { row in row.map(Self.cellValue) })) + } + for try await batch in streamed.batches { + continuation.yield(.rows(batch.map { row in row.map(Self.cellValue) })) } continuation.finish() } catch { @@ -361,7 +525,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Private Helpers - private func qualifiedName(table: String, schema: String?) -> String { + func qualifiedName(table: String, schema: String?) -> String { qualifiedName(table: table, resolvedSchema: schema ?? connection?.currentSchema) } diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift new file mode 100644 index 000000000..82fa1f83c --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift @@ -0,0 +1,86 @@ +// +// SnowflakeSchemaQueries.swift +// SnowflakeDriverPlugin +// +// SQL builders and result parsing for SHOW-based introspection. SHOW commands +// run against the metadata service and need no running warehouse, unlike +// INFORMATION_SCHEMA queries. +// + +import Foundation + +enum SnowflakeSchemaQueries { + static func showObjects(database: String, schema: String) -> String { + "SHOW TERSE OBJECTS IN SCHEMA \(qualified(database, schema))" + } + + static func showPrimaryKeysInSchema(database: String, schema: String) -> String { + "SHOW PRIMARY KEYS IN SCHEMA \(qualified(database, schema))" + } + + static func showPrimaryKeysInTable(database: String, schema: String, table: String) -> String { + "SHOW PRIMARY KEYS IN TABLE \(qualified(database, schema, table))" + } + + static func showImportedKeys(database: String, schema: String, table: String) -> String { + "SHOW IMPORTED KEYS IN TABLE \(qualified(database, schema, table))" + } + + static func showTablesLike(database: String, schema: String, table: String) -> String { + "SHOW TABLES LIKE '\(escapeLikePattern(table))' IN SCHEMA \(qualified(database, schema))" + } + + static func bulkColumns(database: String, schema: String) -> String { + """ + SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COMMENT, + NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH + FROM \(quote(database)).INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '\(escapeLiteral(schema))' + ORDER BY TABLE_NAME, ORDINAL_POSITION + """ + } + + static func objectType(forKind kind: String) -> String { + let upper = kind.uppercased() + if upper.contains("MATERIALIZED") { return "MATERIALIZED_VIEW" } + if upper.contains("VIEW") { return "VIEW" } + return "TABLE" + } + + static func parseClusterBy(_ clusterBy: String) -> [String] { + var inner = clusterBy.trimmingCharacters(in: .whitespaces) + if inner.uppercased().hasPrefix("LINEAR("), inner.hasSuffix(")") { + inner = String(inner.dropFirst("LINEAR(".count).dropLast()) + } + return inner + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: " \"")) } + .filter { !$0.isEmpty } + } + + static func isLikelyMultiStatement(_ sql: String) -> Bool { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard let index = trimmed.firstIndex(of: ";") else { return false } + return trimmed.index(after: index) != trimmed.endIndex + } + + static func escapeLikePattern(_ value: String) -> String { + escapeLiteral(value) + .replacingOccurrences(of: "_", with: "\\\\_") + .replacingOccurrences(of: "%", with: "\\\\%") + } + + static func escapeLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + } + + static func quote(_ identifier: String) -> String { + "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private static func qualified(_ parts: String...) -> String { + parts.map(quote).joined(separator: ".") + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift new file mode 100644 index 000000000..07c16f34e --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift @@ -0,0 +1,135 @@ +// +// SnowflakeStatementGenerator.swift +// SnowflakeDriverPlugin +// +// Generates Snowflake DML from tracked grid changes using server-side bind +// placeholders. Semi-structured columns (VARIANT, OBJECT, ARRAY) bind through +// PARSE_JSON, which Snowflake rejects inside a VALUES clause, so inserts that +// touch them use the INSERT INTO ... SELECT form. +// + +import Foundation +import TableProPluginKit + +struct SnowflakeStatementGenerator { + let qualifiedTable: String + let columns: [String] + let columnTypeNames: [String] + let primaryKeyColumns: [String] + + func generateStatements( + from changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])] { + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex), + let statement = insertStatement(for: change, insertedRowData: insertedRowData) else { continue } + statements.append(statement) + case .update: + guard let statement = updateStatement(for: change) else { continue } + statements.append(statement) + case .delete: + guard deletedRowIndices.contains(change.rowIndex), + let statement = deleteStatement(for: change) else { continue } + statements.append(statement) + @unknown default: + continue + } + } + return statements + } + + static func isSemiStructured(_ typeName: String) -> Bool { + let base = typeName.uppercased().components(separatedBy: "(")[0].trimmingCharacters(in: .whitespaces) + return ["VARIANT", "OBJECT", "ARRAY"].contains(base) + } + + private func insertStatement( + for change: PluginRowChange, + insertedRowData: [Int: [PluginCellValue]] + ) -> (statement: String, parameters: [PluginCellValue])? { + var values: [(column: String, value: PluginCellValue)] = [] + if let rowData = insertedRowData[change.rowIndex] { + for (index, column) in columns.enumerated() where index < rowData.count { + values.append((column, rowData[index])) + } + } else { + for cellChange in change.cellChanges { + values.append((cellChange.columnName, cellChange.newValue)) + } + } + guard !values.isEmpty else { return nil } + + let names = values.map { quoteIdentifier($0.column) } + let placeholders = values.map { placeholder(for: $0.column) } + let parameters = values.map(\.value) + let usesSelect = values.contains { Self.isSemiStructured(typeName(for: $0.column)) } + + let statement = usesSelect + ? "INSERT INTO \(qualifiedTable) (\(names.joined(separator: ", "))) SELECT \(placeholders.joined(separator: ", "))" + : "INSERT INTO \(qualifiedTable) (\(names.joined(separator: ", "))) VALUES (\(placeholders.joined(separator: ", ")))" + return (statement, parameters) + } + + private func updateStatement(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { + guard !change.cellChanges.isEmpty, + let condition = whereClause(for: change) else { return nil } + + var setClauses: [String] = [] + var parameters: [PluginCellValue] = [] + for cellChange in change.cellChanges { + setClauses.append("\(quoteIdentifier(cellChange.columnName)) = \(placeholder(for: cellChange.columnName))") + parameters.append(cellChange.newValue) + } + parameters.append(contentsOf: condition.parameters) + + let statement = "UPDATE \(qualifiedTable) SET \(setClauses.joined(separator: ", ")) WHERE \(condition.sql)" + return (statement, parameters) + } + + private func deleteStatement(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { + guard let condition = whereClause(for: change) else { return nil } + return ("DELETE FROM \(qualifiedTable) WHERE \(condition.sql)", condition.parameters) + } + + private func whereClause(for change: PluginRowChange) -> (sql: String, parameters: [PluginCellValue])? { + guard let originalRow = change.originalRow else { return nil } + + let keyColumns = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns + var conditions: [String] = [] + var parameters: [PluginCellValue] = [] + + for column in keyColumns { + guard let index = columns.firstIndex(of: column), index < originalRow.count else { continue } + if Self.isSemiStructured(typeName(for: column)) { continue } + let value = originalRow[index] + if case .null = value { + conditions.append("\(quoteIdentifier(column)) IS NULL") + } else { + conditions.append("\(quoteIdentifier(column)) = ?") + parameters.append(value) + } + } + + guard !conditions.isEmpty else { return nil } + return (conditions.joined(separator: " AND "), parameters) + } + + private func typeName(for column: String) -> String { + guard let index = columns.firstIndex(of: column), index < columnTypeNames.count else { return "TEXT" } + return columnTypeNames[index] + } + + private func placeholder(for column: String) -> String { + Self.isSemiStructured(typeName(for: column)) ? "PARSE_JSON(?)" : "?" + } + + private func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index eebeee34b..b228819cb 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -8,11 +8,10 @@ /* Begin PBXBuildFile section */ 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; }; - 5AC8FF6F28A24FFBBBFF008F /* SnowflakeBindingEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */; }; - FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */; }; - 8F6387B85F8B4AEE8210A6F1 /* SnowflakeIdTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */; }; + 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */; }; 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; }; + 4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */; }; + 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3BE6FC2F97DB0000611C1F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -60,6 +59,7 @@ 5ABQR00100000000000000A7 /* BigQueryTypeMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A7 /* BigQueryTypeMapper.swift */; }; 5ABQR00100000000000000A8 /* BigQueryOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A8 /* BigQueryOAuthServer.swift */; }; 5ABQR00100000000000000A9 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AC8FF6F28A24FFBBBFF008F /* SnowflakeBindingEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; @@ -86,10 +86,14 @@ 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; 703C8C18A7F43E262695F557 /* SnowflakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */; }; + 734B1C02ED034214A7A6088C /* SnowflakeStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */; }; + 79535441E5CF45039A08AF48 /* SnowflakePluginDriver+DDL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */; }; 8163B172478A511171523320 /* SnowflakeTypeMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */; }; + 8F6387B85F8B4AEE8210A6F1 /* SnowflakeIdTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */; }; 952FCF406F3DE2575166802B /* SnowflakeBrowserAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */; }; B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */; }; EE6F98F0581A8B96C6FE45E9 /* SnowflakeMFATokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */; }; + FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -316,15 +320,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = ""; }; - C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; - 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHeartbeat.swift; sourceTree = ""; }; - 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnection.swift; sourceTree = ""; }; 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeMFATokenStore.swift; sourceTree = ""; }; + 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SnowflakePluginDriver+DDL.swift"; sourceTree = ""; }; 37458E42D8D6876BD9FDC7BD /* SnowflakeTypeMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeTypeMapper.swift; sourceTree = ""; }; + 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeSchemaQueries.swift; sourceTree = ""; }; 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SnowflakeDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePluginDriver.swift; sourceTree = ""; }; + 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeDDLGenerator.swift; sourceTree = ""; }; + 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHTTPRetry.swift; sourceTree = ""; }; 5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "tablepro-mcp"; sourceTree = BUILT_PRODUCTS_DIR; }; 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibSQLDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -378,6 +382,10 @@ 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; 5E0809C2747F432C1F1C5A60 /* SnowflakeBrowserAuthServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBrowserAuthServer.swift; sourceTree = ""; }; 73CFCC6EA4288F34EBED5F37 /* SnowflakePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakePlugin.swift; sourceTree = ""; }; + 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeIdTokenStore.swift; sourceTree = ""; }; + 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = ""; }; + 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHeartbeat.swift; sourceTree = ""; }; + C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeAuth.swift; sourceTree = ""; }; FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeError.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1187,6 +1195,10 @@ isa = PBXGroup; children = ( F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */, + 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */, + 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */, + 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */, + 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */, 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */, C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */, 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */, @@ -2198,6 +2210,10 @@ buildActionMask = 2147483647; files = ( 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */, + 734B1C02ED034214A7A6088C /* SnowflakeStatementGenerator.swift in Sources */, + 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */, + 4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */, + 79535441E5CF45039A08AF48 /* SnowflakePluginDriver+DDL.swift in Sources */, 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */, 5AC8FF6F28A24FFBBBFF008F /* SnowflakeBindingEncoder.swift in Sources */, FF86DE29A70540C88D2624E5 /* SnowflakeHeartbeat.swift in Sources */, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 64747ecaa..2ba590656 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -342,7 +342,7 @@ extension PluginMetadataRegistry { )), ("Snowflake", PluginMetadataSnapshot( displayName: "Snowflake", iconName: "snowflake-icon", defaultPort: 443, - requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [ ExplainVariant(id: "text", label: "Explain (Text)", sqlPrefix: "EXPLAIN USING TEXT") @@ -356,7 +356,7 @@ extension PluginMetadataRegistry { supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( supportsSchemaSwitching: true, - supportsImport: false, + supportsImport: true, supportsExport: true, supportsSSH: false, supportsSSL: false, @@ -377,7 +377,7 @@ extension PluginMetadataRegistry { systemSchemaNames: ["INFORMATION_SCHEMA"], fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue] + structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment] ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: SQLDialectDescriptor( diff --git a/TableProTests/PluginTestSources/SnowflakeDDLGenerator.swift b/TableProTests/PluginTestSources/SnowflakeDDLGenerator.swift new file mode 120000 index 000000000..4b3f6ae89 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeDDLGenerator.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeSchemaQueries.swift b/TableProTests/PluginTestSources/SnowflakeSchemaQueries.swift new file mode 120000 index 000000000..232c1b9a1 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeSchemaQueries.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/SnowflakeStatementGenerator.swift b/TableProTests/PluginTestSources/SnowflakeStatementGenerator.swift new file mode 120000 index 000000000..7084c12cb --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeStatementGenerator.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift \ No newline at end of file diff --git a/TableProTests/Plugins/SnowflakeGeneratorTests.swift b/TableProTests/Plugins/SnowflakeGeneratorTests.swift new file mode 100644 index 000000000..bf9163ff4 --- /dev/null +++ b/TableProTests/Plugins/SnowflakeGeneratorTests.swift @@ -0,0 +1,222 @@ +// +// SnowflakeGeneratorTests.swift +// TableProTests +// +// Tests for the Snowflake DML statement generator, DDL generator, and +// SHOW-based schema query builders (compiled via symlinks from +// SnowflakeDriverPlugin). +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Snowflake Statement Generator") +struct SnowflakeStatementGeneratorTests { + private func generator( + columns: [String] = ["id", "name", "payload"], + types: [String] = ["NUMBER", "VARCHAR(100)", "VARIANT"], + primaryKeys: [String] = ["id"] + ) -> SnowflakeStatementGenerator { + SnowflakeStatementGenerator( + qualifiedTable: "\"DB\".\"PUBLIC\".\"T\"", + columns: columns, + columnTypeNames: types, + primaryKeyColumns: primaryKeys + ) + } + + private func update(_ column: String, to value: PluginCellValue, original: [PluginCellValue]) -> PluginRowChange { + PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(columnIndex: 0, columnName: column, oldValue: .null, newValue: value)], + originalRow: original + ) + } + + @Test("Plain inserts use VALUES with placeholders") + func testPlainInsert() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let result = generator(columns: ["id", "name"], types: ["NUMBER", "VARCHAR"], primaryKeys: ["id"]) + .generateStatements( + from: [change], + insertedRowData: [0: [.text("1"), .text("Alice")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + #expect(result.count == 1) + #expect(result[0].statement == "INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\", \"name\") VALUES (?, ?)") + #expect(result[0].parameters == [.text("1"), .text("Alice")]) + } + + @Test("Inserts touching VARIANT columns use INSERT SELECT with PARSE_JSON") + func testVariantInsertUsesSelect() { + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let result = generator().generateStatements( + from: [change], + insertedRowData: [0: [.text("1"), .text("Alice"), .text("{\"a\":1}")]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + #expect(result[0].statement == + "INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\", \"name\", \"payload\") SELECT ?, ?, PARSE_JSON(?)") + } + + @Test("Updates set PARSE_JSON for VARIANT and filter by primary key") + func testVariantUpdate() { + let change = update("payload", to: .text("{\"b\":2}"), original: [.text("7"), .text("Bob"), .null]) + let result = generator().generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] + ) + #expect(result[0].statement == + "UPDATE \"DB\".\"PUBLIC\".\"T\" SET \"payload\" = PARSE_JSON(?) WHERE \"id\" = ?") + #expect(result[0].parameters == [.text("{\"b\":2}"), .text("7")]) + } + + @Test("Without primary keys the WHERE clause uses all non-variant columns") + func testAllColumnFallbackSkipsVariant() { + let change = update("name", to: .text("New"), original: [.text("7"), .null, .text("{}")]) + let result = generator(primaryKeys: []).generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] + ) + #expect(result[0].statement == + "UPDATE \"DB\".\"PUBLIC\".\"T\" SET \"name\" = ? WHERE \"id\" = ? AND \"name\" IS NULL") + #expect(result[0].parameters == [.text("New"), .text("7")]) + } + + @Test("Deletes filter by primary key") + func testDelete() { + let change = PluginRowChange( + rowIndex: 3, + type: .delete, + cellChanges: [], + originalRow: [.text("9"), .text("Eve"), .null] + ) + let result = generator().generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [3], insertedRowIndices: [] + ) + #expect(result[0].statement == "DELETE FROM \"DB\".\"PUBLIC\".\"T\" WHERE \"id\" = ?") + #expect(result[0].parameters == [.text("9")]) + } + + @Test("Semi-structured detection covers VARIANT, OBJECT, and ARRAY") + func testSemiStructuredDetection() { + #expect(SnowflakeStatementGenerator.isSemiStructured("VARIANT")) + #expect(SnowflakeStatementGenerator.isSemiStructured("object")) + #expect(SnowflakeStatementGenerator.isSemiStructured("ARRAY")) + #expect(!SnowflakeStatementGenerator.isSemiStructured("VARCHAR(10)")) + #expect(!SnowflakeStatementGenerator.isSemiStructured("NUMBER(10,2)")) + } +} + +@Suite("Snowflake DDL Generator") +struct SnowflakeDDLGeneratorTests { + private let generator = SnowflakeDDLGenerator(qualifiedTable: { "\"DB\".\"PUBLIC\".\"\($0)\"" }) + + @Test("Add column renders type, default, nullability, and comment") + func testAddColumn() { + let column = PluginColumnDefinition( + name: "score", dataType: "NUMBER(10,2)", isNullable: false, defaultValue: "0", comment: "points" + ) + #expect(generator.addColumnSQL(table: "T", column: column) == + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ADD COLUMN \"score\" NUMBER(10,2) DEFAULT 0 NOT NULL COMMENT 'points'") + } + + @Test("VARCHAR can widen but never shrink") + func testVarcharWidening() { + #expect(SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(100)", to: "VARCHAR(500)")) + #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(500)", to: "VARCHAR(100)")) + } + + @Test("NUMBER precision can change but scale cannot") + func testNumberConstraints() { + #expect(SnowflakeDDLGenerator.isSupportedTypeChange(from: "NUMBER(10,2)", to: "NUMBER(20,2)")) + #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "NUMBER(10,2)", to: "NUMBER(10,4)")) + } + + @Test("Cross-type changes are rejected") + func testCrossTypeRejected() { + #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(10)", to: "NUMBER(10,0)")) + let old = PluginColumnDefinition(name: "c", dataType: "VARCHAR(10)") + let new = PluginColumnDefinition(name: "c", dataType: "NUMBER(10,0)") + #expect(generator.modifyColumnSQL(table: "T", old: old, new: new) == nil) + } + + @Test("Combined alters render as one statement with comma actions") + func testCombinedAlter() { + let old = PluginColumnDefinition(name: "c", dataType: "VARCHAR(10)", isNullable: true) + let new = PluginColumnDefinition(name: "c", dataType: "VARCHAR(50)", isNullable: false, comment: "x") + let sql = generator.modifyColumnSQL(table: "T", old: old, new: new) + #expect(sql == + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ALTER COLUMN \"c\" SET DATA TYPE VARCHAR(50), COLUMN \"c\" SET NOT NULL, COLUMN \"c\" COMMENT 'x'") + } + + @Test("Rename plus alter renders two statements") + func testRenameWithAlter() { + let old = PluginColumnDefinition(name: "a", dataType: "VARCHAR(10)") + let new = PluginColumnDefinition(name: "b", dataType: "VARCHAR(50)") + let sql = generator.modifyColumnSQL(table: "T", old: old, new: new) + #expect(sql == + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" RENAME COLUMN \"a\" TO \"b\";\n" + + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ALTER COLUMN \"b\" SET DATA TYPE VARCHAR(50)") + } + + @Test("Primary key changes drop then add") + func testModifyPrimaryKey() { + let statements = generator.modifyPrimaryKeySQL(table: "T", oldColumns: ["a"], newColumns: ["a", "b"]) + #expect(statements == [ + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" DROP PRIMARY KEY", + "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ADD PRIMARY KEY (\"a\", \"b\")" + ]) + } + + @Test("Create table includes a primary key constraint") + func testCreateTable() { + let definition = PluginCreateTableDefinition( + tableName: "T", + columns: [ + PluginColumnDefinition(name: "id", dataType: "NUMBER", isNullable: false, isPrimaryKey: true), + PluginColumnDefinition(name: "name", dataType: "VARCHAR(50)") + ] + ) + let sql = generator.createTableSQL(definition: definition) + #expect(sql?.contains("CREATE TABLE \"DB\".\"PUBLIC\".\"T\"") == true) + #expect(sql?.contains("PRIMARY KEY (\"id\")") == true) + } +} + +@Suite("Snowflake Schema Queries") +struct SnowflakeSchemaQueriesTests { + @Test("SHOW statements quote identifiers") + func testShowQuoting() { + #expect(SnowflakeSchemaQueries.showObjects(database: "my\"db", schema: "S") == + "SHOW TERSE OBJECTS IN SCHEMA \"my\"\"db\".\"S\"") + } + + @Test("SHOW TABLES LIKE escapes pattern wildcards") + func testLikeEscaping() { + let sql = SnowflakeSchemaQueries.showTablesLike(database: "D", schema: "S", table: "my_table") + #expect(sql.contains("'my\\\\_table'")) + } + + @Test("Object kinds map to app table types") + func testKindMapping() { + #expect(SnowflakeSchemaQueries.objectType(forKind: "TABLE") == "TABLE") + #expect(SnowflakeSchemaQueries.objectType(forKind: "VIEW") == "VIEW") + #expect(SnowflakeSchemaQueries.objectType(forKind: "MATERIALIZED_VIEW") == "MATERIALIZED_VIEW") + } + + @Test("Clustering key expressions parse to column names") + func testClusterByParsing() { + #expect(SnowflakeSchemaQueries.parseClusterBy("LINEAR(col1, col2)") == ["col1", "col2"]) + #expect(SnowflakeSchemaQueries.parseClusterBy("LINEAR(\"a\")") == ["a"]) + } + + @Test("Multi-statement detection requires content after a semicolon") + func testMultiStatementDetection() { + #expect(SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1; SELECT 2")) + #expect(!SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1;")) + #expect(!SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1")) + } +} diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index 35e4b9067..d3b3249b2 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -26,7 +26,7 @@ The account identifier is the part before `.snowflakecomputing.com`: ALTER USER jane_doe SET RSA_PUBLIC_KEY='MIIBIjANBgkq...'; ``` -**Single Sign-On (Browser)**: Authenticate through your identity provider (Okta, Entra ID, etc.) in the browser. TablePro opens your IdP's login page and captures the response automatically (`authenticator=externalbrowser`). +**Single Sign-On (Browser)**: Authenticate through your identity provider (Okta, Entra ID, etc.) in the browser. TablePro opens your IdP's login page and captures the response automatically (`authenticator=externalbrowser`). With `ALLOW_ID_TOKEN = TRUE` on the account, the sign-in token is cached in the Keychain so later connects skip the browser for about four hours. **OAuth Token**: Paste a valid OAuth access token issued for your Snowflake account. @@ -71,8 +71,12 @@ SELECT * FROM events AT(OFFSET => -60*5); **Data Types**: NUMBER, FLOAT, VARCHAR, BINARY, BOOLEAN, DATE, TIME, TIMESTAMP_NTZ/LTZ/TZ, VARIANT, OBJECT, ARRAY, GEOGRAPHY, GEOMETRY. Semi-structured values display as JSON text. +**Structure Editing**: Add, rename, and drop columns; widen VARCHAR lengths and adjust NUMBER precision (Snowflake does not allow shrinking or cross-type changes); toggle NOT NULL; edit column comments; change primary keys. Declared primary keys, foreign keys, and clustering keys show in the structure pane (Snowflake treats key constraints as informational). + **DDL**: View object DDL via `GET_DDL`. Create or replace views, truncate and drop tables. +**Import**: CSV, TSV, and JSON files import into new or existing tables through parameterized inserts. + **Export**: CSV, JSON, SQL, XLSX formats. ## Troubleshooting @@ -92,4 +96,4 @@ SELECT * FROM events AT(OFFSET => -60*5); ## Limitations - No SSH tunneling (HTTPS only to the Snowflake endpoint) -- Foreign keys are informational only and are not displayed +- Foreign keys display as informational; Snowflake never enforces them From 371ba1ef38e42e656c9a19f3ddc87c3c74cbb862 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 04:50:10 +0700 Subject: [PATCH 09/14] feat(plugins): session context pickers with Snowflake warehouse and role switching in the toolbar --- CHANGELOG.md | 2 +- .../SnowflakeConnection.swift | 2 +- .../SnowflakePluginDriver.swift | 44 +++++++++++++++++++ .../PluginDatabaseDriver.swift | 8 ++++ .../PluginSessionContext.swift | 32 ++++++++++++++ TablePro/Core/Database/DatabaseDriver.swift | 8 ++++ .../Core/Plugins/PluginDriverAdapter.swift | 8 ++++ .../MainWindowToolbar+Buttons.swift | 30 +++++++++++++ .../Infrastructure/MainWindowToolbar.swift | 1 + ...inContentCoordinator+SessionContexts.swift | 40 +++++++++++++++++ .../Views/Main/MainContentCoordinator.swift | 1 + .../Plugins/SnowflakeProtocolTests.swift | 19 ++++++++ docs/databases/snowflake.mdx | 2 + 13 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginSessionContext.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a76bd9ccb..37323cc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Snowflake support: connect with username & password (including MFA TOTP passcodes and cached MFA tokens), key-pair (.p8), browser SSO with a cached sign-in token, or a programmatic access token; browse databases, schemas, and tables without waking a warehouse; edit rows with server-side bind parameters, including VARIANT, OBJECT, and ARRAY cells through the JSON editor; import CSV and JSON files; edit table structure within Snowflake's rules (add, rename, widen, drop columns, primary keys, comments); see declared primary keys, foreign keys, and clustering keys; run multi-statement scripts; sessions stay alive with a background heartbeat. Connections defined in the Snowflake CLI's config files can be reused by name. (#1420) +- Snowflake support: connect with username & password (including MFA TOTP passcodes and cached MFA tokens), key-pair (.p8), browser SSO with a cached sign-in token, or a programmatic access token; browse databases, schemas, and tables without waking a warehouse; edit rows with server-side bind parameters, including VARIANT, OBJECT, and ARRAY cells through the JSON editor; import CSV and JSON files; edit table structure within Snowflake's rules (add, rename, widen, drop columns, primary keys, comments); see declared primary keys, foreign keys, and clustering keys; run multi-statement scripts; switch the active warehouse and role from the toolbar; sessions stay alive with a background heartbeat. Connections defined in the Snowflake CLI's config files can be reused by name. (#1420) - Import data from CSV and TSV files into a table: map columns to an existing table or create a new one, with options for delimiter, quote character, encoding, header row, and empty/NULL handling. (#1568) - SQL autocomplete completes database, schema, and table names at each segment of qualified names for schema-organized connections (Snowflake, BigQuery), fetches tables of unopened schemas on demand, resolves alias columns for schema-qualified tables, and suggests the active connection's full dialect function list. - Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index 3f3273b27..f8bf5486c 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -284,7 +284,7 @@ final class SnowflakeConnection: @unchecked Sendable { let token = responseData["token"] as? String else { let message = response["message"] as? String ?? "Authentication failed" let code = Self.codeString(response["code"]) - if code == "394507" || code == "394508" { + if ["394507", "394508", "394633"].contains(code) { throw SnowflakeError.loginFailed( code: code, message: "\(message) Open the connection settings and refresh the MFA Passcode (TOTP) with a current code from your authenticator." diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 139ac3086..68b195c8d 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -151,6 +151,50 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + // MARK: - Session Contexts + + func fetchSessionContexts() async throws -> [PluginSessionContext]? { + guard let conn = connection else { return nil } + var contexts: [PluginSessionContext] = [] + if let warehouses = try? await rawQuery("SHOW WAREHOUSES"), + let nameIndex = columnIndex(of: "name", in: warehouses) { + contexts.append(PluginSessionContext( + id: "warehouse", + label: String(localized: "Warehouse"), + iconName: "building.columns", + currentValue: conn.currentWarehouse, + availableValues: warehouses.rows.compactMap { Self.text($0, nameIndex) } + )) + } + if let roles = try? await rawQuery("SHOW ROLES"), + let nameIndex = columnIndex(of: "name", in: roles) { + contexts.append(PluginSessionContext( + id: "role", + label: String(localized: "Role"), + iconName: "person.badge.key", + currentValue: conn.currentRole, + availableValues: roles.rows.compactMap { Self.text($0, nameIndex) } + )) + } + return contexts.isEmpty ? nil : contexts + } + + func switchSessionContext(id: String, to value: String) async throws { + guard let conn = connection else { throw SnowflakeError.notConnected } + switch id { + case "warehouse": + _ = try await conn.query("USE WAREHOUSE \(quoteIdentifier(value))") + case "role": + _ = try await conn.query("USE ROLE \(quoteIdentifier(value))") + lock.withLock { + resolvedSchemaCache.removeAll() + columnTypeCache.removeAll() + } + default: + break + } + } + // MARK: - Database Management func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 1f9f1330b..7ea23aafd 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -126,6 +126,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func dropDatabase(name: String) async throws func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult + // Session contexts (optional, switchable session dimensions such as a warehouse or role) + func fetchSessionContexts() async throws -> [PluginSessionContext]? + func switchSessionContext(id: String, to value: String) async throws + // Query building (optional, for NoSQL plugins) func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? @@ -369,6 +373,10 @@ public extension PluginDatabaseDriver { return result } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil } + + func switchSessionContext(id: String, to value: String) async throws {} + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard !parameters.isEmpty else { return try await execute(query: query) diff --git a/Plugins/TableProPluginKit/PluginSessionContext.swift b/Plugins/TableProPluginKit/PluginSessionContext.swift new file mode 100644 index 000000000..397093eab --- /dev/null +++ b/Plugins/TableProPluginKit/PluginSessionContext.swift @@ -0,0 +1,32 @@ +// +// PluginSessionContext.swift +// TableProPluginKit +// +// A switchable session dimension a driver exposes beyond database and schema, +// such as a Snowflake warehouse or role. The app renders one toolbar picker +// per context and routes selections back through switchSessionContext. +// + +import Foundation + +public struct PluginSessionContext: Codable, Sendable, Identifiable { + public let id: String + public let label: String + public let iconName: String + public let currentValue: String? + public let availableValues: [String] + + public init( + id: String, + label: String, + iconName: String, + currentValue: String?, + availableValues: [String] + ) { + self.id = id + self.label = label + self.iconName = iconName + self.currentValue = currentValue + self.availableValues = availableValues + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 8542c56aa..c0fe4ee88 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -143,6 +143,10 @@ protocol DatabaseDriver: AnyObject { func dropDatabase(name: String) async throws + func fetchSessionContexts() async throws -> [PluginSessionContext]? + + func switchSessionContext(id: String, to value: String) async throws + // MARK: - Maintenance /// Returns the list of supported maintenance operations (e.g. "VACUUM", "ANALYZE"). @@ -253,6 +257,10 @@ extension DatabaseDriver { func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil } + + func switchSessionContext(id: String, to value: String) async throws {} + func createDatabase(_ request: CreateDatabaseRequest) async throws { throw NSError( domain: "DatabaseDriver", diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 00775001b..c82546ddc 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -392,6 +392,14 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.dropDatabase(name: name) } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { + try await pluginDriver.fetchSessionContexts() + } + + func switchSessionContext(id: String, to value: String) async throws { + try await pluginDriver.switchSessionContext(id: id, to: value) + } + // MARK: - Batch Operations func fetchAllColumns() async throws -> [String: [ColumnInfo]] { diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index 1456e247c..d7e864d35 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -48,6 +48,36 @@ struct DatabaseToolbarButton: View { } } +struct SessionContextToolbarButton: View { + @Bindable var coordinator: MainContentCoordinator + + var body: some View { + HStack(spacing: 4) { + ForEach(coordinator.sessionContexts) { context in + Menu { + ForEach(context.availableValues, id: \.self) { value in + Button { + Task { await coordinator.switchSessionContext(id: context.id, to: value) } + } label: { + if value == context.currentValue { + Label(value, systemImage: "checkmark") + } else { + Text(value) + } + } + } + } label: { + Label(context.currentValue ?? context.label, systemImage: context.iconName) + } + .help(context.label) + } + } + .task(id: coordinator.toolbarState.connectionState) { + await coordinator.loadSessionContexts() + } + } +} + struct RefreshToolbarButton: View { let coordinator: MainContentCoordinator diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 401954d4f..61b224077 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -117,6 +117,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { content: HStack(spacing: 4) { ConnectionToolbarButton(coordinator: coordinator) DatabaseToolbarButton(coordinator: coordinator) + SessionContextToolbarButton(coordinator: coordinator) } ) group.isNavigational = true diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift new file mode 100644 index 000000000..23e41e202 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift @@ -0,0 +1,40 @@ +// +// MainContentCoordinator+SessionContexts.swift +// TablePro +// + +import Combine +import Foundation +import os +import TableProPluginKit + +extension MainContentCoordinator { + func loadSessionContexts() async { + guard toolbarState.connectionState == .connected, + let driver = services.databaseManager.driver(for: connectionId) else { + sessionContexts = [] + return + } + do { + sessionContexts = try await driver.fetchSessionContexts() ?? [] + } catch { + Self.logger.warning("Failed to load session contexts: \(error.localizedDescription)") + sessionContexts = [] + } + } + + func switchSessionContext(id: String, to value: String) async { + guard let driver = services.databaseManager.driver(for: connectionId) else { return } + do { + try await driver.switchSessionContext(id: id, to: value) + await loadSessionContexts() + AppCommands.shared.refreshData.send(nil) + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Switch Failed"), + message: error.localizedDescription, + window: contentWindow + ) + } + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index de17a8f80..be7abec9d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -167,6 +167,7 @@ final class MainContentCoordinator { var activeSheet: ActiveSheet? var isDatabaseSwitcherShown = false var isConnectionSwitcherShown = false + var sessionContexts: [PluginSessionContext] = [] var databaseToDrop: String? var importFileURL: URL? var exportPreselectedTableNames: Set? diff --git a/TableProTests/Plugins/SnowflakeProtocolTests.swift b/TableProTests/Plugins/SnowflakeProtocolTests.swift index e2060ba00..d0c77f2c0 100644 --- a/TableProTests/Plugins/SnowflakeProtocolTests.swift +++ b/TableProTests/Plugins/SnowflakeProtocolTests.swift @@ -107,6 +107,25 @@ struct SnowflakeReAuthTests { } } +@Suite("Plugin Session Context") +struct PluginSessionContextTests { + @Test("Round-trips through Codable") + func testCodableRoundTrip() throws { + let context = PluginSessionContext( + id: "warehouse", + label: "Warehouse", + iconName: "building.columns", + currentValue: "COMPUTE_WH", + availableValues: ["COMPUTE_WH", "LOAD_WH"] + ) + let data = try JSONEncoder().encode(context) + let decoded = try JSONDecoder().decode(PluginSessionContext.self, from: data) + #expect(decoded.id == context.id) + #expect(decoded.currentValue == "COMPUTE_WH") + #expect(decoded.availableValues == ["COMPUTE_WH", "LOAD_WH"]) + } +} + @Suite("Snowflake Heartbeat Interval") struct SnowflakeHeartbeatIntervalTests { @Test("Interval is a quarter of master validity, clamped to 15 to 60 minutes") diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index d3b3249b2..88ac3de3c 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -56,6 +56,8 @@ Already use the Snowflake CLI? Set **CLI Connection Name** (Advanced) to a conne **Database & Schema Browsing**: The sidebar groups objects by database and schema. Switch the active database or schema with `USE DATABASE` / `USE SCHEMA`, or by selecting them in the UI. +**Warehouse & Role Switchers**: Toolbar pickers show the active warehouse and role and switch them with `USE WAREHOUSE` / `USE ROLE`, no reconnect needed. A suspended warehouse resumes automatically on the next query. + **Snowflake SQL** ([docs](https://docs.snowflake.com/en/sql-reference-commands)): ```sql From 6bb5f1d0e7c7431258c81bdf794f0e8e7132d7cb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 05:01:50 +0700 Subject: [PATCH 10/14] fix(plugin-snowflake): share one session across pooled drivers, never replay a rejected MFA passcode, and show restricted schemas as empty --- .../SnowflakeConnection.swift | 41 +++++++++++++++- .../SnowflakeConnectionRegistry.swift | 48 +++++++++++++++++++ .../SnowflakeError.swift | 11 +++++ .../SnowflakeMFATokenStore.swift | 14 ++++++ .../SnowflakePluginDriver.swift | 32 ++++++++++--- TablePro.xcodeproj/project.pbxproj | 4 ++ .../SnowflakeMFATokenStore.swift | 1 + .../Plugins/SnowflakeProtocolTests.swift | 19 ++++++++ 8 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakeConnectionRegistry.swift create mode 120000 TableProTests/PluginTestSources/SnowflakeMFATokenStore.swift diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index f8bf5486c..096f3cad5 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -53,6 +53,11 @@ final class SnowflakeConnection: @unchecked Sendable { private var renewalToken: String? private var activeRequestIDs: Set = [] private var sequenceId = 0 + private var connectTask: Task? + + var sessionFingerprint: String { + [host, params.user.uppercased(), params.authMethod, params.role.uppercased()].joined(separator: "|") + } private var _currentDatabase: String? private var _currentSchema: String? @@ -117,6 +122,26 @@ final class SnowflakeConnection: @unchecked Sendable { // MARK: - Connection Lifecycle + func connectIfNeeded() async throws { + enum Pending { + case alreadyConnected + case task(Task) + } + let pending: Pending = lock.withLock { + if sessionToken != nil { return .alreadyConnected } + if let connectTask { return .task(connectTask) } + let task = Task { + defer { self.lock.withLock { self.connectTask = nil } } + try await self.connect() + } + connectTask = task + return .task(task) + } + if case .task(let task) = pending { + try await task.value + } + } + func connect() async throws { switch params.authMethod { case "keyPair": @@ -165,11 +190,23 @@ final class SnowflakeConnection: @unchecked Sendable { } var extra: [String: Any] = ["PASSWORD": params.password] - if !params.mfaPasscode.isEmpty { + let usesPasscode = !params.mfaPasscode.isEmpty + && !SnowflakeMFATokenStore.isPasscodeRejected(params.mfaPasscode, account: params.account, user: params.user) + if usesPasscode { extra["PASSCODE"] = params.mfaPasscode extra["EXT_AUTHN_DUO_METHOD"] = "passcode" } - try await login(authenticator: "SNOWFLAKE", extra: extra) + do { + try await login(authenticator: "SNOWFLAKE", extra: extra) + } catch let error as SnowflakeError { + if usesPasscode, case .loginFailed(let code, _) = error, + ["394507", "394633"].contains(code) { + SnowflakeMFATokenStore.markPasscodeRejected( + params.mfaPasscode, account: params.account, user: params.user + ) + } + throw error + } } private func loginWithKeyPair() async throws { diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnectionRegistry.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnectionRegistry.swift new file mode 100644 index 000000000..74f97edf4 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnectionRegistry.swift @@ -0,0 +1,48 @@ +// +// SnowflakeConnectionRegistry.swift +// SnowflakeDriverPlugin +// +// Shares one authenticated SnowflakeConnection across the driver instances +// the app pools for a connection profile. One TablePro connection maps to one +// Snowflake session, so metadata drivers and switchers reuse the session +// token instead of opening fresh logins that re-prompt for MFA. +// + +import Foundation +import TableProPluginKit + +final class SnowflakeConnectionRegistry: @unchecked Sendable { + static let shared = SnowflakeConnectionRegistry() + + private let lock = NSLock() + private var entries: [String: (connection: SnowflakeConnection, refCount: Int)] = [:] + + func acquire(config: DriverConnectionConfig) -> SnowflakeConnection { + lock.withLock { + let candidate = SnowflakeConnection(config: config) + let key = candidate.sessionFingerprint + if let entry = entries[key] { + entries[key] = (entry.connection, entry.refCount + 1) + return entry.connection + } + entries[key] = (candidate, 1) + return candidate + } + } + + func release(_ connection: SnowflakeConnection) { + let shouldDisconnect: Bool = lock.withLock { + let key = connection.sessionFingerprint + guard let entry = entries[key], entry.connection === connection else { return true } + if entry.refCount <= 1 { + entries[key] = nil + return true + } + entries[key] = (entry.connection, entry.refCount - 1) + return false + } + if shouldDisconnect { + connection.disconnect() + } + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift index 811d67651..3362eded9 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift @@ -46,6 +46,17 @@ extension SnowflakeError { static func isReauthenticationCode(_ code: String) -> Bool { reauthenticationCodes.contains(code) } + + static func isInaccessibleObjectCode(_ code: String) -> Bool { + code == "002043" || code == "2043" || code == "003001" || code == "3001" + } + + var indicatesInaccessibleObject: Bool { + if case .queryFailed(let code, _) = self { + return Self.isInaccessibleObjectCode(code) + } + return false + } } extension SnowflakeError: PluginDriverError { diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift index b96e831d8..e36ed9053 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift @@ -39,6 +39,20 @@ enum SnowflakeMFATokenStore { deleteKeychain(key: key) } + static func markPasscodeRejected(_ passcode: String, account: String, user: String) { + guard !passcode.isEmpty else { return } + let key = "\(cacheKey(account: account, user: user)):\(passcode)" + _ = lock.withLock { rejectedPasscodes.insert(key) } + } + + static func isPasscodeRejected(_ passcode: String, account: String, user: String) -> Bool { + guard !passcode.isEmpty else { return false } + let key = "\(cacheKey(account: account, user: user)):\(passcode)" + return lock.withLock { rejectedPasscodes.contains(key) } + } + + private static var rejectedPasscodes: Set = [] + private static func cacheKey(account: String, user: String) -> String { "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" } diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 68b195c8d..d87bd0fd7 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -61,8 +61,13 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Lifecycle func connect() async throws { - let conn = SnowflakeConnection(config: config) - try await conn.connect() + let conn = SnowflakeConnectionRegistry.shared.acquire(config: config) + do { + try await conn.connectIfNeeded() + } catch { + SnowflakeConnectionRegistry.shared.release(conn) + throw error + } lock.withLock { _connection = conn } if let result = try? await conn.query("SELECT CURRENT_VERSION()"), @@ -74,9 +79,13 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func disconnect() { - lock.withLock { - _connection?.disconnect() + let conn: SnowflakeConnection? = lock.withLock { + let current = _connection _connection = nil + return current + } + if let conn { + SnowflakeConnectionRegistry.shared.release(conn) } } @@ -239,7 +248,13 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let targetSchema = schema ?? connection?.currentSchema guard let database, let targetSchema else { return [] } - let result = try await rawQuery(SnowflakeSchemaQueries.showObjects(database: database, schema: targetSchema)) + let result: SnowflakeQueryResult + do { + result = try await rawQuery(SnowflakeSchemaQueries.showObjects(database: database, schema: targetSchema)) + } catch let error as SnowflakeError where error.indicatesInaccessibleObject { + Self.logger.debug("Schema \(targetSchema, privacy: .public) is visible but not enumerable; showing it empty") + return [] + } guard let nameIndex = columnIndex(of: "name", in: result) else { return [] } let kindIndex = columnIndex(of: "kind", in: result) return result.rows.compactMap { row in @@ -259,7 +274,12 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let database, let targetSchema else { return [:] } let primaryKeys = await primaryKeysBySchema(database: database, schema: targetSchema) - let result = try await rawQuery(SnowflakeSchemaQueries.bulkColumns(database: database, schema: targetSchema)) + let result: SnowflakeQueryResult + do { + result = try await rawQuery(SnowflakeSchemaQueries.bulkColumns(database: database, schema: targetSchema)) + } catch let error as SnowflakeError where error.indicatesInaccessibleObject { + return [:] + } var columnsByTable: [String: [PluginColumnInfo]] = [:] for row in result.rows { diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index b228819cb..afa0bb853 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */; }; + 33E6FF2997594F41AA80EEC8 /* SnowflakeConnectionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */; }; 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */; }; 4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */; }; 4CA0E909166145AAB6B6CDD7 /* SnowflakeHTTPRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */; }; @@ -320,6 +321,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnectionRegistry.swift; sourceTree = ""; }; 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnection.swift; sourceTree = ""; }; 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeMFATokenStore.swift; sourceTree = ""; }; 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SnowflakePluginDriver+DDL.swift"; sourceTree = ""; }; @@ -1195,6 +1197,7 @@ isa = PBXGroup; children = ( F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */, + DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */, 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */, 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */, 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */, @@ -2210,6 +2213,7 @@ buildActionMask = 2147483647; files = ( 3DD7311CA07CFCA8A996058F /* SnowflakeAuth.swift in Sources */, + 33E6FF2997594F41AA80EEC8 /* SnowflakeConnectionRegistry.swift in Sources */, 734B1C02ED034214A7A6088C /* SnowflakeStatementGenerator.swift in Sources */, 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */, 4355C825A7554BBCB978E1E4 /* SnowflakeSchemaQueries.swift in Sources */, diff --git a/TableProTests/PluginTestSources/SnowflakeMFATokenStore.swift b/TableProTests/PluginTestSources/SnowflakeMFATokenStore.swift new file mode 120000 index 000000000..c42a0acd0 --- /dev/null +++ b/TableProTests/PluginTestSources/SnowflakeMFATokenStore.swift @@ -0,0 +1 @@ +../../Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift \ No newline at end of file diff --git a/TableProTests/Plugins/SnowflakeProtocolTests.swift b/TableProTests/Plugins/SnowflakeProtocolTests.swift index d0c77f2c0..03e6c7e87 100644 --- a/TableProTests/Plugins/SnowflakeProtocolTests.swift +++ b/TableProTests/Plugins/SnowflakeProtocolTests.swift @@ -105,6 +105,25 @@ struct SnowflakeReAuthTests { #expect(!SnowflakeError.isReauthenticationCode(code)) } } + + @Test("A rejected MFA passcode is never replayed") + func testRejectedPasscodeGuard() { + let account = "guardtest-\(UUID().uuidString)" + #expect(!SnowflakeMFATokenStore.isPasscodeRejected("123456", account: account, user: "U")) + SnowflakeMFATokenStore.markPasscodeRejected("123456", account: account, user: "U") + #expect(SnowflakeMFATokenStore.isPasscodeRejected("123456", account: account, user: "U")) + #expect(!SnowflakeMFATokenStore.isPasscodeRejected("654321", account: account, user: "U")) + #expect(!SnowflakeMFATokenStore.isPasscodeRejected("", account: account, user: "U")) + } + + @Test("Inaccessible-object codes map to empty listings") + func testInaccessibleObjectCodes() { + #expect(SnowflakeError.isInaccessibleObjectCode("002043")) + #expect(SnowflakeError.isInaccessibleObjectCode("2043")) + #expect(SnowflakeError.queryFailed(code: "002043", message: "x").indicatesInaccessibleObject) + #expect(!SnowflakeError.queryFailed(code: "390112", message: "x").indicatesInaccessibleObject) + #expect(!SnowflakeError.authFailed("x").indicatesInaccessibleObject) + } } @Suite("Plugin Session Context") From e70ba8393f062c23dbf62020332c877d2cd5a7d8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 05:15:59 +0700 Subject: [PATCH 11/14] fix(plugin-snowflake): coalesce shared-session re-login, scope the column type cache by schema, and expire rejected passcodes --- .../SnowflakeConnection.swift | 3 ++- .../SnowflakeMFATokenStore.swift | 14 +++++++++++--- .../SnowflakePluginDriver.swift | 16 +++++++++++----- .../SnowflakeStatementGenerator.swift | 15 ++++++++++++--- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift index 096f3cad5..b306b8be6 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -437,7 +437,8 @@ final class SnowflakeConnection: @unchecked Sendable { do { try await renewSession() } catch { - try await connect() + lock.withLock { sessionToken = nil } + try await connectIfNeeded() } return try await operation() } diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift index e36ed9053..0f1f27681 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift @@ -39,19 +39,27 @@ enum SnowflakeMFATokenStore { deleteKeychain(key: key) } + static let rejectedPasscodeLifetime: TimeInterval = 300 + static func markPasscodeRejected(_ passcode: String, account: String, user: String) { guard !passcode.isEmpty else { return } let key = "\(cacheKey(account: account, user: user)):\(passcode)" - _ = lock.withLock { rejectedPasscodes.insert(key) } + lock.withLock { + rejectedPasscodes = rejectedPasscodes.filter { Date().timeIntervalSince($0.value) < rejectedPasscodeLifetime } + rejectedPasscodes[key] = Date() + } } static func isPasscodeRejected(_ passcode: String, account: String, user: String) -> Bool { guard !passcode.isEmpty else { return false } let key = "\(cacheKey(account: account, user: user)):\(passcode)" - return lock.withLock { rejectedPasscodes.contains(key) } + return lock.withLock { + guard let rejectedAt = rejectedPasscodes[key] else { return false } + return Date().timeIntervalSince(rejectedAt) < rejectedPasscodeLifetime + } } - private static var rejectedPasscodes: Set = [] + private static var rejectedPasscodes: [String: Date] = [:] private static func cacheKey(account: String, user: String) -> String { "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index d87bd0fd7..8af541d4e 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -295,7 +295,7 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnsByTable[table, default: []].append(column) } for (table, columns) in columnsByTable { - cacheColumnTypes(table: table, columns: columns) + cacheColumnTypes(table: table, schema: targetSchema, columns: columns) } return columnsByTable } @@ -360,20 +360,26 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { comment: comment ) } - cacheColumnTypes(table: table, columns: columns) + cacheColumnTypes(table: table, schema: targetSchema, columns: columns) return columns } - func cacheColumnTypes(table: String, columns: [PluginColumnInfo]) { + func cacheColumnTypes(table: String, schema: String?, columns: [PluginColumnInfo]) { + let key = columnTypeCacheKey(table: table, schema: schema) let types = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0.dataType) }) - lock.withLock { columnTypeCache[table] = types } + lock.withLock { columnTypeCache[key] = types } } func columnTypeNames(for table: String, columns: [String]) -> [String] { - let types = lock.withLock { columnTypeCache[table] } ?? [:] + let key = columnTypeCacheKey(table: table, schema: nil) + let types = lock.withLock { columnTypeCache[key] } ?? [:] return columns.map { types[$0] ?? "TEXT" } } + private func columnTypeCacheKey(table: String, schema: String?) -> String { + "\((schema ?? connection?.currentSchema) ?? "").\(table)" + } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { let database = connection?.currentDatabase let targetSchema = schema ?? connection?.currentSchema diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift index 07c16f34e..e9c902028 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift @@ -9,9 +9,12 @@ // import Foundation +import os import TableProPluginKit struct SnowflakeStatementGenerator { + private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeStatementGenerator") + let qualifiedTable: String let columns: [String] let columnTypeNames: [String] @@ -77,8 +80,11 @@ struct SnowflakeStatementGenerator { } private func updateStatement(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { - guard !change.cellChanges.isEmpty, - let condition = whereClause(for: change) else { return nil } + guard !change.cellChanges.isEmpty else { return nil } + guard let condition = whereClause(for: change) else { + Self.logger.error("Skipping UPDATE for \(qualifiedTable, privacy: .public): no identifying columns to build a WHERE clause") + return nil + } var setClauses: [String] = [] var parameters: [PluginCellValue] = [] @@ -93,7 +99,10 @@ struct SnowflakeStatementGenerator { } private func deleteStatement(for change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { - guard let condition = whereClause(for: change) else { return nil } + guard let condition = whereClause(for: change) else { + Self.logger.error("Skipping DELETE for \(qualifiedTable, privacy: .public): no identifying columns to build a WHERE clause") + return nil + } return ("DELETE FROM \(qualifiedTable) WHERE \(condition.sql)", condition.parameters) } From f535675e16c8743547d875f61c808fe7993b2560 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 05:16:50 +0700 Subject: [PATCH 12/14] Update project.pbxproj --- TablePro.xcodeproj/project.pbxproj | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index afa0bb853..0f7a4bbf9 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -82,7 +82,6 @@ 5AEA8B462F6808CA0040461A /* EtcdQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3F2F6808CA0040461A /* EtcdQueryBuilder.swift */; }; 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5AEC4DB12FD36700002191A2 /* SnowflakeDriverPlugin.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; 690F1972044539AA3EC01FAD /* SnowflakePluginDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAC14AE00C256911D2A9D2E /* SnowflakePluginDriver.swift */; }; 69F80E23278BD01CA254E291 /* SnowflakeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */; }; @@ -288,7 +287,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 5AEC4DB12FD36700002191A2 /* SnowflakeDriverPlugin.tableplugin in Copy Plug-Ins (12 items) */, 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, @@ -321,7 +319,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnectionRegistry.swift; sourceTree = ""; }; 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnection.swift; sourceTree = ""; }; 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeMFATokenStore.swift; sourceTree = ""; }; 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SnowflakePluginDriver+DDL.swift"; sourceTree = ""; }; @@ -388,6 +385,7 @@ 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeStatementGenerator.swift; sourceTree = ""; }; 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeHeartbeat.swift; sourceTree = ""; }; C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; + DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnectionRegistry.swift; sourceTree = ""; }; F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeAuth.swift; sourceTree = ""; }; FE1C881DFE85C2D2DBCC5B87 /* SnowflakeError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeError.swift; sourceTree = ""; }; /* End PBXFileReference section */ From 58b89d5be13887d1b4e7390725a747d8cfc3851d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 05:20:38 +0700 Subject: [PATCH 13/14] chore(plugin-snowflake): drop the one-time xcode bootstrap script and revert project file churn --- TablePro.xcodeproj/project.pbxproj | 22 +++++---- scripts/add-snowflake-to-xcode.rb | 73 ------------------------------ 2 files changed, 13 insertions(+), 82 deletions(-) delete mode 100644 scripts/add-snowflake-to-xcode.rb diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 0f7a4bbf9..f67a262e8 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1050,14 +1050,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 1DFD9D7F8FEC30E3858A6B5F /* Plugins */ = { - isa = PBXGroup; - children = ( - 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, - ); - name = Plugins; - sourceTree = ""; - }; 5A05FBC72F3EDF7500819CD7 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -1101,7 +1093,7 @@ 5A1091C82EF17EDC0055EA7C /* Products */, 5A05FBC72F3EDF7500819CD7 /* Recovered References */, 5AEA8B482F6808E90040461A /* Frameworks */, - 1DFD9D7F8FEC30E3858A6B5F /* Plugins */, + 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, ); sourceTree = ""; }; @@ -1297,6 +1289,8 @@ 5A32BC012F9D5F1300BAEB5F /* mcp-server */, ); name = "mcp-server"; + packageProductDependencies = ( + ); productName = "mcp-server"; productReference = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; productType = "com.apple.product-type.tool"; @@ -1317,6 +1311,8 @@ 5A3BE6FE2F97DB0100611C1F /* Plugins/LibSQLDriverPlugin */, ); name = LibSQLDriverPlugin; + packageProductDependencies = ( + ); productName = LibSQLDriverPlugin; productReference = 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1762,6 +1758,8 @@ dependencies = ( ); name = BigQueryDriverPlugin; + packageProductDependencies = ( + ); productName = BigQueryDriverPlugin; productReference = 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1779,6 +1777,8 @@ dependencies = ( ); name = DynamoDBDriverPlugin; + packageProductDependencies = ( + ); productName = DynamoDBDriverPlugin; productReference = 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1799,6 +1799,8 @@ 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, ); name = CloudflareD1DriverPlugin; + packageProductDependencies = ( + ); productName = CloudflareD1DriverPlugin; productReference = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -1816,6 +1818,8 @@ dependencies = ( ); name = EtcdDriverPlugin; + packageProductDependencies = ( + ); productName = EtcdDriverPlugin; productReference = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; diff --git a/scripts/add-snowflake-to-xcode.rb b/scripts/add-snowflake-to-xcode.rb deleted file mode 100644 index cda7bb47f..000000000 --- a/scripts/add-snowflake-to-xcode.rb +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env ruby -# Adds the SnowflakeDriverPlugin .tableplugin target to the Xcode project, -# cloning the BigQueryDriverPlugin target's build settings so the bundle is -# produced and signed identically. Idempotent. -# Usage: ruby scripts/add-snowflake-to-xcode.rb - -require 'xcodeproj' - -project_path = File.join(__dir__, '..', 'TablePro.xcodeproj') -proj = Xcodeproj::Project.open(project_path) - -TARGET_NAME = 'SnowflakeDriverPlugin' -PLUGIN_DIR = 'Plugins/SnowflakeDriverPlugin' -BUNDLE_ID = 'com.TablePro.SnowflakeDriverPlugin' -PRINCIPAL_CLASS = '$(PRODUCT_MODULE_NAME).SnowflakePlugin' - -if proj.targets.any? { |t| t.name == TARGET_NAME } - puts "⏭️ Target #{TARGET_NAME} already exists" - exit 0 -end - -template = proj.targets.find { |t| t.name == 'BigQueryDriverPlugin' } -abort 'BigQueryDriverPlugin target not found (needed as a template)' unless template - -framework_ref = proj.files.find { |f| f.display_name == 'TableProPluginKit.framework' } -abort 'TableProPluginKit.framework reference not found' unless framework_ref - -target = proj.new_target(:bundle, TARGET_NAME, :osx, '14.0', proj.products_group, :swift) - -# Mirror the product wrapper so build/CI scripts find .tableplugin -product = target.product_reference -product.path = "#{TARGET_NAME}.tableplugin" -product.explicit_file_type = 'wrapper.cfbundle' -product.include_in_index = '0' - -# Clone the template's build settings, then override identity-specific keys -template.build_configurations.each do |template_cfg| - cfg = target.build_configurations.find { |c| c.name == template_cfg.name } - next unless cfg - settings = template_cfg.build_settings.dup - settings['INFOPLIST_FILE'] = "#{PLUGIN_DIR}/Info.plist" - settings['PRODUCT_BUNDLE_IDENTIFIER'] = BUNDLE_ID - settings['INFOPLIST_KEY_NSPrincipalClass'] = PRINCIPAL_CLASS - settings['PRODUCT_NAME'] = '$(TARGET_NAME)' - cfg.build_settings = settings -end - -# Explicit (non-synchronized) group holding the plugin sources -group = proj.main_group.find_subpath(PLUGIN_DIR, true) -group.set_source_tree('') -group.path = PLUGIN_DIR - -source_files = Dir.glob(File.join(__dir__, '..', PLUGIN_DIR, '*.swift')).sort -abort 'No Swift sources found for the Snowflake plugin' if source_files.empty? - -refs = source_files.map { |path| group.new_reference(File.basename(path)) } -target.add_file_references(refs) - -# new_target(:osx) auto-links Cocoa.framework; the other driver plugins link -# only TableProPluginKit, so clear the phase first to match them exactly. -target.frameworks_build_phase.files_references.dup.each do |ref| - target.frameworks_build_phase.remove_file_reference(ref) -end -# new_target also creates a stray Cocoa.framework file reference; drop it so the -# project stays identical to the other hand-authored plugin targets. -proj.files.select { |f| f.display_name == 'Cocoa.framework' }.each(&:remove_from_project) -target.frameworks_build_phase.add_file_reference(framework_ref) - -proj.save - -puts "🎉 Added #{TARGET_NAME} target with #{refs.length} source files" -puts ' Sources:' -refs.each { |r| puts " - #{r.path}" } From eedd2e0fe52ff892289b0571f21c10ac322e5991 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 6 Jun 2026 05:27:12 +0700 Subject: [PATCH 14/14] ci(plugins): let the abi-additive label acknowledge a reviewed additive PluginKit diff --- .github/workflows/pluginkit-abi.yml | 2 ++ CLAUDE.md | 2 +- scripts/check-pluginkit-abi.sh | 13 ++++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pluginkit-abi.yml b/.github/workflows/pluginkit-abi.yml index 438a6c781..33ec9daa5 100644 --- a/.github/workflows/pluginkit-abi.yml +++ b/.github/workflows/pluginkit-abi.yml @@ -2,6 +2,7 @@ name: PluginKit ABI Gate on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] paths: - "Plugins/TableProPluginKit/**" - "scripts/check-pluginkit-abi.sh" @@ -30,4 +31,5 @@ jobs: - name: Check PluginKit ABI vs base env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + ABI_ACKNOWLEDGED_ADDITIVE: ${{ contains(github.event.pull_request.labels.*.name, 'abi-additive') && '1' || '' }} run: scripts/check-pluginkit-abi.sh "$BASE_SHA" diff --git a/CLAUDE.md b/CLAUDE.md index 88d4ac6d1..3309534a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( **Bump `currentPluginKitVersion` (in `PluginManager.swift`) and `TableProPluginKitVersion` in every plugin `Info.plist` ONLY for a breaking change**: changing or removing an existing requirement's signature, adding a requirement without a default, adding a case to a `@frozen` enum, or changing a frozen type's layout. Mark a public enum `@frozen` only when an exhaustive switch over it forces it (the compiler flags the switch) and its case set is genuinely closed; leave the rest non-frozen so they can gain cases. `PluginCapability` stays non-frozen with `@unknown default` because it is a growing capability set, not a closed vocabulary. The driver protocols and transfer structs stay non-frozen so they can grow. The strict version gate in `validateBundleVersions` still rejects a stale plugin cleanly after a breaking bump (no `EXC_BAD_INSTRUCTION`). -**ABI gate**: `scripts/check-pluginkit-abi.sh [base-ref]` builds TableProPluginKit at the current tree and at the base ref with the same toolchain, then diffs their public interfaces. There is no committed baseline, so a Swift version difference between a dev machine and CI never produces a false diff. CI (`.github/workflows/pluginkit-abi.yml`) runs it on every PR that touches `Plugins/TableProPluginKit/**`, comparing against the PR base. A reported diff is a real ABI change: additive needs no bump; breaking needs the version bump above plus `release-all-plugins.sh`. (Until Library Evolution is on the base too, the base emits no interface and the gate passes as a bootstrap.) +**ABI gate**: `scripts/check-pluginkit-abi.sh [base-ref]` builds TableProPluginKit at the current tree and at the base ref with the same toolchain, then diffs their public interfaces. There is no committed baseline, so a Swift version difference between a dev machine and CI never produces a false diff. CI (`.github/workflows/pluginkit-abi.yml`) runs it on every PR that touches `Plugins/TableProPluginKit/**`, comparing against the PR base. A reported diff is a real ABI change: additive needs no bump; breaking needs the version bump above plus `release-all-plugins.sh`. After reviewing a diff as additive, add the `abi-additive` label to the PR; the gate then passes and records the decision. Remove the label if later commits add a breaking change. (Until Library Evolution is on the base too, the base emits no interface and the gate passes as a bootstrap.) **Post-ABI-bump checklist (mandatory, breaking bumps only)**: Bumps are now rare (only the breaking changes listed above). After one, every registry-published plugin must be rebuilt against the new ABI. Run `release-all-plugins.sh` for the new version BEFORE or WITH the app release, never after, or users on the new app hit `noCompatibleBinary` until the registry catches up. App auto-update reconciliation handles the user-facing recovery, but the registry has to carry binaries for the new PluginKit version first. diff --git a/scripts/check-pluginkit-abi.sh b/scripts/check-pluginkit-abi.sh index f32700506..e4a158d92 100755 --- a/scripts/check-pluginkit-abi.sh +++ b/scripts/check-pluginkit-abi.sh @@ -83,10 +83,21 @@ if diff -u "$work/base.txt" "$work/head.txt"; then exit 0 fi +if [ "${ABI_ACKNOWLEDGED_ADDITIVE:-}" = "1" ]; then + cat <<'EOF' + +::notice::TableProPluginKit public ABI changed vs base (diff above). +The PR carries the abi-additive label: a maintainer reviewed the diff as additive (new defaulted +requirements or non-frozen types), so no version bump is required and the gate passes. +Remove the label if the diff gains a breaking change; the gate will fail again. +EOF + exit 0 +fi + cat <<'EOF' ::error::TableProPluginKit public ABI changed vs base (diff above). Decide additive vs breaking: - Additive: no version bump. + Additive: no version bump. After review, add the abi-additive label to the PR and re-run. Breaking: bump currentPluginKitVersion + every plugin Info.plist TableProPluginKitVersion, then run scripts/release-all-plugins.sh . EOF