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/.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/CHANGELOG.md b/CHANGELOG.md index 3be2ac070..37323cc85 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 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/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/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..a32bf485a --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift @@ -0,0 +1,240 @@ +// +// 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 inDoubleQuotes = false + var inSingleQuotes = false + var result = "" + for char in line { + if char == "\"" && !inSingleQuotes { inDoubleQuotes.toggle() } + if char == "'" && !inDoubleQuotes { inSingleQuotes.toggle() } + if char == "#" && !inDoubleQuotes && !inSingleQuotes { 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/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/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..b306b8be6 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeConnection.swift @@ -0,0 +1,953 @@ +// +// 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 let heartbeat = SnowflakeHeartbeat() + private var sessionToken: String? + 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? + 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 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": + 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 + activeRequestIDs.removeAll() + } + Task { [weak self] in + await self?.heartbeat.stop() + 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] + 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" + } + 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 { + 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 { + 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() + + 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 ["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." + ) + } + 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") + } + 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 { + 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": true, + "CLIENT_SESSION_KEEP_ALIVE": true, + "QUERY_TAG": Self.appName + ] + 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, 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 operation() + } catch SnowflakeError.queryFailed(let code, _) where SnowflakeError.isReauthenticationCode(code) { + do { + try await renewSession() + } catch { + lock.withLock { sessionToken = nil } + try await connectIfNeeded() + } + return try await operation() + } + } + + func cancelAllQueries() { + let (requestIDs, token) = lock.withLock { (activeRequestIDs, sessionToken) } + guard !requestIDs.isEmpty, let token else { return } + Task { [weak self] in + 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, 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 + } + + let requestID = UUID().uuidString.lowercased() + let sequence = lock.withLock { () -> Int in + sequenceId += 1 + activeRequestIDs.insert(requestID) + return sequenceId + } + defer { + lock.withLock { _ = activeRequestIDs.remove(requestID) } + } + + var body: [String: Any] = [ + "sqlText": sql, + "asyncExec": false, + "sequenceId": sequence, + "querySubmissionTime": Int(Date().timeIntervalSince1970 * 1_000) + ] + if !parameters.isEmpty { + body["bindings"] = SnowflakeBindingEncoder.encode(parameters) + } else if SnowflakeSchemaQueries.isLikelyMultiStatement(sql) { + body["parameters"] = ["MULTI_STATEMENT_COUNT": 0] + } + + 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") + } + return (data, 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] { + [ + URLQueryItem(name: "requestId", value: requestID), + URLQueryItem(name: "request_guid", value: UUID().uuidString.lowercased()) + ] + } + + private static let queryPollTimeout: TimeInterval = 2_700 + + private func pollIfInProgress(_ initial: [String: Any], token: String) async throws -> [String: Any] { + var response = initial + let deadline = Date().addingTimeInterval(Self.queryPollTimeout) + while Self.isInProgress(response) { + guard let data = response["data"] as? [String: Any], + let resultPath = data["getResultUrl"] as? String else { + break + } + guard Date() < deadline else { + throw SnowflakeError.timeout("Query did not finish within 45 minutes") + } + 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 + } + + 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]] ?? []) + + 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 { + rows.append(contentsOf: try await downloadChunks(chunks, headers: Self.chunkRequestHeaders(from: data))) + } + + return SnowflakeQueryResult( + columns: columns, + rows: rows, + affectedRows: affectedRows, + isTruncated: false, + statusMessage: nil + ) + } + + 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]] { + 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 + } + 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 + } + } + } + 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 + + 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, 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( + "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 { + _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 } + 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/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/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/SnowflakeError.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift new file mode 100644 index 000000000..3362eded9 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeError.swift @@ -0,0 +1,75 @@ +// +// 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 { + static let reauthenticationCodes: Set = [ + "390110", "390112", "390113", "390114", "390115", "390195" + ] + + 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 { + 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/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/SnowflakeMFATokenStore.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift new file mode 100644 index 000000000..0f1f27681 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift @@ -0,0 +1,105 @@ +// +// 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) + } + + 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 = 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 { + guard let rejectedAt = rejectedPasscodes[key] else { return false } + return Date().timeIntervalSince(rejectedAt) < rejectedPasscodeLifetime + } + } + + private static var rejectedPasscodes: [String: Date] = [:] + + 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..7d92ed25c --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -0,0 +1,335 @@ +// +// 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 = 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 = true + static let supportsHealthMonitor = false + static let supportsDatabaseSwitching = true + static let supportsSchemaSwitching = true + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + static let supportsImport = true + 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+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 new file mode 100644 index 000000000..8af541d4e --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -0,0 +1,696 @@ +// +// 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 var resolvedSchemaCache: [String: String] = [:] + private var columnTypeCache: [String: [String: 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, .parameterizedQueries, .alterTableDDL] + } + + func cancelQuery() throws { + connection?.cancelAllQueries() + } + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { true } + 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 + + func connect() async throws { + 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()"), + let first = result.rows.first?.first, case .text(let version) = first { + lock.withLock { _serverVersion = "Snowflake \(version)" } + } else { + lock.withLock { _serverVersion = "Snowflake" } + } + } + + func disconnect() { + let conn: SnowflakeConnection? = lock.withLock { + let current = _connection + _connection = nil + return current + } + if let conn { + SnowflakeConnectionRegistry.shared.release(conn) + } + } + + 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) + 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() + columnTypeCache.removeAll() + } + } + + // 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? { + 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 { + 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 { + _ = try await rawQuery("DROP DATABASE IF EXISTS \(quoteIdentifier(name))") + } + + // 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 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 + 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: 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 { + 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, schema: targetSchema, 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] { + let database = connection?.currentDatabase + 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) + + 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) + Self.logger.debug( + "fetchColumns table=\(table, privacy: .public) schema=\(targetSchema, privacy: .public) database=\(database, privacy: .public) rows=\(result.rows.count, privacy: .public)" + ) + 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" + 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 + ) + } + cacheColumnTypes(table: table, schema: targetSchema, columns: columns) + return columns + } + + 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[key] = types } + } + + func columnTypeNames(for table: String, columns: [String]) -> [String] { + 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 + 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? { + let database = connection?.currentDatabase + 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 + 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 streamed = try await conn.queryStreamed(trimmed) + continuation.yield(.header(PluginStreamHeader( + columns: streamed.columns.map(\.name), + columnTypeNames: streamed.columns.map(SnowflakeTypeMapper.displayType), + estimatedRowCount: streamed.estimatedRowCount + ))) + 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 { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + // MARK: - Private Helpers + + func qualifiedName(table: String, schema: String?) -> String { + 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))" + } + 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 = 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 { + throw SnowflakeError.invalidResponse("No DDL returned for \(name)") + } + return ddl + } + + 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 + } + + /// 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) + } + + 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/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..e9c902028 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift @@ -0,0 +1,144 @@ +// +// 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 os +import TableProPluginKit + +struct SnowflakeStatementGenerator { + private static let logger = Logger(subsystem: "com.TablePro", category: "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 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] = [] + 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 { + 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) + } + + 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/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift new file mode 100644 index 000000000..d99fc42b3 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift @@ -0,0 +1,63 @@ +// +// SnowflakeTypeMapper.swift +// SnowflakeDriverPlugin +// +// Maps Snowflake's internal row metadata types to display type names. +// + +import Foundation + +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() + } + } +} 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.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index beadf9b0b..f67a262e8 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* 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 */; }; 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 */; }; @@ -54,6 +60,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 */; }; @@ -76,6 +83,17 @@ 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 */; }; + 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 */ @@ -301,6 +319,15 @@ /* 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 = ""; }; + 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; }; @@ -352,6 +379,15 @@ 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 = ""; }; + 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 = ""; }; + 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 */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1003,6 +1039,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C49F7C0CBE979B96ACFB9ABC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1049,6 +1093,7 @@ 5A1091C82EF17EDC0055EA7C /* Products */, 5A05FBC72F3EDF7500819CD7 /* Recovered References */, 5AEA8B482F6808E90040461A /* Frameworks */, + 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, ); sourceTree = ""; }; @@ -1084,6 +1129,7 @@ 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */, 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */, 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */, + 48B9743D4BDA458C9C0502A8 /* SnowflakeDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -1137,6 +1183,31 @@ name = Frameworks; sourceTree = ""; }; + 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */ = { + isa = PBXGroup; + children = ( + F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */, + DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */, + 84A5EE73D62C4576A9B9DFF2 /* SnowflakeStatementGenerator.swift */, + 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */, + 3C399209C2D14499870FBD49 /* SnowflakeSchemaQueries.swift */, + 31FF6EBB13774833BA087FEB /* SnowflakePluginDriver+DDL.swift */, + 55F1B63541C24A10BF4AF873 /* SnowflakeHTTPRetry.swift */, + C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */, + 95E00BB6B3C84C9583510E67 /* SnowflakeHeartbeat.swift */, + 7D67B38E07DF4F42A9C86639 /* SnowflakeIdTokenStore.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 */ @@ -1774,6 +1845,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 */ @@ -1910,11 +1998,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; @@ -2114,6 +2210,30 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 329606C9C47DD1FA4F8F5350 /* Sources */ = { + isa = PBXSourcesBuildPhase; + 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 */, + 79535441E5CF45039A08AF48 /* SnowflakePluginDriver+DDL.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 */, + 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; @@ -2459,6 +2579,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 = { @@ -4279,6 +4448,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 = ( 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/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/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/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/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 764832255..2ba590656 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: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "text", label: "Explain (Text)", sqlPrefix: "EXPLAIN USING TEXT") + ], + pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: [], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#29B5E8", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + 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/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/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/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 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/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/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/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/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/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/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/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/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/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/TableProTests/Plugins/SnowflakeProtocolTests.swift b/TableProTests/Plugins/SnowflakeProtocolTests.swift new file mode 100644 index 000000000..03e6c7e87 --- /dev/null +++ b/TableProTests/Plugins/SnowflakeProtocolTests.swift @@ -0,0 +1,157 @@ +// +// 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)) + } + } + + @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") +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") + 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) + } +} 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 new file mode 100644 index 000000000..88ac3de3c --- /dev/null +++ b/docs/databases/snowflake.mdx @@ -0,0 +1,101 @@ +--- +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`). 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. + + +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. + +**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 +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. + +**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 + +**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. + +**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. + +**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 display as informational; Snowflake never enforces them 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/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 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 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 )