Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2fda293
feat(plugins): add Snowflake database driver (#1420)
xantiagoma Jun 4, 2026
4e90b05
feat(plugins): allow loading locally built plugins in DEBUG builds
xantiagoma Jun 4, 2026
272bd1d
Merge branch 'main' into feat/snowflake-driver
xantiagoma Jun 4, 2026
aabf4c7
fix(plugins): implement create and drop database in the Snowflake driver
xantiagoma Jun 4, 2026
c4d6f86
fix(plugins): resolve the schema for schema-unaware metadata calls in…
xantiagoma Jun 5, 2026
6103296
Merge remote-tracking branch 'origin/main' into feat/snowflake-driver
xantiagoma Jun 5, 2026
c30a5b1
Merge branch 'main' into feat/snowflake-driver
datlechin Jun 5, 2026
75c3f12
fix(plugin-snowflake): poll long queries with backoff up to 45 minute…
datlechin Jun 5, 2026
57a9f3b
fix(plugin-snowflake): trust the login session info for database and …
datlechin Jun 5, 2026
e9b2d32
feat(plugin-snowflake): server-side bindings, session heartbeat, SSO …
datlechin Jun 5, 2026
3990029
feat(plugin-snowflake): warehouse-free metadata, key introspection, s…
datlechin Jun 5, 2026
371ba1e
feat(plugins): session context pickers with Snowflake warehouse and r…
datlechin Jun 5, 2026
6bb5f1d
fix(plugin-snowflake): share one session across pooled drivers, never…
datlechin Jun 5, 2026
e70ba83
fix(plugin-snowflake): coalesce shared-session re-login, scope the co…
datlechin Jun 5, 2026
f535675
Update project.pbxproj
datlechin Jun 5, 2026
58b89d5
chore(plugin-snowflake): drop the one-time xcode bootstrap script and…
datlechin Jun 5, 2026
eedd2e0
ci(plugins): let the abi-additive label acknowledge a reviewed additi…
datlechin Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pluginkit-abi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
}

Expand Down
10 changes: 10 additions & 0 deletions Plugins/SnowflakeDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TableProMinAppVersion</key>
<string>0.48.0</string>
<key>TableProPluginKitVersion</key>
<integer>18</integer>
</dict>
</plist>
240 changes: 240 additions & 0 deletions Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift
Original file line number Diff line number Diff line change
@@ -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[..<dotIndex])
}
Comment on lines +29 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize URL account forms before deriving ACCOUNT_NAME

When a user enters a Snowflake account as a URL form that host(forAccount:) accepts (for example https://abc.snowflakecomputing.com/console or even https://abc.snowflakecomputing.com), this helper runs the suffix/dot stripping against the raw URL string and returns values like HTTPS://ABC for ACCOUNT_NAME/JWT issuer. The request is then sent to the correct host but with an invalid Snowflake account name, so URL-form connections fail authentication; parse the URL host before stripping the Snowflake domain/region.

Useful? React with 👍 / 👎.

return name.uppercased()
}
}

struct SnowflakeKeyPairAuth {
private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeKeyPairAuth")

let account: String
let user: String
let privateKeyPEM: String
let passphrase: String?

func makeJWT(lifetime: TimeInterval = 3_540) throws -> 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<CFError>?
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<CFError>?
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[..<equalIndex].trimmingCharacters(in: .whitespaces)
let value = unquote(String(line[line.index(after: equalIndex)...]).trimmingCharacters(in: .whitespaces))
sections[section]?[key] = value
}
return sections
}

private static func stripComment(_ line: String) -> 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
}
}
39 changes: 39 additions & 0 deletions Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading