Skip to content

Commit 2d5b704

Browse files
xantiagomaclaudedatlechin
authored
feat(plugins): add Snowflake database driver (#1420) (#1580)
* feat(plugins): add Snowflake database driver (#1420) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): allow loading locally built plugins in DEBUG builds Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): implement create and drop database in the Snowflake driver Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): resolve the schema for schema-unaware metadata calls in the Snowflake driver Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugin-snowflake): poll long queries with backoff up to 45 minutes and add unit tests for auth parsing and type mapping * fix(plugin-snowflake): trust the login session info for database and schema instead of the form values * feat(plugin-snowflake): server-side bindings, session heartbeat, SSO token cache, transport retry, and cancel hardening * feat(plugin-snowflake): warehouse-free metadata, key introspection, structure editing, import, streaming, and multi-statement scripts * feat(plugins): session context pickers with Snowflake warehouse and role switching in the toolbar * fix(plugin-snowflake): share one session across pooled drivers, never replay a rejected MFA passcode, and show restricted schemas as empty * fix(plugin-snowflake): coalesce shared-session re-login, scope the column type cache by schema, and expire rejected passcodes * Update project.pbxproj * chore(plugin-snowflake): drop the one-time xcode bootstrap script and revert project file churn * ci(plugins): let the abi-additive label acknowledge a reviewed additive PluginKit diff --------- Signed-off-by: Santiago Montoya A. <xantiagoma@gmail.com> Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 716b23f commit 2d5b704

58 files changed

Lines changed: 4810 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-plugin.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ jobs:
199199
DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API"
200200
DB_TYPE_IDS='["BigQuery"]'; ICON="bigquery-icon"; BUNDLE_NAME="BigQueryDriverPlugin"
201201
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/bigquery" ;;
202+
snowflake)
203+
TARGET="SnowflakeDriverPlugin"; BUNDLE_ID="com.TablePro.SnowflakeDriverPlugin"
204+
DISPLAY_NAME="Snowflake Driver"; SUMMARY="Snowflake cloud data warehouse driver via the connector REST protocol"
205+
DB_TYPE_IDS='["Snowflake"]'; ICON="snowflake-icon"; BUNDLE_NAME="SnowflakeDriverPlugin"
206+
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/snowflake" ;;
202207
xlsx)
203208
TARGET="XLSXExport"; BUNDLE_ID="com.TablePro.XLSXExportPlugin"
204209
DISPLAY_NAME="XLSX Export"; SUMMARY="Export data to Microsoft Excel XLSX format"

.github/workflows/pluginkit-abi.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: PluginKit ABI Gate
22

33
on:
44
pull_request:
5+
types: [opened, synchronize, reopened, labeled, unlabeled]
56
paths:
67
- "Plugins/TableProPluginKit/**"
78
- "scripts/check-pluginkit-abi.sh"
@@ -30,4 +31,5 @@ jobs:
3031
- name: Check PluginKit ABI vs base
3132
env:
3233
BASE_SHA: ${{ github.event.pull_request.base.sha }}
34+
ABI_ACKNOWLEDGED_ADDITIVE: ${{ contains(github.event.pull_request.labels.*.name, 'abi-additive') && '1' || '' }}
3335
run: scripts/check-pluginkit-abi.sh "$BASE_SHA"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- 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)
1213
- 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)
1314
- 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.
1415
- 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)

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (
103103

104104
**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`).
105105

106-
**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.)
106+
**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.)
107107

108108
**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.
109109

Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
2525
public static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1")
2626
public static let dynamodb = DatabaseType(rawValue: "DynamoDB")
2727
public static let bigquery = DatabaseType(rawValue: "BigQuery")
28+
public static let snowflake = DatabaseType(rawValue: "Snowflake")
2829
public static let libsql = DatabaseType(rawValue: "libSQL")
2930
public static let cockroachdb = DatabaseType(rawValue: "CockroachDB")
3031
public static let scylladb = DatabaseType(rawValue: "ScyllaDB")
@@ -33,7 +34,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
3334
public static let allKnownTypes: [DatabaseType] = [
3435
.mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb,
3536
.clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift,
36-
.etcd, .cloudflareD1, .dynamodb, .bigquery, .libsql
37+
.etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql
3738
]
3839

3940
/// 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 {
5556
case .cloudflareD1: return "cloudflare-d1-icon"
5657
case .dynamodb: return "dynamodb-icon"
5758
case .bigquery: return "bigquery-icon"
59+
case .snowflake: return "snowflake-icon"
5860
case .libsql: return "libsql-icon"
5961
default: return "externaldrive"
6062
}

Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ struct DatabaseTypeTests {
1515
#expect(DatabaseType.mssql.rawValue == "SQL Server")
1616
#expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1")
1717
#expect(DatabaseType.bigquery.rawValue == "BigQuery")
18+
#expect(DatabaseType.snowflake.rawValue == "Snowflake")
1819
}
1920

2021
@Test("pluginTypeId maps multi-type databases")
@@ -51,9 +52,10 @@ struct DatabaseTypeTests {
5152

5253
@Test("allKnownTypes contains all expected types")
5354
func allKnownTypesComplete() {
54-
#expect(DatabaseType.allKnownTypes.count == 17)
55+
#expect(DatabaseType.allKnownTypes.count == 18)
5556
#expect(DatabaseType.allKnownTypes.contains(.mysql))
5657
#expect(DatabaseType.allKnownTypes.contains(.bigquery))
58+
#expect(DatabaseType.allKnownTypes.contains(.snowflake))
5759
#expect(DatabaseType.allKnownTypes.contains(.libsql))
5860
}
5961

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>TableProMinAppVersion</key>
6+
<string>0.48.0</string>
7+
<key>TableProPluginKitVersion</key>
8+
<integer>18</integer>
9+
</dict>
10+
</plist>
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
//
2+
// SnowflakeAuth.swift
3+
// SnowflakeDriverPlugin
4+
//
5+
// Account identifier parsing, key-pair JWT generation, and
6+
// ~/.snowflake/connections.toml parsing.
7+
//
8+
9+
import CryptoKit
10+
import Foundation
11+
import os
12+
import Security
13+
14+
enum SnowflakeAccount {
15+
static func host(forAccount account: String) -> String {
16+
let trimmed = account.trimmingCharacters(in: .whitespacesAndNewlines)
17+
if trimmed.lowercased().hasSuffix(".snowflakecomputing.com") {
18+
return trimmed
19+
}
20+
if trimmed.contains("://") {
21+
return URL(string: trimmed)?.host ?? trimmed
22+
}
23+
return "\(trimmed).snowflakecomputing.com"
24+
}
25+
26+
/// The account name used as the JWT issuer/subject prefix. Snowflake expects the
27+
/// account locator without any region/cloud segment, uppercased.
28+
static func issuerAccountName(forAccount account: String) -> String {
29+
var name = account.trimmingCharacters(in: .whitespacesAndNewlines)
30+
if name.lowercased().hasSuffix(".snowflakecomputing.com") {
31+
name = String(name.dropLast(".snowflakecomputing.com".count))
32+
}
33+
if let dotIndex = name.firstIndex(of: ".") {
34+
name = String(name[..<dotIndex])
35+
}
36+
return name.uppercased()
37+
}
38+
}
39+
40+
struct SnowflakeKeyPairAuth {
41+
private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeKeyPairAuth")
42+
43+
let account: String
44+
let user: String
45+
let privateKeyPEM: String
46+
let passphrase: String?
47+
48+
func makeJWT(lifetime: TimeInterval = 3_540) throws -> String {
49+
let privateKey = try loadPrivateKey()
50+
let qualifiedUser = "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())"
51+
let fingerprint = try publicKeyFingerprint(for: privateKey)
52+
let issuer = "\(qualifiedUser).\(fingerprint)"
53+
54+
let now = Date()
55+
let iat = Int(now.timeIntervalSince1970)
56+
let exp = iat + Int(lifetime)
57+
58+
let headerJSON = #"{"alg":"RS256","typ":"JWT"}"#
59+
let claimsJSON = #"{"iss":"\#(issuer)","sub":"\#(qualifiedUser)","iat":\#(iat),"exp":\#(exp)}"#
60+
61+
let signingInput = "\(base64URL(Data(headerJSON.utf8))).\(base64URL(Data(claimsJSON.utf8)))"
62+
let signature = try sign(Data(signingInput.utf8), with: privateKey)
63+
return "\(signingInput).\(base64URL(signature))"
64+
}
65+
66+
private func loadPrivateKey() throws -> SecKey {
67+
guard let pemData = privateKeyPEM.data(using: .utf8) else {
68+
throw SnowflakeError.authFailed("Private key is not valid UTF-8")
69+
}
70+
71+
var inputFormat = SecExternalFormat.formatUnknown
72+
var itemType = SecExternalItemType.itemTypeUnknown
73+
var importedItems: CFArray?
74+
75+
var keyParams = SecItemImportExportKeyParameters()
76+
var passphraseRef: CFTypeRef?
77+
if let passphrase, !passphrase.isEmpty {
78+
let ref = passphrase as CFString
79+
passphraseRef = ref
80+
keyParams.passphrase = Unmanaged.passUnretained(ref)
81+
}
82+
_ = passphraseRef
83+
84+
let status = SecItemImport(
85+
pemData as CFData,
86+
"p8" as CFString,
87+
&inputFormat,
88+
&itemType,
89+
SecItemImportExportFlags(rawValue: 0),
90+
&keyParams,
91+
nil,
92+
&importedItems
93+
)
94+
95+
guard status == errSecSuccess,
96+
let items = importedItems as? [SecKey],
97+
let key = items.first
98+
else {
99+
throw SnowflakeError.authFailed(
100+
"Failed to load private key (OSStatus \(status)). Ensure the file is a valid RSA .p8 key and the passphrase is correct."
101+
)
102+
}
103+
return key
104+
}
105+
106+
private func publicKeyFingerprint(for privateKey: SecKey) throws -> String {
107+
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
108+
throw SnowflakeError.authFailed("Could not derive public key from private key")
109+
}
110+
var error: Unmanaged<CFError>?
111+
guard let pkcs1 = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
112+
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
113+
throw SnowflakeError.authFailed("Could not export public key: \(message)")
114+
}
115+
let spki = Self.wrapPKCS1IntoSPKI(pkcs1)
116+
let digest = SHA256.hash(data: spki)
117+
return "SHA256:\(Data(digest).base64EncodedString())"
118+
}
119+
120+
private func sign(_ data: Data, with key: SecKey) throws -> Data {
121+
var error: Unmanaged<CFError>?
122+
guard let signature = SecKeyCreateSignature(
123+
key, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error
124+
) as Data? else {
125+
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
126+
throw SnowflakeError.authFailed("Failed to sign JWT: \(message)")
127+
}
128+
return signature
129+
}
130+
131+
private func base64URL(_ data: Data) -> String {
132+
data.base64EncodedString()
133+
.replacingOccurrences(of: "+", with: "-")
134+
.replacingOccurrences(of: "/", with: "_")
135+
.replacingOccurrences(of: "=", with: "")
136+
}
137+
138+
/// Wrap a PKCS#1 RSAPublicKey DER blob into a SubjectPublicKeyInfo DER blob,
139+
/// which is what Snowflake fingerprints with SHA-256.
140+
static func wrapPKCS1IntoSPKI(_ pkcs1: Data) -> Data {
141+
let rsaAlgorithmID: [UInt8] = [
142+
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86,
143+
0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00
144+
]
145+
var bitString: [UInt8] = [0x03]
146+
bitString += derLength(pkcs1.count + 1)
147+
bitString.append(0x00)
148+
bitString += [UInt8](pkcs1)
149+
150+
var body = rsaAlgorithmID
151+
body += bitString
152+
153+
var spki: [UInt8] = [0x30]
154+
spki += derLength(body.count)
155+
spki += body
156+
return Data(spki)
157+
}
158+
159+
private static func derLength(_ length: Int) -> [UInt8] {
160+
if length < 0x80 {
161+
return [UInt8(length)]
162+
}
163+
var value = length
164+
var bytes: [UInt8] = []
165+
while value > 0 {
166+
bytes.insert(UInt8(value & 0xFF), at: 0)
167+
value >>= 8
168+
}
169+
return [UInt8(0x80 | bytes.count)] + bytes
170+
}
171+
}
172+
173+
enum SnowflakeConnectionsTOML {
174+
/// Look up the named connection in the Snowflake CLI's config files, checking
175+
/// ~/.snowflake/connections.toml first, then [connections.*] sections in
176+
/// ~/.snowflake/config.toml. Keys follow the CLI's snake_case naming
177+
/// (account, user, password, authenticator, private_key_file, role, ...).
178+
static func parameters(forConnection name: String) -> [String: String]? {
179+
for filename in ["connections.toml", "config.toml"] {
180+
let path = NSString(string: "~/.snowflake/\(filename)").expandingTildeInPath
181+
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
182+
if let section = parse(contents)[name] {
183+
return section
184+
}
185+
}
186+
return nil
187+
}
188+
189+
static func parse(_ contents: String) -> [String: [String: String]] {
190+
var sections: [String: [String: String]] = [:]
191+
var currentSection: String?
192+
193+
for rawLine in contents.components(separatedBy: .newlines) {
194+
let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces)
195+
if line.isEmpty { continue }
196+
197+
if line.hasPrefix("[") && line.hasSuffix("]") {
198+
var name = String(line.dropFirst().dropLast())
199+
if name.hasPrefix("connections.") {
200+
name = String(name.dropFirst("connections.".count))
201+
}
202+
name = name.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
203+
currentSection = name
204+
if sections[name] == nil { sections[name] = [:] }
205+
continue
206+
}
207+
208+
guard let section = currentSection,
209+
let equalIndex = line.firstIndex(of: "=") else { continue }
210+
211+
let key = line[..<equalIndex].trimmingCharacters(in: .whitespaces)
212+
let value = unquote(String(line[line.index(after: equalIndex)...]).trimmingCharacters(in: .whitespaces))
213+
sections[section]?[key] = value
214+
}
215+
return sections
216+
}
217+
218+
private static func stripComment(_ line: String) -> String {
219+
var inDoubleQuotes = false
220+
var inSingleQuotes = false
221+
var result = ""
222+
for char in line {
223+
if char == "\"" && !inSingleQuotes { inDoubleQuotes.toggle() }
224+
if char == "'" && !inDoubleQuotes { inSingleQuotes.toggle() }
225+
if char == "#" && !inDoubleQuotes && !inSingleQuotes { break }
226+
result.append(char)
227+
}
228+
return result
229+
}
230+
231+
private static func unquote(_ value: String) -> String {
232+
if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
233+
return String(value.dropFirst().dropLast())
234+
}
235+
if value.count >= 2, value.hasPrefix("'"), value.hasSuffix("'") {
236+
return String(value.dropFirst().dropLast())
237+
}
238+
return value
239+
}
240+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// SnowflakeBindingEncoder.swift
3+
// SnowflakeDriverPlugin
4+
//
5+
// Encodes PluginCellValue parameters into the Snowflake v1 query-request
6+
// "bindings" payload: 1-based string keys, TEXT for scalar values (the server
7+
// coerces into the target column type), BINARY as hex, null as a typed null.
8+
//
9+
10+
import Foundation
11+
import TableProPluginKit
12+
13+
enum SnowflakeBindingEncoder {
14+
static func encode(_ parameters: [PluginCellValue]) -> [String: [String: Any]] {
15+
var bindings: [String: [String: Any]] = [:]
16+
bindings.reserveCapacity(parameters.count)
17+
for (index, parameter) in parameters.enumerated() {
18+
bindings[String(index + 1)] = binding(for: parameter)
19+
}
20+
return bindings
21+
}
22+
23+
private static func binding(for value: PluginCellValue) -> [String: Any] {
24+
switch value {
25+
case .null:
26+
return ["type": "TEXT", "value": NSNull()]
27+
case .text(let text):
28+
return ["type": "TEXT", "value": text]
29+
case .bytes(let data):
30+
return ["type": "BINARY", "value": hex(data)]
31+
default:
32+
return ["type": "TEXT", "value": NSNull()]
33+
}
34+
}
35+
36+
private static func hex(_ data: Data) -> String {
37+
data.map { String(format: "%02X", $0) }.joined()
38+
}
39+
}

0 commit comments

Comments
 (0)