Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -203,6 +203,11 @@ jobs:
DISPLAY_NAME="Cloudflare D1 Driver"; SUMMARY="Cloudflare D1 serverless SQLite-compatible database driver via REST API"
DB_TYPE_IDS='["Cloudflare D1"]'; ICON="cloudflare-d1-icon"; BUNDLE_NAME="CloudflareD1DriverPlugin"
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-d1" ;;
cloudflare-r2-sql)
TARGET="CloudflareR2SQLDriverPlugin"; BUNDLE_ID="com.TablePro.CloudflareR2SQLDriverPlugin"
DISPLAY_NAME="Cloudflare R2 SQL Driver"; SUMMARY="Read-only Cloudflare R2 SQL driver for Iceberg tables in R2 Data Catalog via REST API"
DB_TYPE_IDS='["Cloudflare R2 SQL"]'; ICON="cloudflare-r2-sql-icon"; BUNDLE_NAME="CloudflareR2SQLDriverPlugin"
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-r2-sql" ;;
libsql)
TARGET="LibSQLDriverPlugin"; BUNDLE_ID="com.TablePro.LibSQLDriverPlugin"
DISPLAY_NAME="libSQL / Turso Driver"; SUMMARY="libSQL and Turso database support via Hrana HTTP protocol"
Expand Down
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

- Cloudflare R2 SQL support as a downloadable, read-only driver. Connect with an account ID, a bucket, and an API token, browse Iceberg namespaces and tables, and run SELECT queries against them. (#3885)
- Redshift external schemas now list their tables. Spectrum, federated query, cross-database, and datashare schemas showed up empty because their tables are not in the standard catalog.
- External schemas are marked in the sidebar, and their tables show an external icon. External tables open read-only, because Redshift rejects `UPDATE` and `DELETE` on them.

Expand Down
13 changes: 12 additions & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ let package = Package(
.library(name: "TableProAnalytics", targets: ["TableProAnalytics"]),
.library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]),
.library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]),
.library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"])
.library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"])
],
targets: [
.target(
Expand Down Expand Up @@ -84,6 +85,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProTrinoCore"
),
.target(
name: "TableProR2SQLCore",
dependencies: [],
path: "Sources/TableProR2SQLCore"
),
.testTarget(
name: "TableProModelsTests",
dependencies: ["TableProModels", "TableProPluginKit"],
Expand Down Expand Up @@ -124,6 +130,11 @@ let package = Package(
dependencies: ["TableProTrinoCore"],
path: "Tests/TableProTrinoCoreTests"
),
.testTarget(
name: "TableProR2SQLCoreTests",
dependencies: ["TableProR2SQLCore"],
path: "Tests/TableProR2SQLCoreTests"
),
.testTarget(
name: "TableProSyncTests",
dependencies: ["TableProSync", "TableProSyncTransport", "TableProModels"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let surrealdb = DatabaseType(rawValue: "SurrealDB")
public static let teradata = DatabaseType(rawValue: "Teradata")
public static let trino = DatabaseType(rawValue: "Trino")
public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL")

public static let allKnownTypes: [DatabaseType] = [
.mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb,
.clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift,
.etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount,
.surrealdb, .teradata, .trino
.surrealdb, .teradata, .trino, .cloudflareR2SQL
]

/// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback
Expand Down Expand Up @@ -67,6 +68,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
case .surrealdb: return "surrealdb-icon"
case .teradata: return "teradata-icon"
case .trino: return "trino-icon"
case .cloudflareR2SQL: return "cloudflare-r2-sql-icon"
default: return "externaldrive"
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

public struct R2SQLConnectionConfig: Sendable, Equatable {
public static let queryHost = "api.sql.cloudflarestorage.com"

public let accountId: String
public let bucket: String
public let token: String
public let defaultNamespace: String
public let timeoutSeconds: Int

public init(
accountId: String,
bucket: String,
token: String,
defaultNamespace: String = "",
timeoutSeconds: Int = 60
) {
self.accountId = accountId.trimmingCharacters(in: .whitespacesAndNewlines)
self.bucket = bucket.trimmingCharacters(in: .whitespacesAndNewlines)
self.token = token
self.defaultNamespace = defaultNamespace.trimmingCharacters(in: .whitespacesAndNewlines)
self.timeoutSeconds = timeoutSeconds
}

public var warehouse: String {
"\(accountId)_\(bucket)"
}

public var queryURL: URL? {
var components = URLComponents()
components.scheme = "https"
components.host = Self.queryHost
components.path = "/api/v1/accounts/\(accountId)/r2-sql/query/\(bucket)"
return components.url
}

public func validate() -> R2SQLError? {
if accountId.isEmpty {
return .configuration(R2SQLErrorText.missingAccountId)
}
if bucket.isEmpty {
return .configuration(R2SQLErrorText.missingBucket)
}
if token.isEmpty {
return .configuration(R2SQLErrorText.missingToken)
}
if queryURL == nil {
return .configuration(R2SQLErrorText.invalidEndpoint)
}
return nil
}
}

public enum R2SQLWarehouse {
public static func split(_ warehouse: String) -> (accountId: String, bucket: String)? {
guard let separator = warehouse.firstIndex(of: "_") else { return nil }
let accountId = String(warehouse[warehouse.startIndex..<separator])
let bucket = String(warehouse[warehouse.index(after: separator)...])
guard !accountId.isEmpty, !bucket.isEmpty else { return nil }
return (accountId, bucket)
}
}
61 changes: 61 additions & 0 deletions Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

public struct R2SQLAPIError: Decodable, Sendable, Equatable {
public let code: Int
public let message: String

public init(code: Int, message: String) {
self.code = code
self.message = message
}
}

public enum R2SQLError: Error, LocalizedError, Equatable {
case configuration(String)
case notConnected
case transport(String)
case authentication(String)
case query(R2SQLAPIError)
case api([R2SQLAPIError])
case malformedResponse(status: Int, body: String)
case unsupported(String)
case cancelled

public var errorDescription: String? {
switch self {
case .configuration(let detail):
return detail
case .notConnected:
return "Not connected to R2 SQL"
case .transport(let detail):
return detail
case .authentication(let detail):
return detail
case .query(let error):
return error.message
case .api(let errors):
let joined = errors.map(\.message).filter { !$0.isEmpty }.joined(separator: "\n")
return joined.isEmpty ? "R2 SQL returned an unspecified error" : joined
case .malformedResponse(let status, let body):
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return "R2 SQL returned an unreadable response (HTTP \(status))"
}
return "R2 SQL returned an unreadable response (HTTP \(status)): \(trimmed.prefix(200))"
case .unsupported(let detail):
return detail
case .cancelled:
return "Query was cancelled"
}
}
}

public enum R2SQLErrorText {
public static let missingAccountId = "Account ID is required"
public static let missingBucket = "Bucket is required"
public static let missingToken = "API token is required"
public static let invalidEndpoint = "Could not build the R2 SQL endpoint from the account ID and bucket"
public static let noNamespace = "Select a namespace before browsing tables"
public static let noViews = "R2 SQL does not support views"
public static let readOnlyEngine = "R2 SQL is a read-only query engine"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

public enum R2SQLErrorClassifier {
public static let missingTokenCode = 80_007
public static let invalidTokenCode = 80_011
public static let invalidAccountCode = 80_016

private static let authenticationCodes: Set<Int> = [missingTokenCode, invalidTokenCode]

public static func decode(_ response: R2SQLHTTPResponse) -> Result<R2SQLResult, R2SQLError> {
guard let envelope = try? JSONDecoder().decode(R2SQLEnvelope.self, from: response.body) else {
return .failure(.malformedResponse(
status: response.statusCode,
body: String(data: response.body, encoding: .utf8) ?? ""
))
}

guard envelope.success else {
return .failure(classify(errors: envelope.errors, statusCode: response.statusCode))
}

guard let result = envelope.result else {
return .success(R2SQLResult(schema: [], rows: []))
}
return .success(result)
}

public static func classify(errors: [R2SQLAPIError], statusCode: Int) -> R2SQLError {
guard let first = errors.first else {
return authenticationStatus(statusCode) ?? .malformedResponse(status: statusCode, body: "")
}

if authenticationCodes.contains(first.code) {
return .authentication(authenticationGuidance(first.message))
}
if first.code == invalidAccountCode {
return .authentication("\(first.message). Check the Account ID on this connection.")
}
if let status = authenticationStatus(statusCode) {
return status
}
return errors.count == 1 ? .query(first) : .api(errors)
}

private static func authenticationStatus(_ statusCode: Int) -> R2SQLError? {
switch statusCode {
case 401:
return .authentication(authenticationGuidance("Unauthenticated."))
case 403:
return .authentication(authenticationGuidance("Forbidden."))
default:
return nil
}
}

private static func authenticationGuidance(_ message: String) -> String {
"""
\(message) The API token needs the R2 SQL, R2 Data Catalog and R2 Storage permission groups \
for this account.
"""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public enum R2SQLIntrospectionSQL {
public static func showNamespaces() -> String {
"SHOW NAMESPACES"
}

public static func showTables(namespace: String) -> String {
"SHOW TABLES IN \(R2SQLLiteral.quoteQualifiedName(namespace))"
}

public static func describe(namespace: String, table: String) -> String {
"DESCRIBE \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))"
}
}
Loading
Loading