Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/cloudflare-d1"
},
"cloudflare-r2-sql": {
"target": "CloudflareR2SQLDriverPlugin",
"bundleName": "CloudflareR2SQLDriverPlugin",
"bundleId": "com.TablePro.CloudflareR2SQLDriverPlugin",
"bundled": false,
"displayName": "Cloudflare R2 SQL Driver",
"summary": "Read-only Cloudflare R2 SQL driver for Iceberg tables in R2 Data Catalog via REST API",
"databaseTypeIds": [
"Cloudflare R2 SQL"
],
"icon": "cloudflare-r2-sql-icon",
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/cloudflare-r2-sql"
},
"dameng": {
"target": "DamengDriver",
"bundleName": "DamengDriver",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TiDB and Databend connection types on the MySQL driver. (#1066, #2514)
- Empty state in the inspector and the assistant for a connection that is not up.
- **Check connections** in Settings > General, including Only when I use the connection. (#2700)
- Cloudflare R2 SQL driver, read-only, for Iceberg tables in R2 Data Catalog. (#2030)
- Tips for preview tabs, Open Quickly, and query history.
- **Enable Plugin**, **Open Plugin Settings** and **Edit Connection…** on a connection whose driver cannot load.
- Invisible characters shown in the SQL editor: control characters, zero-width spaces, bidi controls and special spaces. (#2717)
Expand All @@ -28,9 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New Connection… and Import on the welcome window, named as in the File menu.
- Open Project Folder… in File > Import.
- First-launch tour replaced by a one-page welcome sheet, shown again from Help > Getting Started.
- Beancount connections held at Safe Mode Read-Only. (#2030)

### Fixed

- Safe Mode minimum from a configuration profile missing from the toolbar, the Database menu and the connection form. (#2030)
- Stop not ending queries on MySQL and MariaDB servers without TLS.
- Users & Roles failing, Stop not ending queries and sequences listed as tables on TiDB servers opened as MySQL.
- Update release notes show all changes for the offered version, with new features before fixes and properly formatted Markdown. The full changelog is also available from Help and Software Update settings.
Expand All @@ -51,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Table transfer abortable by Stop from an unrelated tab, part-applied. (#2700)
- SQLite, DuckDB and Teradata connections pinged every 30 seconds despite opting out of health checks. (#2700)
- Data grid dropping the UTC offset from a `timestamp with time zone` value. (#2702)
- Edits accepted on a SQLite remote file, which only ever changed the local copy. (#2030)
- Oracle `TIMESTAMP` values carrying a `Z` the column never stored. (#2702)
- Timestamp shown an hour late, and its time lost on an edit, when the value falls in the reader's daylight-saving gap. (#2702)
- Timestamp stored on a day the reader's time zone skipped rendering as raw text with no date picker. (#2702)
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweig
- **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/`
- **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework.
- **Bundled in app** (the 18 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, Markdown export, HTML export, XML export, MQL export, SQL import, JSON import, CSV import, XLSX import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work.
- **Registry-only** (the other 20): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Typesense, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **Registry-only** (the other 21): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, CloudflareR2SQL, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Typesense, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
- **Static libs**: `Libs/` holds pre-built `.a` files and `Libs/ios/` holds the iOS xcframeworks. Both are downloaded by `scripts/download-libs.sh` and are not in git.
- **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`.
Expand Down Expand Up @@ -103,7 +103,7 @@ git add Libs/ios/checksums.sha256 && git commit -m "build: update iOS xcframewor

Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead.

The 38 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 38, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.
The 39 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 39, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.

### Plugin System

Expand Down
13 changes: 12 additions & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ let package = Package(
.library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]),
.library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]),
.library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]),
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"])
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"])
],
targets: [
.target(
Expand Down Expand Up @@ -89,6 +90,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProTrinoCore"
),
.target(
name: "TableProR2SQLCore",
dependencies: [],
path: "Sources/TableProR2SQLCore"
),
.testTarget(
name: "TableProNumberFormattingTests",
dependencies: ["TableProNumberFormatting"],
Expand Down Expand Up @@ -134,6 +140,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 @@ -38,12 +38,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
public static let teradata = DatabaseType(rawValue: "Teradata")
public static let trino = DatabaseType(rawValue: "Trino")
public static let kafka = DatabaseType(rawValue: "Kafka")
public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL")

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

/// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback
Expand Down Expand Up @@ -75,6 +76,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
case .teradata: return "teradata-icon"
case .trino: return "trino-icon"
case .kafka: return "kafka-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,33 @@
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 init(accountId: String, bucket: String, token: String) {
self.accountId = accountId.trimmingCharacters(in: .whitespacesAndNewlines)
self.bucket = bucket.trimmingCharacters(in: .whitespacesAndNewlines)
self.token = token.trimmingCharacters(in: .whitespacesAndNewlines)
}

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 validated() throws -> URL {
if accountId.isEmpty { throw R2SQLError.configuration("Enter the Cloudflare account ID.") }
if bucket.isEmpty { throw R2SQLError.configuration("Enter the R2 bucket name.") }
if token.isEmpty { throw R2SQLError.configuration("Enter a Cloudflare API token.") }
guard let url = queryURL else {
throw R2SQLError.configuration("The account ID or bucket name is not valid in a URL.")
}
return url
}
}
57 changes: 57 additions & 0 deletions Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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
}

private enum CodingKeys: String, CodingKey {
case code, message
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
message = try container.decodeIfPresent(String.self, forKey: .message) ?? ""
}
}

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

public var errorDescription: String? {
switch self {
case .configuration(let detail), .transport(let detail), .unexpectedResult(let detail),
.unsupported(let detail):
return detail
case .notConnected:
return "Not connected to R2 SQL."
case .authentication(let status, let errors):
let reason = Self.joined(errors) ?? "HTTP \(status)"
return "\(reason). The API token needs the R2 SQL, R2 Data Catalog and R2 Storage permissions for this account."
case .api(let status, let errors):
return Self.joined(errors) ?? "R2 SQL returned HTTP \(status) with no error message."
case .malformedResponse(let status, let detail):
return "R2 SQL returned a response TablePro could not read (HTTP \(status)): \(detail)"
case .cancelled:
return "The query was cancelled."
}
}

private static func joined(_ errors: [R2SQLAPIError]) -> String? {
let messages = errors.map(\.message).filter { !$0.isEmpty }
return messages.isEmpty ? nil : messages.joined(separator: "\n")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation

public struct R2SQLColumnDescription: Sendable, Equatable {
public let name: String
public let typeName: String
public let isNullable: Bool
public let comment: String?

public init(name: String, typeName: String, isNullable: Bool, comment: String?) {
self.name = name
self.typeName = typeName
self.isNullable = isNullable
self.comment = comment
}
}

/// The catalog statements R2 SQL answers without scanning data, and how their results are read.
///
/// Every result is read by column name. DESCRIBE's columns are documented (`column_name`, `type`,
/// `required`, `initial_default`, `write_default`, `doc`); SHOW's are not, and the engines R2 SQL
/// resembles disagree on the order (Spark leads with the namespace, DataFusion with the catalog),
/// so the first column is not the name. A result carrying none of the known name columns is
/// reported rather than guessed at.
public enum R2SQLIntrospectionSQL {
public static let showNamespaces = "SHOW NAMESPACES"

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

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

public static func quoteIdentifier(_ identifier: String) -> String {
"\"" + identifier.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}

static let namespaceColumns = ["namespace", "namespace_name", "database_name", "schema_name", "databaseName"]
static let tableColumns = ["table_name", "tableName", "name"]

public static func namespaces(from result: R2SQLResult) throws -> [String] {
try names(in: result, column: namespaceColumns, statement: showNamespaces)
}

public static func tables(from result: R2SQLResult) throws -> [String] {
try names(in: result, column: tableColumns, statement: "SHOW TABLES")
}

public static func columns(from result: R2SQLResult) throws -> [R2SQLColumnDescription] {
let available = Set(result.schema.map(\.name))
guard available.contains("column_name"), available.contains("type") else {
throw R2SQLError.unexpectedResult(unexpectedColumnsMessage("DESCRIBE", result))
}
return result.rows.compactMap { row in
guard case .string(let name)? = row["column_name"], !name.isEmpty else { return nil }
return R2SQLColumnDescription(
name: name,
typeName: text(row["type"]) ?? "",
isNullable: !isTrue(row["required"]),
comment: text(row["doc"]).flatMap { $0.isEmpty ? nil : $0 }
)
}
}

private static func names(in result: R2SQLResult, column candidates: [String], statement: String) throws -> [String] {
let available = result.schema.map(\.name)
let column = candidates.first(where: available.contains) ?? (available.count == 1 ? available[0] : nil)
guard let column else {
throw R2SQLError.unexpectedResult(unexpectedColumnsMessage(statement, result))
}
return result.rows
.compactMap { text($0[column]) }
.filter { !$0.isEmpty }
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}

private static func text(_ value: R2SQLJSONValue?) -> String? {
switch value {
case .string(let text)?:
return text
case .number(let number)?:
return number.description
case .bool(let flag)?:
return flag ? "true" : "false"
default:
return nil
}
}

private static func isTrue(_ value: R2SQLJSONValue?) -> Bool {
switch value {
case .bool(let flag)?:
return flag
case .string(let text)?:
return text.lowercased() == "true"
default:
return false
}
}

private static func unexpectedColumnsMessage(_ statement: String, _ result: R2SQLResult) -> String {
let names = result.schema.map(\.name).joined(separator: ", ")
return "\(statement) returned columns TablePro does not recognize: \(names)."
}
}
Loading
Loading