diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 25cce868b5..768385c549 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be7ee6a4a..38659139a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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. @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index 58215243ed..5d8a935ae3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. @@ -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 [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 [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 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 93c75f76eb..cfca5892de 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -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( @@ -89,6 +90,11 @@ let package = Package( dependencies: [], path: "Sources/TableProTrinoCore" ), + .target( + name: "TableProR2SQLCore", + dependencies: [], + path: "Sources/TableProR2SQLCore" + ), .testTarget( name: "TableProNumberFormattingTests", dependencies: ["TableProNumberFormatting"], @@ -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"], diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 2be96ac6d7..767acc5d6a 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -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 @@ -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" } } diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift new file mode 100644 index 0000000000..62dd79eb18 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift @@ -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 + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift new file mode 100644 index 0000000000..307717665f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift @@ -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") + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift new file mode 100644 index 0000000000..b8cb4bb289 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift @@ -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)." + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift new file mode 100644 index 0000000000..daa43a3f17 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift @@ -0,0 +1,60 @@ +import Foundation + +/// A JSON value as R2 SQL sent it, with numbers kept exact. +/// +/// Numbers decode as `Decimal`, which carries 38 significant digits. Trying `Int64` first, as the +/// usual pattern does, silently truncates a fractional value whose `Double` rounding happens to be +/// integral (`12345678901234567.89` arrives as `12345678901234567`), and `Double` loses every digit +/// past the 17th. +public enum R2SQLJSONValue: Decodable, Sendable, Equatable { + case null + case bool(Bool) + case number(Decimal) + case string(String) + case array([R2SQLJSONValue]) + case object([String: R2SQLJSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Decimal.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([R2SQLJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: R2SQLJSONValue].self)) + } + } + + public var jsonText: String { + switch self { + case .null: + return "null" + case .bool(let value): + return value ? "true" : "false" + case .number(let value): + return value.description + case .string(let value): + return Self.quoted(value) + case .array(let values): + return "[" + values.map(\.jsonText).joined(separator: ",") + "]" + case .object(let fields): + let members = fields.keys.sorted().map { key in + Self.quoted(key) + ":" + (fields[key] ?? .null).jsonText + } + return "{" + members.joined(separator: ",") + "}" + } + } + + private static func quoted(_ value: String) -> String { + guard let data = try? JSONEncoder().encode(value), let text = String(data: data, encoding: .utf8) else { + return "\"\"" + } + return text + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift new file mode 100644 index 0000000000..5106c613df --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift @@ -0,0 +1,22 @@ +import Foundation + +public enum R2SQLRequestBuilder { + public static func queryRequest( + config: R2SQLConnectionConfig, + sql: String, + timeoutInterval: TimeInterval + ) throws -> R2SQLHTTPRequest { + let url = try config.validated() + let body = try JSONEncoder().encode(R2SQLRequestBody(query: sql)) + return R2SQLHTTPRequest( + url: url, + headers: [ + "Authorization": "Bearer \(config.token)", + "Content-Type": "application/json", + "Accept": "application/json" + ], + body: body, + timeoutInterval: timeoutInterval + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLResponseDecoder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLResponseDecoder.swift new file mode 100644 index 0000000000..f6f2500074 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLResponseDecoder.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum R2SQLResponseDecoder { + public static func decode(_ response: R2SQLHTTPResponse) throws -> R2SQLResult { + let envelope: R2SQLEnvelope + do { + envelope = try JSONDecoder().decode(R2SQLEnvelope.self, from: response.body) + } catch { + throw R2SQLError.malformedResponse(status: response.statusCode, detail: snippet(response.body)) + } + + guard envelope.success else { + switch response.statusCode { + case 401, 403: + throw R2SQLError.authentication(status: response.statusCode, errors: envelope.errors) + default: + throw R2SQLError.api(status: response.statusCode, errors: envelope.errors) + } + } + return envelope.result ?? R2SQLResult(schema: [], rows: []) + } + + private static func snippet(_ body: Data) -> String { + let text = String(decoding: body.prefix(300), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "empty body" : text + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift new file mode 100644 index 0000000000..c0eb95a92b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct R2SQLResultSet: Sendable, Equatable { + public let columns: [String] + public let columnTypeNames: [String] + public let rows: [[R2SQLValue]] + + public init(columns: [String], columnTypeNames: [String], rows: [[R2SQLValue]]) { + self.columns = columns + self.columnTypeNames = columnTypeNames + self.rows = rows + } +} + +public enum R2SQLRowMapper { + /// Rows arrive as objects keyed by column name, so the schema supplies the order and a key a + /// row leaves out is a NULL. + public static func map(_ result: R2SQLResult) -> R2SQLResultSet { + let kinds = result.schema.map { R2SQLTypeMapper.valueKind($0.typeName) } + let rows = result.rows.map { row in + zip(result.schema, kinds).map { field, kind in R2SQLTypeMapper.cell(row[field.name], kind: kind) } + } + return R2SQLResultSet( + columns: result.schema.map(\.name), + columnTypeNames: result.schema.map { R2SQLTypeMapper.displayTypeName($0.typeName) }, + rows: rows + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift new file mode 100644 index 0000000000..031b3dc365 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift @@ -0,0 +1,95 @@ +import Foundation + +/// How a column's values are read, decided once from the type R2 SQL names in the result schema. +public enum R2SQLValueKind: Sendable, Equatable { + case integer + case floatingPoint + case decimal + case boolean + case binary + case nested + case text +} + +/// Maps the type names in an R2 SQL result schema (`int64`, `bytes`, `list`, `struct`) to the SQL +/// names the grid classifies (`BIGINT`, `BINARY`, `ARRAY`, `STRUCT`) and to how each value is read. +public enum R2SQLTypeMapper { + private struct Entry { + let displayName: String + let kind: R2SQLValueKind + } + + private static let entries: [String: Entry] = [ + "int8": Entry(displayName: "TINYINT", kind: .integer), + "int16": Entry(displayName: "SMALLINT", kind: .integer), + "int32": Entry(displayName: "INT", kind: .integer), + "int64": Entry(displayName: "BIGINT", kind: .integer), + "uint8": Entry(displayName: "TINYINT UNSIGNED", kind: .integer), + "uint16": Entry(displayName: "SMALLINT UNSIGNED", kind: .integer), + "uint32": Entry(displayName: "INT UNSIGNED", kind: .integer), + "uint64": Entry(displayName: "BIGINT UNSIGNED", kind: .integer), + "float16": Entry(displayName: "REAL", kind: .floatingPoint), + "float32": Entry(displayName: "REAL", kind: .floatingPoint), + "float64": Entry(displayName: "DOUBLE", kind: .floatingPoint), + "decimal": Entry(displayName: "DECIMAL", kind: .decimal), + "decimal128": Entry(displayName: "DECIMAL", kind: .decimal), + "decimal256": Entry(displayName: "DECIMAL", kind: .decimal), + "bool": Entry(displayName: "BOOLEAN", kind: .boolean), + "boolean": Entry(displayName: "BOOLEAN", kind: .boolean), + "utf8": Entry(displayName: "TEXT", kind: .text), + "largeutf8": Entry(displayName: "TEXT", kind: .text), + "utf8view": Entry(displayName: "TEXT", kind: .text), + "string": Entry(displayName: "TEXT", kind: .text), + "bytes": Entry(displayName: "BINARY", kind: .binary), + "binary": Entry(displayName: "BINARY", kind: .binary), + "largebinary": Entry(displayName: "BINARY", kind: .binary), + "binaryview": Entry(displayName: "BINARY", kind: .binary), + "fixedsizebinary": Entry(displayName: "BINARY", kind: .binary), + "date": Entry(displayName: "DATE", kind: .text), + "date32": Entry(displayName: "DATE", kind: .text), + "date64": Entry(displayName: "DATE", kind: .text), + "time": Entry(displayName: "TIME", kind: .text), + "time32": Entry(displayName: "TIME", kind: .text), + "time64": Entry(displayName: "TIME", kind: .text), + "timestamp": Entry(displayName: "TIMESTAMP", kind: .text), + "list": Entry(displayName: "ARRAY", kind: .nested), + "largelist": Entry(displayName: "ARRAY", kind: .nested), + "fixedsizelist": Entry(displayName: "ARRAY", kind: .nested), + "struct": Entry(displayName: "STRUCT", kind: .nested), + "map": Entry(displayName: "MAP", kind: .nested) + ] + + public static func displayTypeName(_ typeName: String) -> String { + entry(typeName)?.displayName ?? typeName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + public static func valueKind(_ typeName: String) -> R2SQLValueKind { + entry(typeName)?.kind ?? .text + } + + public static func cell(_ value: R2SQLJSONValue?, kind: R2SQLValueKind) -> R2SQLValue { + guard let value else { return .null } + switch value { + case .null: + return .null + case .bool(let flag): + return .text(flag ? "true" : "false") + case .number(let number): + return .text(text(number, kind: kind)) + case .string(let string): + guard kind == .binary, let data = Data(base64Encoded: string) else { return .text(string) } + return .bytes([UInt8](data)) + case .array, .object: + return .text(value.jsonText) + } + } + + private static func text(_ number: Decimal, kind: R2SQLValueKind) -> String { + guard kind == .floatingPoint else { return number.description } + return Double(truncating: number as NSDecimalNumber).description + } + + private static func entry(_ typeName: String) -> Entry? { + entries[typeName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()] + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLURLSessionTransport.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLURLSessionTransport.swift new file mode 100644 index 0000000000..175fb2755e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLURLSessionTransport.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Sends R2 SQL requests with `URLSession.data(for:delegate:)`, so cancelling the Swift task that +/// awaits a request cancels its URL task, and keeps every task in flight so `cancelAll` stops each +/// one. A single stored task handle cancelled whichever request started last, which on a live +/// connection is as often a sidebar metadata read as the query the user stopped. +public final class URLSessionR2SQLTransport: R2SQLTransport, @unchecked Sendable { + private let session: URLSession + private let lock = NSLock() + private var inFlight: [ObjectIdentifier: URLSessionTask] = [:] + + public init(configuration: URLSessionConfiguration = .ephemeral, resourceTimeout: TimeInterval) { + configuration.timeoutIntervalForResource = resourceTimeout + session = URLSession(configuration: configuration) + } + + deinit { + session.invalidateAndCancel() + } + + public func cancelAll() { + let tasks = lock.withLock { Array(inFlight.values) } + tasks.forEach { $0.cancel() } + } + + public func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = request.timeoutInterval + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let tracker = TaskTracker(transport: self) + defer { tracker.finish() } + do { + let (data, response) = try await session.data(for: urlRequest, delegate: tracker) + guard let httpResponse = response as? HTTPURLResponse else { + throw R2SQLError.transport("R2 SQL answered with something other than HTTP.") + } + return R2SQLHTTPResponse(statusCode: httpResponse.statusCode, body: data) + } catch let error as URLError where error.code == .cancelled { + throw R2SQLError.cancelled + } catch let error as URLError { + throw R2SQLError.transport(error.localizedDescription) + } + } + + var inFlightCount: Int { + lock.withLock { inFlight.count } + } + + fileprivate func register(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = task } + } + + fileprivate func unregister(_ task: URLSessionTask) { + lock.withLock { inFlight[ObjectIdentifier(task)] = nil } + } +} + +private final class TaskTracker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private weak var transport: URLSessionR2SQLTransport? + private let lock = NSLock() + private var task: URLSessionTask? + + init(transport: URLSessionR2SQLTransport) { + self.transport = transport + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + lock.withLock { self.task = task } + transport?.register(task) + } + + func finish() { + guard let task = lock.withLock({ task }) else { return } + transport?.unregister(task) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLValue.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLValue.swift new file mode 100644 index 0000000000..a1b8763576 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLValue.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum R2SQLValue: Sendable, Equatable { + case null + case text(String) + case bytes([UInt8]) +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift new file mode 100644 index 0000000000..e73f76b97e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift @@ -0,0 +1,114 @@ +import Foundation + +public struct R2SQLRequestBody: Encodable, Sendable, Equatable { + public let query: String + + public init(query: String) { + self.query = query + } +} + +/// The envelope every R2 SQL response arrives in. +/// +/// Decoded strictly: a body that does not match is a `malformedResponse`, never an empty result. +/// Decoding every field with `try?` is what let a changed wire shape come back as a successful +/// query with no columns. +public struct R2SQLEnvelope: Decodable, Sendable, Equatable { + public let success: Bool + public let errors: [R2SQLAPIError] + public let result: R2SQLResult? + + private enum CodingKeys: String, CodingKey { + case success, errors, result + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + success = try container.decode(Bool.self, forKey: .success) + errors = try container.decodeIfPresent([R2SQLAPIError].self, forKey: .errors) ?? [] + result = try container.decodeIfPresent(R2SQLResult.self, forKey: .result) + } +} + +public struct R2SQLResult: Decodable, Sendable, Equatable { + public let schema: [R2SQLField] + public let rows: [[String: R2SQLJSONValue]] + + public init(schema: [R2SQLField], rows: [[String: R2SQLJSONValue]]) { + self.schema = schema + self.rows = rows + } + + private enum CodingKeys: String, CodingKey { + case schema, rows + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schema = try container.decodeIfPresent([R2SQLField].self, forKey: .schema) ?? [] + rows = try container.decodeIfPresent([[String: R2SQLJSONValue]].self, forKey: .rows) ?? [] + } +} + +/// One output column, as `{"name": ..., "descriptor": {"type": {"name": ...}, "nullable": ...}}`. +public struct R2SQLField: Decodable, Sendable, Equatable { + public let name: String + public let typeName: String + public let isNullable: Bool + + public init(name: String, typeName: String, isNullable: Bool = true) { + self.name = name + self.typeName = typeName + self.isNullable = isNullable + } + + private enum CodingKeys: String, CodingKey { + case name, descriptor + } + + private enum DescriptorKeys: String, CodingKey { + case type, nullable + } + + private enum TypeKeys: String, CodingKey { + case name + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + let descriptor = try container.nestedContainer(keyedBy: DescriptorKeys.self, forKey: .descriptor) + let type = try descriptor.nestedContainer(keyedBy: TypeKeys.self, forKey: .type) + typeName = try type.decode(String.self, forKey: .name) + isNullable = try descriptor.decodeIfPresent(Bool.self, forKey: .nullable) ?? true + } +} + +public struct R2SQLHTTPRequest: Sendable, Equatable { + public let url: URL + public let headers: [String: String] + public let body: Data + public let timeoutInterval: TimeInterval + + public init(url: URL, headers: [String: String], body: Data, timeoutInterval: TimeInterval) { + self.url = url + self.headers = headers + self.body = body + self.timeoutInterval = timeoutInterval + } +} + +public struct R2SQLHTTPResponse: Sendable, Equatable { + public let statusCode: Int + public let body: Data + + public init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body + } +} + +public protocol R2SQLTransport: Sendable { + func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse + func cancelAll() +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index a8123f7cf5..c01a24e917 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -58,7 +58,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 26) + #expect(DatabaseType.allKnownTypes.count == 27) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.tidb)) #expect(DatabaseType.allKnownTypes.contains(.databend)) @@ -71,6 +71,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.trino)) #expect(DatabaseType.allKnownTypes.contains(.dameng)) #expect(DatabaseType.allKnownTypes.contains(.kafka)) + #expect(DatabaseType.allKnownTypes.contains(.cloudflareR2SQL)) } /// The list has no duplicates, which a count alone would not catch: adding a type twice @@ -81,6 +82,13 @@ struct DatabaseTypeTests { #expect(Set(raw).count == raw.count) } + @Test("Cloudflare R2 SQL resolves its icon and plugin type id") + func cloudflareR2SQLIdentity() { + #expect(DatabaseType.cloudflareR2SQL.rawValue == "Cloudflare R2 SQL") + #expect(DatabaseType.cloudflareR2SQL.iconName == "cloudflare-r2-sql-icon") + #expect(DatabaseType.cloudflareR2SQL.pluginTypeId == "Cloudflare R2 SQL") + } + @Test("Hashable conformance") func hashableConformance() { var set: Set = [.mysql, .postgresql, .mysql] diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift new file mode 100644 index 0000000000..c18cf83967 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL envelope decoding") +struct R2SQLEnvelopeDecodingTests { + private func response(_ json: String, status: Int = 200) -> R2SQLHTTPResponse { + R2SQLHTTPResponse(statusCode: status, body: Data(json.utf8)) + } + + @Test("The live envelope carries each column's type under descriptor.type.name") + func descriptorEnvelope() throws { + let json = """ + {"result":{"request_id":"dqe-prod-01", + "schema":[{"name":"category","descriptor":{"type":{"name":"utf8"},"nullable":true}}, + {"name":"cnt","descriptor":{"type":{"name":"int64"},"nullable":false}}], + "rows":[{"category":"Electronics","cnt":12345}], + "metrics":{"r2_requests_count":5,"files_scanned":29,"bytes_scanned":12345678,"cache_hits":0}}, + "success":true,"errors":[]} + """ + let result = try R2SQLResponseDecoder.decode(response(json)) + + #expect(result.schema == [ + R2SQLField(name: "category", typeName: "utf8", isNullable: true), + R2SQLField(name: "cnt", typeName: "int64", isNullable: false) + ]) + #expect(result.rows.first?["cnt"] == .number(12345)) + } + + @Test("A nested list of structs decodes its outer type and keeps the value tree") + func nestedDescriptor() throws { + let json = """ + {"success":true,"errors":[],"messages":[],"result":{"request_id":"dqe-prod-test", + "schema":[{"name":"approx_top_k(value, Int64(3))","descriptor":{"type":{"name":"list","item":{"type":{"name":"struct", + "fields":[{"type":{"name":"int64"},"nullable":true,"name":"value"},{"type":{"name":"uint64"},"nullable":false,"name":"count"}]}, + "nullable":true}},"nullable":true}}], + "rows":[{"approx_top_k(value, Int64(3))":[{"value":0,"count":961},{"value":2,"count":null}]}], + "metrics":{"r2_requests_count":6,"files_scanned":3,"bytes_scanned":62878}}} + """ + let result = try R2SQLResponseDecoder.decode(response(json)) + + #expect(result.schema.first?.typeName == "list") + let mapped = R2SQLRowMapper.map(result) + #expect(mapped.columnTypeNames == ["ARRAY"]) + #expect(mapped.rows == [[.text(#"[{"count":961,"value":0},{"count":null,"value":2}]"#)]]) + } + + @Test("A schema without a descriptor is a malformed response, not an empty success") + func legacyShapeIsRejected() { + let json = """ + {"result":{"schema":[{"name":"id","type":"Int64"}],"rows":[{"id":1}]},"success":true,"errors":[]} + """ + #expect(throws: R2SQLError.self) { try R2SQLResponseDecoder.decode(response(json)) } + } + + @Test("A body with no success flag is malformed") + func missingSuccessIsMalformed() { + #expect(throws: R2SQLError.malformedResponse(status: 200, detail: #"{"result":null}"#)) { + try R2SQLResponseDecoder.decode(response(#"{"result":null}"#)) + } + } + + @Test("A body that is not JSON is malformed and quotes the body") + func nonJSONIsMalformed() { + #expect(throws: R2SQLError.malformedResponse(status: 502, detail: "Bad gateway")) { + try R2SQLResponseDecoder.decode(response("Bad gateway", status: 502)) + } + } + + @Test("A success with a null result is an empty result") + func nullResultIsEmpty() throws { + let result = try R2SQLResponseDecoder.decode(response(#"{"result":null,"success":true,"errors":[]}"#)) + #expect(result.schema.isEmpty && result.rows.isEmpty) + } + + @Test("A failure under 401 or 403 is an authentication error", arguments: [401, 403]) + func authenticationFailure(status: Int) { + let json = #"{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}"# + #expect(throws: R2SQLError.authentication( + status: status, + errors: [R2SQLAPIError(code: 10_000, message: "Authentication error")] + )) { + try R2SQLResponseDecoder.decode(response(json, status: status)) + } + } + + @Test("Any other failure carries the server's errors, including under HTTP 200") + func queryFailure() { + let json = #"{"result":null,"success":false,"errors":[{"code":40003,"message":"syntax error at LIMIT"}]}"# + let expected = R2SQLError.api(status: 200, errors: [R2SQLAPIError(code: 40_003, message: "syntax error at LIMIT")]) + + #expect(throws: expected) { try R2SQLResponseDecoder.decode(response(json)) } + #expect(expected.errorDescription == "syntax error at LIMIT") + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift new file mode 100644 index 0000000000..2efc02b64f --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL catalog statements") +struct R2SQLIntrospectionSQLTests { + private func result(_ columns: [String], _ rows: [[String: R2SQLJSONValue]]) -> R2SQLResult { + R2SQLResult(schema: columns.map { R2SQLField(name: $0, typeName: "utf8") }, rows: rows) + } + + @Test("Statements quote the namespace and table as identifiers") + func statements() { + #expect(R2SQLIntrospectionSQL.showTables(namespace: "logs") == #"SHOW TABLES IN "logs""#) + #expect(R2SQLIntrospectionSQL.describe(namespace: "lo\"gs", table: "events") + == #"DESCRIBE "lo""gs"."events""#) + } + + @Test("A Spark-shaped SHOW TABLES reads the tableName column, not the namespace") + func sparkShape() throws { + let listing = result(["namespace", "tableName", "isTemporary"], [ + ["namespace": .string("logs"), "tableName": .string("zeta"), "isTemporary": .bool(false)], + ["namespace": .string("logs"), "tableName": .string("alpha"), "isTemporary": .bool(false)] + ]) + #expect(try R2SQLIntrospectionSQL.tables(from: listing) == ["alpha", "zeta"]) + } + + @Test("A DataFusion-shaped SHOW TABLES reads table_name, not the catalog") + func dataFusionShape() throws { + let listing = result(["table_catalog", "table_schema", "table_name", "table_type"], [ + ["table_catalog": .string("r2"), "table_schema": .string("logs"), + "table_name": .string("events"), "table_type": .string("BASE TABLE")] + ]) + #expect(try R2SQLIntrospectionSQL.tables(from: listing) == ["events"]) + } + + @Test("A single-column listing reads that column") + func singleColumn() throws { + let listing = result(["db"], [["db": .string("logs")], ["db": .string("default")]]) + #expect(try R2SQLIntrospectionSQL.namespaces(from: listing) == ["default", "logs"]) + } + + @Test("A listing with no recognizable name column is reported, not guessed") + func unknownShapeThrows() { + let listing = result(["a", "b"], [["a": .string("x"), "b": .string("y")]]) + #expect(throws: R2SQLError.unexpectedResult( + "SHOW TABLES returned columns TablePro does not recognize: a, b." + )) { + try R2SQLIntrospectionSQL.tables(from: listing) + } + } + + @Test("DESCRIBE reads columns by name: nullable is the inverse of required, and doc is the comment") + func describeByName() throws { + let description = result(["column_name", "type", "required", "initial_default", "write_default", "doc"], [ + ["column_name": .string("sale_id"), "type": .string("BIGINT"), "required": .bool(false), + "doc": .string("Unique identifier")], + ["column_name": .string("region"), "type": .string("TEXT"), "required": .string("true"), + "doc": .string("")] + ]) + #expect(try R2SQLIntrospectionSQL.columns(from: description) == [ + R2SQLColumnDescription(name: "sale_id", typeName: "BIGINT", isNullable: true, comment: "Unique identifier"), + R2SQLColumnDescription(name: "region", typeName: "TEXT", isNullable: false, comment: nil) + ]) + } + + @Test("A DESCRIBE result without column_name and type is reported") + func describeWithoutColumnsThrows() { + #expect(throws: R2SQLError.self) { + try R2SQLIntrospectionSQL.columns(from: result(["name", "kind"], [])) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift new file mode 100644 index 0000000000..5c201e56b0 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL JSON values") +struct R2SQLJSONValueTests { + private func decode(_ json: String) throws -> R2SQLJSONValue { + try JSONDecoder().decode(R2SQLJSONValue.self, from: Data(json.utf8)) + } + + @Test("Numbers keep every digit", arguments: [ + "12345678901234567.89", + "99999999999999999999999999999999999999", + "18446744073709551615", + "9007199254740993", + "-9223372036854775808", + "0.1" + ]) + func exactNumbers(literal: String) throws { + #expect(try decode(literal).jsonText == literal) + } + + @Test("A number no Decimal can hold is a decoding error, not NULL") + func outOfRangeNumberThrows() { + #expect(throws: DecodingError.self) { try decode("1e400") } + } + + @Test("Booleans stay booleans and never read as numbers") + func booleans() throws { + #expect(try decode("true") == .bool(true)) + #expect(try decode("1") == .number(1)) + } + + @Test("Nested values render as JSON without re-encoding their numbers") + func nestedText() throws { + let value = try decode(#"{"y":0.1,"x":[1,"a\"b",null,false]}"#) + #expect(value.jsonText == #"{"x":[1,"a\"b",null,false],"y":0.1}"#) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift new file mode 100644 index 0000000000..485d923554 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL request") +struct R2SQLRequestBuilderTests { + private let config = R2SQLConnectionConfig(accountId: " acc123 ", bucket: "my-bucket", token: " tok \n") + + @Test("The request posts the query alone to the account's bucket endpoint with a bearer token") + func request() throws { + let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1", timeoutInterval: 330) + let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: String]) + + #expect(request.url.absoluteString + == "https://api.sql.cloudflarestorage.com/api/v1/accounts/acc123/r2-sql/query/my-bucket") + #expect(body == ["query": "SELECT 1"]) + #expect(request.headers["Authorization"] == "Bearer tok") + #expect(request.headers["Content-Type"] == "application/json") + #expect(request.timeoutInterval == 330) + } + + @Test("A missing account, bucket or token fails before any request", arguments: [ + R2SQLConnectionConfig(accountId: "", bucket: "b", token: "t"), + R2SQLConnectionConfig(accountId: "a", bucket: " ", token: "t"), + R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "") + ]) + func validation(config: R2SQLConnectionConfig) { + #expect(throws: R2SQLError.self) { + try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1", timeoutInterval: 60) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift new file mode 100644 index 0000000000..d0fa9cb908 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL row mapping") +struct R2SQLRowMapperTests { + @Test("Rows follow the schema's order and a missing key is NULL") + func schemaOrder() { + let result = R2SQLResult( + schema: [R2SQLField(name: "b", typeName: "utf8"), R2SQLField(name: "a", typeName: "int64")], + rows: [["a": .number(1), "b": .string("x")], ["a": .number(2)]] + ) + let mapped = R2SQLRowMapper.map(result) + + #expect(mapped.columns == ["b", "a"]) + #expect(mapped.columnTypeNames == ["TEXT", "BIGINT"]) + #expect(mapped.rows == [[.text("x"), .text("1")], [.null, .text("2")]]) + } + + @Test("Wide integers and decimals keep every digit") + func exactNumbers() throws { + let value = try JSONDecoder().decode(R2SQLJSONValue.self, from: Data("12345678901234567.89".utf8)) + #expect(R2SQLTypeMapper.cell(value, kind: .decimal) == .text("12345678901234567.89")) + #expect(R2SQLTypeMapper.cell(.number(Decimal(string: "18446744073709551615")!), kind: .integer) + == .text("18446744073709551615")) + } + + @Test("A floating-point column keeps its fractional form") + func floatingPoint() { + #expect(R2SQLTypeMapper.cell(.number(1), kind: .floatingPoint) == .text("1.0")) + #expect(R2SQLTypeMapper.cell(.number(Decimal(string: "0.1")!), kind: .floatingPoint) == .text("0.1")) + } + + @Test("A bytes column decodes base64, and text that is not base64 stays text") + func binary() { + #expect(R2SQLTypeMapper.cell(.string("AAEC/w=="), kind: .binary) == .bytes([0, 1, 2, 255])) + #expect(R2SQLTypeMapper.cell(.string("not base64!"), kind: .binary) == .text("not base64!")) + #expect(R2SQLTypeMapper.cell(.string("AAEC/w=="), kind: .text) == .text("AAEC/w==")) + } + + @Test("JSON null is NULL and booleans read as true or false") + func nullAndBoolean() { + #expect(R2SQLTypeMapper.cell(.null, kind: .text) == .null) + #expect(R2SQLTypeMapper.cell(nil, kind: .integer) == .null) + #expect(R2SQLTypeMapper.cell(.bool(false), kind: .boolean) == .text("false")) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift new file mode 100644 index 0000000000..a1993e7d3a --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import TableProR2SQLCore + +@Suite("R2 SQL type names") +struct R2SQLTypeMapperTests { + @Test("Result schema type names map to SQL names the grid classifies", arguments: [ + ("int64", "BIGINT", R2SQLValueKind.integer), + ("uint64", "BIGINT UNSIGNED", .integer), + ("int32", "INT", .integer), + ("float64", "DOUBLE", .floatingPoint), + ("decimal128", "DECIMAL", .decimal), + ("bool", "BOOLEAN", .boolean), + ("utf8", "TEXT", .text), + ("bytes", "BINARY", .binary), + ("date32", "DATE", .text), + ("timestamp", "TIMESTAMP", .text), + ("list", "ARRAY", .nested), + ("struct", "STRUCT", .nested), + ("map", "MAP", .nested), + ("Int64", "BIGINT", .integer) + ]) + func knownTypes(raw: String, display: String, kind: R2SQLValueKind) { + #expect(R2SQLTypeMapper.displayTypeName(raw) == display) + #expect(R2SQLTypeMapper.valueKind(raw) == kind) + } + + @Test("An unknown type name passes through uppercased and reads as text") + func unknownType() { + #expect(R2SQLTypeMapper.displayTypeName("interval") == "INTERVAL") + #expect(R2SQLTypeMapper.valueKind("interval") == .text) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift new file mode 100644 index 0000000000..85147a286e --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import TableProR2SQLCore + +/// A protocol that answers `/ok` at once and leaves every other request hanging until cancelled, +/// so a test can hold several requests in flight and watch what a cancel does to each. +private final class StubProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var lastTimeout: TimeInterval? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lastTimeout = request.timeoutInterval + guard request.url?.path == "/ok", let url = request.url, + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + else { return } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(#"{"success":true}"#.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@Suite("R2 SQL URLSession transport", .serialized) +struct R2SQLURLSessionTransportTests { + private func transport() -> URLSessionR2SQLTransport { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubProtocol.self] + return URLSessionR2SQLTransport(configuration: configuration, resourceTimeout: 3_600) + } + + private func request(_ path: String, timeout: TimeInterval = 60) throws -> R2SQLHTTPRequest { + R2SQLHTTPRequest( + url: try #require(URL(string: "https://r2.test\(path)")), + headers: [:], + body: Data(), + timeoutInterval: timeout + ) + } + + private func waitUntil(_ condition: () -> Bool) async { + for _ in 0 ..< 200 where !condition() { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @Test("A request carries its own timeout and returns the status and body") + func roundTrip() async throws { + let response = try await transport().send(try request("/ok", timeout: 330)) + + #expect(response.statusCode == 200) + #expect(String(decoding: response.body, as: UTF8.self) == #"{"success":true}"#) + #expect(StubProtocol.lastTimeout == 330) + } + + @Test("Cancelling everything stops every request in flight, not just the latest") + func cancelAllStopsEveryRequest() async throws { + let transport = transport() + let firstRequest = try request("/hang/1") + let secondRequest = try request("/hang/2") + let first = Task { try await transport.send(firstRequest) } + let second = Task { try await transport.send(secondRequest) } + await waitUntil { transport.inFlightCount == 2 } + + transport.cancelAll() + + for task in [first, second] { + await #expect(throws: R2SQLError.cancelled) { try await task.value } + } + } + + @Test("Cancelling the awaiting task cancels its request") + func taskCancellation() async throws { + let transport = transport() + let hanging = try request("/hang") + let task = Task { try await transport.send(hanging) } + await waitUntil { transport.inFlightCount == 1 } + + task.cancel() + + await #expect(throws: R2SQLError.cancelled) { try await task.value } + #expect(transport.inFlightCount == 0) + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift new file mode 100644 index 0000000000..4875d44177 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift @@ -0,0 +1,101 @@ +// +// CloudflareR2SQLMetadata.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The values the plugin declares and the app's pre-install catalog must repeat. +/// +/// One file, compiled into the plugin and into the test target, so a parity test can hold the +/// app's curated snapshot to exactly these values instead of two hand-typed copies drifting apart. +enum CloudflareR2SQLMetadata { + static let displayName = "Cloudflare R2 SQL" + static let iconName = "cloudflare-r2-sql-icon" + static let brandColorHex = "#F6821F" + static let defaultSchemaName = "" + static let schemaEntityName = "Namespace" + static let containerEntityName = "Bucket" + static let maximumRows = 10_000 + static let accountIdFieldId = "r2AccountId" + static let bucketFieldId = "r2Bucket" + + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "explainJson", label: "Explain (JSON)", sqlPrefix: "EXPLAIN FORMAT JSON") + ] + + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .comment] + + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "DECIMAL"], + "String": ["TEXT"], + "Date": ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ"], + "Binary": ["BINARY"], + "Boolean": ["BOOLEAN"], + "Nested": ["ARRAY", "STRUCT", "MAP"] + ] + + static let statementCompletions: [CompletionEntry] = [ + CompletionEntry(label: "SELECT", insertText: "SELECT * FROM namespace.table LIMIT 100"), + CompletionEntry(label: "SHOW NAMESPACES", insertText: "SHOW NAMESPACES"), + CompletionEntry(label: "SHOW TABLES", insertText: "SHOW TABLES IN namespace"), + CompletionEntry(label: "DESCRIBE", insertText: "DESCRIBE namespace.table"), + CompletionEntry(label: "EXPLAIN", insertText: "EXPLAIN SELECT * FROM namespace.table LIMIT 10") + ] + + static let dialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "QUALIFY", + "ORDER", "ASC", "DESC", "NULLS", "FIRST", "LAST", "LIMIT", "AS", "ON", "USING", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", + "AND", "OR", "NOT", "IN", "EXISTS", "LIKE", "ILIKE", "ESCAPE", "BETWEEN", "IS", "NULL", + "CASE", "WHEN", "THEN", "ELSE", "END", + "WITH", "UNION", "INTERSECT", "EXCEPT", "ALL", + "OVER", "PARTITION", "ROWS", "RANGE", "PRECEDING", "FOLLOWING", "CURRENT", "ROW", "UNBOUNDED", + "SHOW", "NAMESPACES", "DATABASES", "SCHEMAS", "TABLES", "DESCRIBE", "EXPLAIN", "FORMAT", "JSON", + "TRUE", "FALSE", "CAST" + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "MEDIAN", + "APPROX_DISTINCT", "APPROX_PERCENTILE_CONT", "APPROX_TOP_K", "PERCENTILE_CONT", + "ROW_NUMBER", "RANK", "DENSE_RANK", "PERCENT_RANK", "CUME_DIST", "NTILE", + "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE", + "ABS", "CEIL", "FLOOR", "ROUND", "POWER", "SQRT", "LN", "LOG", "EXP", + "LENGTH", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", "SUBSTR", "SUBSTRING", + "REPLACE", "CONCAT", "SPLIT_PART", "STARTS_WITH", "ENDS_WITH", "REGEXP_LIKE", + "DATE_TRUNC", "DATE_PART", "EXTRACT", "TO_TIMESTAMP", "NOW", + "COALESCE", "NULLIF", "GET_FIELD", "ARRAY_LENGTH", "MAP_KEYS", "MAP_VALUES", "MAP_EXTRACT" + ], + dataTypes: [ + "BOOLEAN", "TINYINT", "SMALLINT", "INT", "BIGINT", "REAL", "DOUBLE", "DECIMAL", + "TEXT", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "BINARY", "ARRAY", "STRUCT", "MAP" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + static var connectionFields: [ConnectionField] { + [ + ConnectionField( + id: accountIdFieldId, + label: String(localized: "Account ID"), + placeholder: "Cloudflare Account ID", + required: true, + section: .authentication + ), + ConnectionField( + id: bucketFieldId, + label: String(localized: "Bucket"), + placeholder: "my-bucket", + required: true, + section: .authentication + ) + ] + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift new file mode 100644 index 0000000000..69af034e65 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift @@ -0,0 +1,57 @@ +// +// CloudflareR2SQLPlugin.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +final class CloudflareR2SQLPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Cloudflare R2 SQL Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Read-only Cloudflare R2 SQL driver for Apache Iceberg tables in R2" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Cloudflare R2 SQL" + static let databaseDisplayName = CloudflareR2SQLMetadata.displayName + static let iconName = CloudflareR2SQLMetadata.iconName + static let defaultPort = 0 + + static let connectionMode: ConnectionMode = .apiOnly + static let supportsSSH = false + static let supportsSSL = false + static let isDownloadable = true + static let supportsImport = false + static let supportsExport = true + static let supportsSchemaEditing = false + static let supportsForeignKeys = false + static let supportsDropDatabase = false + static let supportsCascadeDrop = false + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let supportsDatabaseSwitching = false + static let supportsSchemaSwitching = true + static let supportsHealthMonitor = false + static let supportsQueryProgress = false + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let defaultSchemaName = CloudflareR2SQLMetadata.defaultSchemaName + static let schemaEntityName = CloudflareR2SQLMetadata.schemaEntityName + static let containerEntityName = CloudflareR2SQLMetadata.containerEntityName + static let brandColorHex = CloudflareR2SQLMetadata.brandColorHex + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + static let explainVariants = CloudflareR2SQLMetadata.explainVariants + static let structureColumnFields = CloudflareR2SQLMetadata.structureColumnFields + static let columnTypesByCategory = CloudflareR2SQLMetadata.columnTypesByCategory + static let statementCompletions = CloudflareR2SQLMetadata.statementCompletions + static let sqlDialect: SQLDialectDescriptor? = CloudflareR2SQLMetadata.dialect + static let additionalConnectionFields = CloudflareR2SQLMetadata.connectionFields + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + CloudflareR2SQLPluginDriver(config: config) + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift new file mode 100644 index 0000000000..894433f25e --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift @@ -0,0 +1,44 @@ +// +// CloudflareR2SQLPluginDriver+Query.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProR2SQLCore + +extension CloudflareR2SQLPluginDriver { + func execute(query: String) async throws -> PluginQueryResult { + let started = Date() + let mapped = R2SQLRowMapper.map(try await run(sql: query)) + return PluginQueryResult( + columns: mapped.columns, + columnTypeNames: mapped.columnTypeNames, + rows: mapped.rows.map { $0.map(Self.cellValue) }, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(started), + statusMessage: Self.ceilingMessage(rowCount: mapped.rows.count) + ) + } + + /// A result as long as the engine's ceiling cannot say whether more rows exist, so it says + /// where it stopped instead of passing for the whole answer. + static func ceilingMessage(rowCount: Int) -> String? { + guard rowCount >= CloudflareR2SQLMetadata.maximumRows else { return nil } + return String( + format: String(localized: "Stopped at %lld rows, the most R2 SQL returns from one query."), + CloudflareR2SQLMetadata.maximumRows + ) + } + + private static func cellValue(_ value: R2SQLValue) -> PluginCellValue { + switch value { + case .null: + return .null + case .text(let text): + return .text(text) + case .bytes(let bytes): + return .bytes(Data(bytes)) + } + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift new file mode 100644 index 0000000000..9e4fc6da92 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift @@ -0,0 +1,80 @@ +// +// CloudflareR2SQLPluginDriver+Schema.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProR2SQLCore + +extension CloudflareR2SQLPluginDriver { + func fetchDatabases() async throws -> [String] { + [connectionConfig.bucket] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchSchemas() async throws -> [String] { + try R2SQLIntrospectionSQL.namespaces(from: try await run(sql: R2SQLIntrospectionSQL.showNamespaces)) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let namespace = schema.flatMap({ $0.isEmpty ? nil : $0 }) ?? currentSchema else { return [] } + let listing = try await run(sql: R2SQLIntrospectionSQL.showTables(namespace: namespace)) + return try R2SQLIntrospectionSQL.tables(from: listing).map { name in + PluginTableInfo(name: name, type: "TABLE", schema: namespace, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + try await describe(table: table, schema: schema).columns.map { column in + PluginColumnInfo( + name: column.name, + dataType: column.typeName, + isNullable: column.isNullable, + defaultValue: nil, + comment: column.comment + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let described = try await describe(table: table, schema: schema) + let body = described.columns + .map { column in + let quoted = R2SQLIntrospectionSQL.quoteIdentifier(column.name) + return " \(quoted) \(column.typeName)\(column.isNullable ? "" : " NOT NULL")" + } + .joined(separator: ",\n") + let name = R2SQLIntrospectionSQL.quoteIdentifier(described.namespace) + + "." + R2SQLIntrospectionSQL.quoteIdentifier(table) + return "CREATE TABLE \(name) (\n\(body)\n)" + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw R2SQLError.unsupported("R2 SQL has no views.") + } + + private func describe( + table: String, + schema: String? + ) async throws -> (namespace: String, columns: [R2SQLColumnDescription]) { + let namespace = try namespace(for: schema) + let result = try await run(sql: R2SQLIntrospectionSQL.describe(namespace: namespace, table: table)) + return (namespace, try R2SQLIntrospectionSQL.columns(from: result)) + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift new file mode 100644 index 0000000000..7609541a2a --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift @@ -0,0 +1,97 @@ +// +// CloudflareR2SQLPluginDriver.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit +import TableProR2SQLCore + +final class CloudflareR2SQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareR2SQL") + + private let lock = NSLock() + private var namespace: String? + private var isConnected = false + private let queryTimeout = HttpQueryTimeoutBox() + + let transport: R2SQLTransport + let connectionConfig: R2SQLConnectionConfig + + init( + config: DriverConnectionConfig, + transport: R2SQLTransport = URLSessionR2SQLTransport(resourceTimeout: HttpQueryTimeout.sessionResourceTimeout) + ) { + self.connectionConfig = R2SQLConnectionConfig( + accountId: config.additionalFields[CloudflareR2SQLMetadata.accountIdFieldId] ?? "", + bucket: config.additionalFields[CloudflareR2SQLMetadata.bucketFieldId] ?? "", + token: config.password + ) + self.transport = transport + } + + var capabilities: PluginCapabilities { [.cancelQuery] } + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { false } + var serverVersion: String? { nil } + + var currentSchema: String? { + lock.withLock { namespace } + } + + func switchSchema(to schema: String) async throws { + lock.withLock { namespace = schema.isEmpty ? nil : schema } + } + + func namespace(for schema: String?) throws -> String { + if let schema, !schema.isEmpty { return schema } + guard let current = currentSchema else { + throw R2SQLError.configuration("Choose a namespace first.") + } + return current + } + + // MARK: - Lifecycle + + func connect() async throws { + _ = try connectionConfig.validated() + lock.withLock { isConnected = true } + do { + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces) + } catch { + lock.withLock { isConnected = false } + throw error + } + } + + func disconnect() { + lock.withLock { isConnected = false } + transport.cancelAll() + } + + func ping() async throws { + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces) + } + + func cancelQuery() throws { + transport.cancelAll() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + + // MARK: - Transport + + func run(sql: String) async throws -> R2SQLResult { + guard lock.withLock({ isConnected }) else { throw R2SQLError.notConnected } + let request = try R2SQLRequestBuilder.queryRequest( + config: connectionConfig, + sql: sql, + timeoutInterval: queryTimeout.requestTimeoutInterval + ) + let response = try await transport.send(request) + return try R2SQLResponseDecoder.decode(response) + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist new file mode 100644 index 0000000000..5d91dcfa09 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + 25 + TableProProvidesDatabaseTypeIds + + Cloudflare R2 SQL + + + diff --git a/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json b/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json new file mode 100644 index 0000000000..c26dbcdf85 --- /dev/null +++ b/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cloudflare-r2-sql.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg b/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg new file mode 100644 index 0000000000..37cdf584ab --- /dev/null +++ b/TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 2fc9726d79..35eeebd041 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -21,31 +21,45 @@ final class PaginationCoordinator { // MARK: - Pagination func goToNextPage() { - guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } + guard canSeek, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } let loadedRowCount = parent.tabSessionRegistry.tableRows(for: tab.id).rows.count guard tab.pagination.canGoToNextPage(loadedRowCount: loadedRowCount) else { return } paginateAfterConfirmation(tabIndex: tabIndex) { $0.goToNextPage(loadedRowCount: loadedRowCount) } } func goToPreviousPage() { - paginateIfPossible(where: \.hasPreviousPage) { $0.goToPreviousPage() } + seekIfPossible(where: \.hasPreviousPage) { $0.goToPreviousPage() } } func goToFirstPage() { - paginateIfPossible(where: \.hasPreviousPage) { $0.goToFirstPage() } + seekIfPossible(where: \.hasPreviousPage) { $0.goToFirstPage() } } func goToLastPage() { - paginateIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } + seekIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } } func goToPage(_ page: Int) { - paginateIfPossible(where: { $0.hasRowCountTotal && page > 0 }) { $0.goToPage(page) } + seekIfPossible(where: { $0.hasRowCountTotal && page > 0 }) { $0.goToPage(page) } } func updatePageSize(_ newSize: Int) { guard newSize > 0 else { return } - paginateIfPossible { $0.updatePageSize(newSize) } + let pageSize = parent.paginationCapability.clampedRowCount(newSize) + paginateIfPossible { $0.updatePageSize(pageSize) } + } + + /// Every page move asks this, because an engine that cannot skip rows has only the first page. + private var canSeek: Bool { + parent.paginationCapability.allowsSeeking + } + + private func seekIfPossible( + where condition: (PaginationState) -> Bool, + mutate: @escaping (inout PaginationState) -> Void + ) { + guard canSeek else { return } + paginateIfPossible(where: condition, mutate: mutate) } /// Only ever sized from a real count. @@ -55,7 +69,8 @@ final class PaginationCoordinator { /// `Count Exactly` in the status bar is the route to an exact total, and it sits next to the /// estimate that makes this unavailable. func showAllRows() { - guard let (tab, _) = parent.tabManager.selectedTabAndIndex, + guard canSeek, + let (tab, _) = parent.tabManager.selectedTabAndIndex, tab.pagination.hasExactRowCount, let total = tab.pagination.totalRowCount, total > 0 else { return } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index ff3c46a30f..8861cdece8 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -29,6 +29,15 @@ extension QueryExecutionCoordinator { return cap } + /// The text to send for a tab's read and the cap the app keeps on its result. + func resolveStatement(sql: String, tabType: TabType, bypassLimit: Bool = false) -> LeadingRowsStatement { + LeadingRowsStatement.resolve( + sql, + rowCap: resolveRowCap(sql: sql, tabType: tabType, bypassLimit: bypassLimit), + databaseType: parent.connection.type + ) + } + func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { QueryExecutor.parseSchemaMetadata( schema, @@ -630,6 +639,7 @@ extension QueryExecutionCoordinator { connectionType: DatabaseType ) { let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql + let countsAutomatically = PluginManager.shared.paginationCapability(for: connectionType).allowsSeeking let contentEpoch = parent.tabExecution.contentEpoch(for: tabId) let token = UUID() @@ -648,7 +658,8 @@ extension QueryExecutionCoordinator { isNonSQL: isNonSQL, filterState: tab.filterState, approximateRowCount: tab.pagination.totalRowCount, - threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan + threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan, + countsAutomatically: countsAutomatically ) guard case let .exactCount(filtered) = plan else { return (plan, nil, scope) } let buffer = parent.tabSessionRegistry.tableRows(for: tabId) @@ -729,12 +740,18 @@ extension QueryExecutionCoordinator { } } + /// An engine that cannot skip rows has no pages for a total to bound, so it is only counted + /// when the user asks: each automatic count would be a full scan the engine may bill for. static func rowCountPlan( isNonSQL: Bool, filterState: TabFilterState, approximateRowCount: Int?, - threshold: Int + threshold: Int, + countsAutomatically: Bool = true ) -> RowCountPlan { + guard countsAutomatically else { + return filterState.hasAppliedFilters ? .clear : .skip + } if isNonSQL { return filterState.hasAppliedFilters ? .filteredNonSQL(filters: filterState.appliedFilters, logicMode: filterState.filterLogicMode) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index e56a425494..1f743124fe 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -20,6 +20,10 @@ private struct BoundParameterValues: @unchecked Sendable { private struct PreparedStatement: @unchecked Sendable { let originalSQL: String let executableSQL: String + /// `executableSQL` with the LIMIT an engine that caps its rows is always sent. Kept apart + /// because Fetch All re-runs `executableSQL`, and re-running the limited text would fetch the + /// same trimmed rows again. + let sentSQL: String let parameterValues: [Any?]? let rowCap: Int? let anchor: StatementAnchor? @@ -120,7 +124,8 @@ extension QueryExecutionCoordinator { let tabId = parent.tabManager.tabs[index].id let claim = parent.tabExecution.claim(tabId) - let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) + let statement = resolveStatement(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) + let rowCap = statement.rowCap let (tableName, isEditable) = parent.resolveTableEditability(tab: tab, sql: sql) let needsMetadataFetch: Bool @@ -154,7 +159,7 @@ extension QueryExecutionCoordinator { ) { [queryExecutor = parent.queryExecutor, boundValues] driver in try await queryExecutor.executeQuery( driver: driver, - sql: sql, + sql: statement.sql, parameters: boundValues.values, rowCap: rowCap ) @@ -357,11 +362,13 @@ extension QueryExecutionCoordinator { ? nil : SQLParameterExtractor.convertToNativeStyle(sql: sql, parameters: parameters, style: style) let executableSQL = conversion?.sql ?? sql + let bounded = resolveStatement(sql: executableSQL, tabType: tabType, bypassLimit: bypassRowLimit) return PreparedStatement( originalSQL: sql, executableSQL: executableSQL, + sentSQL: bounded.sql, parameterValues: conversion?.values, - rowCap: resolveRowCap(sql: executableSQL, tabType: tabType, bypassLimit: bypassRowLimit), + rowCap: bounded.rowCap, anchor: StatementAnchor(statement) ) } @@ -417,7 +424,7 @@ extension QueryExecutionCoordinator { do { results.append(try await executeStatement( rowCap: statement.rowCap, - originalSQL: statement.executableSQL, + originalSQL: statement.sentSQL, driver: driver, parameters: statement.parameterValues )) diff --git a/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift b/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift index d29fad2eaf..31e51c1e09 100644 --- a/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift +++ b/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift @@ -34,7 +34,8 @@ struct RewindExecutor { queryBuilder: TableQueryBuilder( databaseType: connection.type, pluginDriver: factory.pluginDriver, - dialect: PluginManager.shared.sqlDialect(for: connection.type) + dialect: PluginManager.shared.sqlDialect(for: connection.type), + pagination: PluginManager.shared.paginationCapability(for: connection.type) ) ) let queries = planner.readQueries() diff --git a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 0b4288eb3d..80cabaa551 100644 --- a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -185,6 +185,14 @@ internal actor DatabaseAccessBridge { options: [.regularExpression, .caseInsensitive] ) != nil let shouldCap = classification.tier == .safe || hasReturning + let statement: LeadingRowsStatement + if shouldCap { + statement = await MainActor.run { + LeadingRowsStatement.resolve(normalizedQuery, rowCap: maxRows, databaseType: databaseType) + } + } else { + statement = LeadingRowsStatement(sql: normalizedQuery, rowCap: nil) + } let connectionId = scope.connectionId let policy: DriverCancellationPolicy = classification.tier == .safe ? .cancellableRead : .protectedWrite @@ -208,8 +216,8 @@ internal actor DatabaseAccessBridge { ) { driver in if shouldCap { return try await driver.executeUserQuery( - query: normalizedQuery, - rowCap: maxRows, + query: statement.sql, + rowCap: statement.rowCap ?? maxRows, parameters: nil ) } diff --git a/TablePro/Core/Database/DatabaseManager+RemoteFile.swift b/TablePro/Core/Database/DatabaseManager+RemoteFile.swift index 95f4046571..037cbdb691 100644 --- a/TablePro/Core/Database/DatabaseManager+RemoteFile.swift +++ b/TablePro/Core/Database/DatabaseManager+RemoteFile.swift @@ -41,15 +41,7 @@ extension DatabaseManager { forceRefetch: false ) - var effective = connection.substitutingLocalFilePath(file.workingCopy.path, in: field) - - // Read-only is enforced here rather than promised in the pane's copy. The driver opens a - // copy on this Mac, so an edit would succeed locally, change nothing on the server, and be - // discarded the next time the file is fetched. Routing it through the same `safeModeLevel` - // the rest of the app already honours means the grid, the editor and the AI tools all - // refuse the write for the same reason, instead of each having to learn about remote files. - effective.safeModeLevel = .readOnly - return effective + return connection.substitutingLocalFilePath(file.workingCopy.path, in: field) } /// Answers Test Connection without fetching the database. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 2623aea3b3..6cccde6be5 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -220,7 +220,7 @@ extension DatabaseManager { internal func resolvedConnectionDefinition(for connection: DatabaseConnection) -> DatabaseConnection { guard let stored = connectionStorage.loadConnection(id: connection.id) else { return connection } var resolved = connection - resolved.safeModeLevel = stored.safeModeLevel + resolved.preferredSafeModeLevel = stored.preferredSafeModeLevel return resolved } @@ -557,7 +557,7 @@ extension DatabaseManager { guard let session = activeSessions[id], let stored = connectionStorage.loadConnection(id: id) else { continue } adoptDisplayFields(from: stored, into: session, for: id) - setSafeModeLevel(stored.safeModeLevel, for: id) + setSafeModeLevel(stored.preferredSafeModeLevel, for: id) } } @@ -588,11 +588,26 @@ extension DatabaseManager { setSession(updated, for: connectionId) } + /// The user picking a level from the toolbar or the Database menu. + /// + /// A level below the connection's floor is not on offer, and picking the level already in + /// force changes nothing: writing it would replace the level the user saved, which is the one + /// that comes back once the floor lifts. + func chooseSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { + guard let connection = activeSessions[connectionId]?.connection, + level != connection.safeModeLevel, + connection.safeModeFloor?.allows(level) ?? true + else { return } + setSafeModeLevel(level, for: connectionId) + } + func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { guard var session = activeSessions[connectionId] else { return } - guard session.safeModeLevel != level || session.connection.safeModeLevel != level else { return } - session.safeModeLevel = level - session.connection.safeModeLevel = level + guard session.connection.preferredSafeModeLevel != level + || session.safeModeLevel != session.connection.safeModeLevel + else { return } + session.connection.preferredSafeModeLevel = level + session.safeModeLevel = session.connection.safeModeLevel setSession(session, for: connectionId) _ = connectionStorage.updateSafeModeLevel(level, for: connectionId) } diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 6a7d1370c8..b93afa65ac 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -80,6 +80,8 @@ extension MCPConnectionBridge { let databaseType = try await ensureConnected(scope.connectionId) let schema = scope.schema let dialect = try? resolveSQLDialect(for: databaseType) + let pagination = PaginationCapability.of(databaseType) + let limit = try MCPConnectionBridge.browseLimit(for: request, pagination: pagination) let sql = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver -> String in let columnInfos = try await driver.fetchColumns(table: request.table, schema: schema) @@ -94,7 +96,8 @@ extension MCPConnectionBridge { let builder = TableQueryBuilder( databaseType: databaseType, pluginDriver: driver.queryBuildingPluginDriver, - dialect: dialect + dialect: dialect, + pagination: pagination ) let sortState = MCPConnectionBridge.sortState(from: request.sort, columns: names) let requested = try MCPConnectionBridge.validatedSelection(request.columns, available: names) @@ -114,7 +117,7 @@ extension MCPConnectionBridge { sortState: sortState, columns: names, selectColumns: selected, - limit: request.limit, + limit: limit, offset: request.offset ) } @@ -127,7 +130,7 @@ extension MCPConnectionBridge { columns: names, columnTypes: types, selectColumns: selected, - limit: request.limit, + limit: limit, offset: request.offset ) } @@ -135,19 +138,34 @@ extension MCPConnectionBridge { var payload = try await executeQuery( scope: scope, query: sql, - maxRows: request.limit, + maxRows: limit, timeoutSeconds: timeoutSeconds, cancellation: cancellation ) if case .object(var fields) = payload { fields["table"] = .string(request.table) fields["offset"] = .int(request.offset) - fields["limit"] = .int(request.limit) + fields["limit"] = .int(limit) + if limit < request.limit, case .int(let rowCount)? = fields["row_count"], rowCount >= limit { + fields["is_truncated"] = .bool(true) + } payload = .object(fields) } return payload } + /// The row count a browse may ask for. An engine that cannot skip rows serves only the leading + /// ones, so an offset is refused rather than quietly answered with the first page again, and a + /// limit past the engine's ceiling is lowered to it. + static func browseLimit(for request: MCPBrowseRequest, pagination: PaginationCapability) throws -> Int { + guard pagination.allowsSeeking || request.offset == 0 else { + throw DatabaseAccessError.invalidArgument( + String(localized: "This database cannot skip rows, so offset must be 0. Narrow the rows with filters or a sort instead.") + ) + } + return pagination.clampedRowCount(request.limit) + } + func searchSchema(scope: DatabaseScope, term: String, limit: Int) async throws -> JsonValue { try await ensureConnected(scope.connectionId) let schema = scope.schema diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 33c49181ec..c79acc8904 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -20,10 +20,13 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable /// construction, on the main actor, because the registry lives there and this is asked for from /// the export plugin's own thread. let supportsCascadeDrop: Bool + private let pagination: PaginationCapability + private let cappedTables = OSAllocatedUnfairLock<[String]>(initialState: []) init(driver: DatabaseDriver, databaseType: DatabaseType) { self.supportsCascadeDrop = PluginMetadataRegistry.shared .snapshot(for: databaseType)?.capabilities.supportsCascadeDrop ?? false + self.pagination = PaginationCapability.of(databaseType) self.driver = driver self.dbType = databaseType self.databaseTypeId = databaseType.rawValue @@ -33,17 +36,71 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable (driver as? PluginDriverAdapter)?.schemaPluginDriver } + /// One line per table that stopped at the engine's row ceiling, so a partial copy is never + /// reported as the whole table. + var cappedTableWarnings: [String] { + guard let maximum = pagination.maximumRows else { return [] } + return cappedTables.withLock { $0 }.map { table in + String( + format: String(localized: "%1$@: only the first %2$lld rows were read, the most this database returns from one query."), + table, + maximum + ) + } + } + func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { guard let pluginDriver else { return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } } - let query: String if let customQuery = pluginDriver.defaultExportQuery(table: table, schema: exportSchema(for: databaseName)) { - query = customQuery - } else { - query = "SELECT * FROM \(qualifiedTableRef(table: table, databaseName: databaseName))" + return pluginDriver.streamRows(query: customQuery) + } + let query = "SELECT * FROM \(qualifiedTableRef(table: table, databaseName: databaseName))" + return streamLeadingRows(query: limitedToLeadingRows(query, limit: nil, driver: pluginDriver), table: table) + } + + /// An engine that caps its rows answers a statement with no LIMIT with a smaller default of its + /// own, so every read here states a limit, and a limit past the ceiling is lowered to it. + private func limitedToLeadingRows(_ query: String, limit: Int?, driver: any PluginDatabaseDriver) -> String { + guard let rowLimit = Self.rowLimit(requested: limit, pagination: pagination) else { return query } + return driver.injectRowLimit(query, limit: rowLimit) ?? "\(query) LIMIT \(rowLimit)" + } + + static func rowLimit(requested: Int?, pagination: PaginationCapability) -> Int? { + requested.map(pagination.clampedRowCount) ?? pagination.maximumRows + } + + /// Streams through the adapter rather than the plugin, so the statement text is validated the + /// way every other statement is: a row scope carries a filter the user typed. + private func streamLeadingRows( + query: String, + table: String + ) -> AsyncThrowingStream { + guard let adapter = driver as? PluginDriverAdapter else { + return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } + } + let stream = adapter.streamRows(query: query) + guard let maximum = pagination.maximumRows else { return stream } + let cappedTables = cappedTables + return AsyncThrowingStream { continuation in + let task = Task { + var rowCount = 0 + do { + for try await element in stream { + if case .rows(let rows) = element { rowCount += rows.count } + continuation.yield(element) + } + if rowCount >= maximum { + cappedTables.withLock { $0.append(table) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } } - return pluginDriver.streamRows(query: query) } /// The row limit goes through the driver's own `injectRowLimit`, because `LIMIT` is not the @@ -65,13 +122,8 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable if !filter.isEmpty { query += " WHERE \(filter)" } - if let rowLimit = scope.rowLimit { - query = pluginDriver.injectRowLimit(query, limit: rowLimit) ?? "\(query) LIMIT \(rowLimit)" - } - guard let adapter = driver as? PluginDriverAdapter else { - return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } - } - return adapter.streamRows(query: query) + query = limitedToLeadingRows(query, limit: scope.rowLimit, driver: pluginDriver) + return streamLeadingRows(query: query, table: object.name) } func fetchTableDDL(table: String, databaseName: String) async throws -> String { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index b9d907ff0e..fbb91a7268 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -473,6 +473,15 @@ extension PluginManager { .capabilities.supportsReadOnlyMode ?? true } + func paginationCapability(for databaseType: DatabaseType) -> PaginationCapability { + PaginationCapability.of(databaseType) + } + + func isEngineReadOnly(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.isEngineReadOnly ?? false + } + func defaultSchemaName(for databaseType: DatabaseType) -> String { PluginMetadataRegistry.shared.snapshot(for: databaseType)? .schema.defaultSchemaName ?? "public" diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift new file mode 100644 index 0000000000..8d7f0b7b46 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift @@ -0,0 +1,150 @@ +// +// PluginMetadataRegistry+R2SQLDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + func r2SQLPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Cloudflare R2 SQL", PluginMetadataSnapshot( + displayName: "Cloudflare R2 SQL", iconName: "cloudflare-r2-sql-icon", defaultPort: 0, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: r2SQLExplainVariants, + pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: [], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#F6821F", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false, + supportsCloudflareTunnel: false, + pagination: .leadingRowsOnly(maximumRows: 10_000), + isEngineReadOnly: true + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Bucket", + schemaEntityName: "Namespace", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: r2SQLDialect, + statementCompletions: r2SQLCompletions, + columnTypesByCategory: r2SQLColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: r2SQLConnectionFields(), + category: .cloud, + tagline: String(localized: "Read-only SQL over Iceberg tables in R2") + ) + )) + ] + } + + private func r2SQLConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: "r2AccountId", + label: String(localized: "Account ID"), + placeholder: "Cloudflare Account ID", + required: true, + section: .authentication + ), + ConnectionField( + id: "r2Bucket", + label: String(localized: "Bucket"), + placeholder: "my-bucket", + required: true, + section: .authentication + ) + ] + } +} + +private let r2SQLExplainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "Explain", sqlPrefix: "EXPLAIN"), + ExplainVariant(id: "explainJson", label: "Explain (JSON)", sqlPrefix: "EXPLAIN FORMAT JSON") +] + +private let r2SQLCompletions: [CompletionEntry] = [ + CompletionEntry(label: "SELECT", insertText: "SELECT * FROM namespace.table LIMIT 100"), + CompletionEntry(label: "SHOW NAMESPACES", insertText: "SHOW NAMESPACES"), + CompletionEntry(label: "SHOW TABLES", insertText: "SHOW TABLES IN namespace"), + CompletionEntry(label: "DESCRIBE", insertText: "DESCRIBE namespace.table"), + CompletionEntry(label: "EXPLAIN", insertText: "EXPLAIN SELECT * FROM namespace.table LIMIT 10") +] + +private let r2SQLColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "DECIMAL"], + "String": ["TEXT"], + "Date": ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ"], + "Binary": ["BINARY"], + "Boolean": ["BOOLEAN"], + "Nested": ["ARRAY", "STRUCT", "MAP"] +] + +private let r2SQLDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "QUALIFY", + "ORDER", "ASC", "DESC", "NULLS", "FIRST", "LAST", "LIMIT", "AS", "ON", "USING", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", + "AND", "OR", "NOT", "IN", "EXISTS", "LIKE", "ILIKE", "ESCAPE", "BETWEEN", "IS", "NULL", + "CASE", "WHEN", "THEN", "ELSE", "END", + "WITH", "UNION", "INTERSECT", "EXCEPT", "ALL", + "OVER", "PARTITION", "ROWS", "RANGE", "PRECEDING", "FOLLOWING", "CURRENT", "ROW", "UNBOUNDED", + "SHOW", "NAMESPACES", "DATABASES", "SCHEMAS", "TABLES", "DESCRIBE", "EXPLAIN", "FORMAT", "JSON", + "TRUE", "FALSE", "CAST" + ], + functions: [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "MEDIAN", + "APPROX_DISTINCT", "APPROX_PERCENTILE_CONT", "APPROX_TOP_K", "PERCENTILE_CONT", + "ROW_NUMBER", "RANK", "DENSE_RANK", "PERCENT_RANK", "CUME_DIST", "NTILE", + "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE", + "ABS", "CEIL", "FLOOR", "ROUND", "POWER", "SQRT", "LN", "LOG", "EXP", + "LENGTH", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", "SUBSTR", "SUBSTRING", + "REPLACE", "CONCAT", "SPLIT_PART", "STARTS_WITH", "ENDS_WITH", "REGEXP_LIKE", + "DATE_TRUNC", "DATE_PART", "EXTRACT", "TO_TIMESTAMP", "NOW", + "COALESCE", "NULLIF", "GET_FIELD", "ARRAY_LENGTH", "MAP_KEYS", "MAP_VALUES", "MAP_EXTRACT" + ], + dataTypes: [ + "BOOLEAN", "TINYINT", "SMALLINT", "INT", "BIGINT", "REAL", "DOUBLE", "DECIMAL", + "TEXT", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "BINARY", "ARRAY", "STRUCT", "MAP" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit +) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5d7c822c9e..d8f4b855e9 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -717,6 +717,7 @@ extension PluginMetadataRegistry { supportsAddIndex: false, supportsDropIndex: false, supportsModifyPrimaryKey: false, + isEngineReadOnly: true, localFilePathField: .database ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -1139,7 +1140,7 @@ extension PluginMetadataRegistry { ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) + duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes) + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() - + kafkaPluginDefaults() + typesensePluginDefaults() + + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index c4e293487d..eb072f196a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -74,6 +74,8 @@ struct PluginMetadataSnapshot: Sendable { var supportsClientKeyPassphrase: Bool = false var supportsConnectionPooling: Bool = true var authenticationIsDatabaseScoped: Bool = false + var pagination: PaginationCapability = .offset + var isEngineReadOnly: Bool = false /// Which connection field carries the path of the local database file this driver opens, /// for the types that open one. Nil for every driver that reaches its database over the @@ -624,6 +626,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsConnectionPooling: existingSnapshot?.capabilities.supportsConnectionPooling ?? true, authenticationIsDatabaseScoped: existingSnapshot?.capabilities .authenticationIsDatabaseScoped ?? false, + pagination: existingSnapshot?.capabilities.pagination ?? .offset, + isEngineReadOnly: existingSnapshot?.capabilities.isEngineReadOnly ?? false, localFilePathField: existingSnapshot?.capabilities.localFilePathField, supportsRemoteDatabaseFile: existingSnapshot?.capabilities .supportsRemoteDatabaseFile ?? false, diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index dec247d996..1b37d5cbb6 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -71,7 +71,7 @@ struct ColumnTypeClassifier { // MARK: - Pattern Fallback private func classifyByPattern(upper: String, rawTypeName: String) -> ColumnType { - if upper == "ARRAY" || upper == "MAP" || upper == "ROW" { + if upper == "ARRAY" || upper == "MAP" || upper == "ROW" || upper == "STRUCT" { return .json(rawType: rawTypeName) } if upper.contains("BOOL") { diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index d54c7ba5ed..c5d6fe5814 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -104,7 +104,8 @@ enum ConnectionExportService { let color: String? = connection.color == .none ? nil : connection.color.rawValue - let safeModeLevel: String? = connection.safeModeLevel == .silent ? nil : connection.safeModeLevel.rawValue + let preferredLevel = connection.preferredSafeModeLevel + let safeModeLevel: String? = preferredLevel == .silent ? nil : preferredLevel.rawValue let aiPolicy: String? = connection.aiPolicy?.rawValue diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 9b1ede026a..1f450ba3c6 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -199,7 +199,7 @@ final class ExportService { state.processedRows = progress.processedRows - state.warnings = result.warnings + state.warnings = result.warnings + dataSource.cappedTableWarnings } // MARK: - Statement Timeout @@ -316,7 +316,7 @@ final class ExportService { } let dataSource = StreamingQueryExportDataSource( - query: query, + query: LeadingRowsStatement.resolve(query, rowCap: nil, databaseType: databaseType).sql, driver: driver, databaseType: databaseType ) @@ -353,7 +353,21 @@ final class ExportService { state.processedRows = progress.processedRows - state.warnings = result.warnings + let capWarning = Self.leadingRowsCapWarning( + exportedRows: progress.processedRows, + pagination: PaginationCapability.of(databaseType) + ) + state.warnings = result.warnings + [capWarning].compactMap { $0 } + } + + /// A query result exported from an engine that returns only its leading rows stops at the + /// engine's ceiling, so a file that reached it is named as partial rather than passing as whole. + static func leadingRowsCapWarning(exportedRows: Int, pagination: PaginationCapability) -> String? { + guard let maximum = pagination.maximumRows, exportedRows >= maximum else { return nil } + return String( + format: String(localized: "Only the first %lld rows were exported, the most this database returns from one query."), + maximum + ) } // MARK: - Row Count Fetching diff --git a/TablePro/Core/Services/Export/TableTransferService.swift b/TablePro/Core/Services/Export/TableTransferService.swift index f50ea9d419..3555242cbd 100644 --- a/TablePro/Core/Services/Export/TableTransferService.swift +++ b/TablePro/Core/Services/Export/TableTransferService.swift @@ -150,6 +150,7 @@ final class TableTransferService { ) try await transferOne(object: object, from: source, into: sink, request: request) } + state.warnings.append(contentsOf: source.cappedTableWarnings) } /// The sink writes by column name and skips any field the mapping does not name, so an empty diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 05053dc313..2a11ff404c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -55,6 +55,8 @@ struct MenuValidationContext: Equatable { /// run out independently and an item that is disabled has to say which one it is. var canNavigateBack = false var canNavigateForward = false + /// First, Previous, Next and Last Page, which an engine that cannot skip rows never offers. + var canNavigatePages = false var canSaveAsFavorite = false var canSwitchSidebarLayout = false var canToggleWorkspaceRail = false @@ -112,13 +114,15 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(focusSidebarFilter(_:)), #selector(showERDiagram(_:)), #selector(previewFKReference(_:)), - #selector(goToFirstPage(_:)), - #selector(goToPreviousPage(_:)), - #selector(goToNextPage(_:)), - #selector(goToLastPage(_:)), #selector(selectNumberedTab(_:)): return context.isConnected + case #selector(goToFirstPage(_:)), + #selector(goToPreviousPage(_:)), + #selector(goToNextPage(_:)), + #selector(goToLastPage(_:)): + return context.isConnected && context.canNavigatePages + case #selector(saveDocument(_:)): return context.isConnected && !context.isReadOnly && context.hasPendingChanges case #selector(saveDocumentAs(_:)): @@ -314,6 +318,7 @@ extension MainSplitViewController: NSMenuItemValidation { canPinResultTab: actions.canPinResultTab, canNavigateBack: actions.canNavigateBack, canNavigateForward: actions.canNavigateForward, + canNavigatePages: actions.canNavigatePages, canSaveAsFavorite: actions.canSaveAsFavorite, canSwitchSidebarLayout: actions.canSwitchSidebarLayout, canToggleWorkspaceRail: canToggleWorkspaceRail, @@ -358,6 +363,7 @@ extension MainSplitViewController: NSMenuItemValidation { return isAssistantVisible || (currentPane == .content && AppSettingsManager.shared.ai.enabled) } if action == #selector(setResultView(_:)) { return canShowResultView(menuItem) } + if action == #selector(setSafeModeLevel(_:)) { return canChooseSafeModeLevel(menuItem) } if action == #selector(requestDisconnect) { return canDisconnect } if action == #selector(retryConnection) { return canReconnect } return Self.isEnabled(action, context: menuValidationContext) @@ -436,6 +442,13 @@ extension MainSplitViewController: NSMenuItemValidation { return commandActions?.availableResultsViewModes.contains(mode) ?? false } + private func canChooseSafeModeLevel(_ menuItem: NSMenuItem) -> Bool { + guard isConnected, + let raw = menuItem.representedObject as? String, + let level = SafeModeLevel(rawValue: raw) else { return false } + return commandActions?.coordinator?.connection.safeModeFloor?.allows(level) ?? true + } + private func isCurrentResultView(_ menuItem: NSMenuItem) -> Bool { guard let raw = menuItem.representedObject as? String else { return false } return commandActions?.resultsViewMode?.rawValue == raw diff --git a/TablePro/Core/Services/Policy/ManagedPolicy.swift b/TablePro/Core/Services/Policy/ManagedPolicy.swift index f008c182e7..8a925a8581 100644 --- a/TablePro/Core/Services/Policy/ManagedPolicy.swift +++ b/TablePro/Core/Services/Policy/ManagedPolicy.swift @@ -78,21 +78,11 @@ internal enum ManagedPolicyResolver { connectionLevel: SafeModeLevel, policy: any ManagedPolicyReading ) -> SafeModeLevel { - guard let raw = policy.string(.minimumSafeModeLevel), - let floor = SafeModeLevel(rawValue: raw) - else { return connectionLevel } - return strictness(floor) > strictness(connectionLevel) ? floor : connectionLevel + guard let floor = minimumSafeModeLevel(policy: policy) else { return connectionLevel } + return floor.strictness > connectionLevel.strictness ? floor : connectionLevel } - /// Ordered weakest to strongest by what each level actually prevents, not by declaration order. - private static func strictness(_ level: SafeModeLevel) -> Int { - switch level { - case .silent: 0 - case .alert: 1 - case .alertFull: 2 - case .safeMode: 3 - case .safeModeFull: 4 - case .readOnly: 5 - } + internal static func minimumSafeModeLevel(policy: any ManagedPolicyReading) -> SafeModeLevel? { + policy.string(.minimumSafeModeLevel).flatMap(SafeModeLevel.init(rawValue:)) } } diff --git a/TablePro/Core/Services/Query/LeadingRowsStatement.swift b/TablePro/Core/Services/Query/LeadingRowsStatement.swift new file mode 100644 index 0000000000..66055c75f5 --- /dev/null +++ b/TablePro/Core/Services/Query/LeadingRowsStatement.swift @@ -0,0 +1,70 @@ +// +// LeadingRowsStatement.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// A read sent to an engine that returns only its leading rows, with the LIMIT stated. +/// +/// Such an engine answers a statement that names no limit with a smaller default of its own +/// (Cloudflare R2 SQL stops at 500), so leaving the limit out does not mean "all rows" there. Every +/// read the user did not limit is therefore sent with one: one row past the app's row cap, so a +/// trimmed result is still detected and offers Fetch All, or the engine's ceiling when nothing caps it. +struct LeadingRowsStatement: Equatable { + let sql: String + let rowCap: Int? + + static func bound( + _ sql: String, + rowCap: Int?, + maximumRows: Int, + autoLimitStyle: AutoLimitStyle, + lexicalDialect: SqlDialect + ) -> LeadingRowsStatement { + let unchanged = LeadingRowsStatement(sql: sql, rowCap: rowCap) + guard !SQLLimitDetector.hasExplicitRowLimit(sql, autoLimitStyle: autoLimitStyle, lexicalDialect: lexicalDialect) + else { return unchanged } + + let cap = rowCap.map { min($0, maximumRows) } + let fetched = cap.map { min($0 + 1, maximumRows) } ?? maximumRows + guard let limited = appending(limit: fetched, to: sql, style: autoLimitStyle) else { return unchanged } + return LeadingRowsStatement(sql: limited, rowCap: cap) + } + + /// The clause goes on a line of its own, so a trailing `--` comment cannot swallow it. + private static func appending(limit: Int, to sql: String, style: AutoLimitStyle) -> String? { + var statement = StatementBlank.trimming(sql) + while statement.hasSuffix(";") { + statement = StatementBlank.trimming(String(statement.dropLast())) + } + switch style { + case .limit: + return "\(statement)\nLIMIT \(limit)" + case .fetchFirst: + return "\(statement)\nFETCH FIRST \(limit) ROWS ONLY" + case .top, .none: + return nil + @unknown default: + return nil + } + } +} + +@MainActor +extension LeadingRowsStatement { + /// The statement to send for a query tab's read, or for re-running one to export it. + static func resolve(_ sql: String, rowCap: Int?, databaseType: DatabaseType) -> LeadingRowsStatement { + guard let maximumRows = PluginManager.shared.paginationCapability(for: databaseType).maximumRows, + QueryExecutor.qualifiesForRowCap(sql: sql, tabType: .query, databaseType: databaseType) + else { return LeadingRowsStatement(sql: sql, rowCap: rowCap) } + return bound( + sql, + rowCap: rowCap, + maximumRows: maximumRows, + autoLimitStyle: PluginManager.shared.autoLimitStyle(for: databaseType), + lexicalDialect: SqlDialect.from(databaseTypeId: databaseType.rawValue) + ) + } +} diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 0474b9aee5..f41aa80aff 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -16,6 +16,7 @@ struct TableQueryBuilder { private let databaseType: DatabaseType private var pluginDriver: (any PluginDatabaseDriver)? private let dialect: SQLDialectDescriptor? + private let pagination: PaginationCapability private let dialectQuote: (String) -> String // MARK: - Initialization @@ -24,11 +25,13 @@ struct TableQueryBuilder { databaseType: DatabaseType, pluginDriver: (any PluginDatabaseDriver)? = nil, dialect: SQLDialectDescriptor? = nil, + pagination: PaginationCapability, dialectQuote: ((String) -> String)? = nil ) { self.databaseType = databaseType self.pluginDriver = pluginDriver self.dialect = dialect + self.pagination = pagination self.dialectQuote = dialectQuote ?? { name in let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" @@ -219,6 +222,9 @@ struct TableQueryBuilder { } private func buildPaginationClause(limit: Int, offset: Int) -> String { + guard pagination.allowsSeeking else { + return "LIMIT \(pagination.clampedRowCount(limit))" + } if let dialect, dialect.paginationStyle == .offsetFetch { return "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 192715162a..21b1ef24a6 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -244,9 +244,9 @@ final class ConnectionStorage { return false } - guard connections[index].safeModeLevel != level else { return true } + guard connections[index].preferredSafeModeLevel != level else { return true } - connections[index].safeModeLevel = level + connections[index].preferredSafeModeLevel = level guard saveConnections(connections) else { Self.logger.error( "Aborted updateSafeModeLevel: persistence failed for \(connectionId, privacy: .public)" @@ -367,7 +367,7 @@ final class ConnectionStorage { cloudSQLProxyMode: connection.cloudSQLProxyMode, socksProxyMode: connection.socksProxyMode, tunnelCommandMode: connection.tunnelCommandMode, - safeModeLevel: connection.safeModeLevel, + safeModeLevel: connection.preferredSafeModeLevel, aiPolicy: connection.aiPolicy, aiRules: connection.aiRules, aiAlwaysAllowedTools: connection.aiAlwaysAllowedTools, diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index df4b5928a3..a36b59ec21 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -126,7 +126,7 @@ struct StoredConnection: Codable { self.groupId = connection.groupId?.uuidString self.sshProfileId = connection.sshProfileId?.uuidString - self.safeModeLevel = connection.safeModeLevel.rawValue + self.safeModeLevel = connection.preferredSafeModeLevel.rawValue self.externalAccess = connection.externalAccess.rawValue diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 7a51625b95..c2d094f650 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -86,12 +86,12 @@ struct SyncRecordMapper { fields[.username] = connection.username fields[.type] = connection.type.rawValue fields[.color] = connection.color.rawValue - fields[.safeModeLevel] = connection.safeModeLevel.rawValue + fields[.safeModeLevel] = connection.preferredSafeModeLevel.rawValue /// `safeModeLevel` superseded `isReadOnly`, but both are still on the wire and this mapper /// still reads the old one when the new one is absent. Writing only the new one left the /// old one holding whatever it last held, so a connection taken out of read-only on a Mac /// stayed read-only for anything reading the legacy field. - fields[.isReadOnly] = Int64(connection.safeModeLevel == .readOnly ? 1 : 0) + fields[.isReadOnly] = Int64(connection.preferredSafeModeLevel == .readOnly ? 1 : 0) fields[.modifiedAtLocal] = Date() fields[.schemaVersion] = schemaVersion fields[.sortOrder] = Int64(connection.sortOrder) diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 82081df02a..0b06ebbc99 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -51,6 +51,7 @@ enum QueryClassifier { let trimmed = StatementBlank.trimming(strippingLeadingComments(sql)) guard !trimmed.isEmpty else { return .safe } if let redis = redisClassification(trimmed, databaseType: databaseType) { return redis } + if let ledger = beancountClassification(trimmed, databaseType: databaseType) { return ledger } if let document = documentStoreClassification(trimmed, databaseType: databaseType) { return document } return sqlClassification(trimmed) } @@ -553,6 +554,20 @@ private extension QueryClassifier { "$where", "$function", "$accumulator", "mapreduce", ".eval(", "$out", "$merge" ] + /// Beancount answers BQL, whose statements (`SELECT`, `BALANCES`, `JOURNAL`, `PRINT`) only + /// read, and the two `PRAGMA` forms its driver accepts. Anything else falls through to SQL. + static func beancountClassification( + _ trimmed: String, + databaseType: DatabaseType + ) -> QueryClassification? { + guard databaseType == .beancount else { return nil } + let lowered = trimmed.lowercased() + guard beancountReadPrefixes.contains(where: lowered.hasPrefix) else { return nil } + return .safe + } + + private static let beancountReadPrefixes = ["bql:", "bql ", "pragma table_info", "pragma database_list"] + static func documentStoreClassification( _ trimmed: String, databaseType: DatabaseType diff --git a/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift b/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift new file mode 100644 index 0000000000..40d5efea15 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift @@ -0,0 +1,27 @@ +// +// DatabaseConnection+SafeMode.swift +// TablePro +// + +import Foundation + +extension DatabaseConnection { + /// The Safe Mode level in force: the user's own level, raised to the connection's floor. + /// + /// Every reader asks this one property, so a connection that cannot be written to, or one a + /// configuration profile holds at a minimum, reads the same in the grid, the toolbar, the + /// execution gate, MCP and scripting alike. Only the places that persist or edit the user's + /// choice read `preferredSafeModeLevel`. Assigning sets the user's choice. + var safeModeLevel: SafeModeLevel { + get { safeModeFloor?.raising(preferredSafeModeLevel) ?? preferredSafeModeLevel } + set { preferredSafeModeLevel = newValue } + } + + var safeModeFloor: SafeModeFloor? { + SafeModeFloor.resolve( + isEngineReadOnly: PluginMetadataRegistry.shared.snapshot(for: type)?.capabilities.isEngineReadOnly ?? false, + opensRemoteDatabaseFile: opensRemoteDatabaseFile, + managedMinimum: ManagedPolicyResolver.minimumSafeModeLevel(policy: ManagedPolicyReader.shared) + ) + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 494f661aca..076f827c99 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -165,7 +165,7 @@ struct DatabaseConnection: Identifiable, Hashable { var cloudSQLProxyMode: CloudSQLProxyMode = .disabled var socksProxyMode: SOCKSProxyMode = .disabled var tunnelCommandMode: TunnelCommandMode = .disabled - var safeModeLevel: SafeModeLevel + var preferredSafeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var aiRules: String? var aiAlwaysAllowedTools: Set = [] @@ -306,7 +306,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.tagIds = tagIds self.groupId = groupId self.sshProfileId = sshProfileId - self.safeModeLevel = safeModeLevel + self.preferredSafeModeLevel = safeModeLevel // Auto-derive sshTunnelMode from legacy fields if not explicitly set if sshTunnelMode == .disabled { @@ -421,7 +421,7 @@ extension DatabaseConnection: Codable { } groupId = try container.decodeIfPresent(UUID.self, forKey: .groupId) sshProfileId = try container.decodeIfPresent(UUID.self, forKey: .sshProfileId) - safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent + preferredSafeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent aiPolicy = try container.decodeIfPresent(AIConnectionPolicy.self, forKey: .aiPolicy) aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules) aiAlwaysAllowedTools = try container.decodeIfPresent(Set.self, forKey: .aiAlwaysAllowedTools) ?? [] @@ -486,7 +486,7 @@ extension DatabaseConnection: Codable { if case .inline = tunnelCommandMode { try container.encode(tunnelCommandMode, forKey: .tunnelCommandMode) } - try container.encode(safeModeLevel, forKey: .safeModeLevel) + try container.encode(preferredSafeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) if !aiAlwaysAllowedTools.isEmpty { diff --git a/TablePro/Models/Connection/DatabaseType.swift b/TablePro/Models/Connection/DatabaseType.swift index 5bda0ced42..34798f47e7 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -37,6 +37,7 @@ extension DatabaseType { static let scylladb = DatabaseType(rawValue: "ScyllaDB") static let etcd = DatabaseType(rawValue: "etcd") static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") + static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL") static let dynamodb = DatabaseType(rawValue: "DynamoDB") static let bigQuery = DatabaseType(rawValue: "BigQuery") static let libsql = DatabaseType(rawValue: "libSQL") diff --git a/TablePro/Models/Connection/SafeModeFloor.swift b/TablePro/Models/Connection/SafeModeFloor.swift new file mode 100644 index 0000000000..206f52d932 --- /dev/null +++ b/TablePro/Models/Connection/SafeModeFloor.swift @@ -0,0 +1,67 @@ +// +// SafeModeFloor.swift +// TablePro +// + +import Foundation + +/// The weakest Safe Mode level a connection may run at, and why it cannot go lower. +/// +/// A floor is never written into the user's own setting: switching the connection's type, turning +/// the remote file off or removing the configuration profile hands back the level the user chose. +internal struct SafeModeFloor: Equatable, Sendable { + internal enum Reason: Equatable, Sendable { + /// The engine accepts no writes at all. + case readOnlyEngine + /// The driver opens a working copy of a file on an SSH server, and nothing on this Mac + /// writes that copy back. + case remoteDatabaseFile + /// A configuration profile sets a minimum level for every connection. + case managedPolicy + } + + let level: SafeModeLevel + let reason: Reason + + /// A fact about the connection outranks the profile, because it already holds the strictest level. + static func resolve( + isEngineReadOnly: Bool, + opensRemoteDatabaseFile: Bool, + managedMinimum: SafeModeLevel? + ) -> SafeModeFloor? { + if isEngineReadOnly { return SafeModeFloor(level: .readOnly, reason: .readOnlyEngine) } + if opensRemoteDatabaseFile { return SafeModeFloor(level: .readOnly, reason: .remoteDatabaseFile) } + guard let managedMinimum, managedMinimum != .silent else { return nil } + return SafeModeFloor(level: managedMinimum, reason: .managedPolicy) + } + + func allows(_ candidate: SafeModeLevel) -> Bool { + candidate.strictness >= level.strictness + } + + func raising(_ candidate: SafeModeLevel) -> SafeModeLevel { + allows(candidate) ? candidate : level + } + + var explanation: String { + switch reason { + case .readOnlyEngine: + return String(localized: "This database only runs read queries, so the connection is always Read-Only.") + case .remoteDatabaseFile: + return String( + localized: "The database is a copy of a file on the SSH server, and changes are never written back, so the connection is always Read-Only." + ) + case .managedPolicy: + return String( + format: String(localized: "Your organization requires Safe Mode to be at least %@ on every connection."), + level.displayName + ) + } + } +} + +internal extension SafeModeFloor { + static func levels(allowedBy floor: SafeModeFloor?) -> [SafeModeLevel] { + SafeModeLevel.allCases.filter { floor?.allows($0) ?? true } + } +} diff --git a/TablePro/Models/Connection/SafeModeLevel.swift b/TablePro/Models/Connection/SafeModeLevel.swift index 5e571d3fc2..0e79d9b423 100644 --- a/TablePro/Models/Connection/SafeModeLevel.swift +++ b/TablePro/Models/Connection/SafeModeLevel.swift @@ -28,6 +28,18 @@ internal extension SafeModeLevel { } } + /// Ordered weakest to strongest by what each level actually prevents, not by declaration order. + var strictness: Int { + switch self { + case .silent: return 0 + case .alert: return 1 + case .alertFull: return 2 + case .safeMode: return 3 + case .safeModeFull: return 4 + case .readOnly: return 5 + } + } + var blocksAllWrites: Bool { self == .readOnly } diff --git a/TablePro/Models/Query/PaginationCapability.swift b/TablePro/Models/Query/PaginationCapability.swift new file mode 100644 index 0000000000..3f6e7acf7a --- /dev/null +++ b/TablePro/Models/Query/PaginationCapability.swift @@ -0,0 +1,39 @@ +// +// PaginationCapability.swift +// TablePro +// + +import Foundation + +/// How far into a result an engine lets the app read. +/// +/// An engine fact, next to `SQLDialectDescriptor.paginationStyle` rather than inside it: +/// `PaginationStyle` is `@frozen`, so a case for "cannot skip rows" would be a breaking PluginKit +/// change and a re-release of every plugin, and the spelling of a clause is a different question from +/// whether the engine can seek at all. +internal enum PaginationCapability: Equatable, Sendable { + /// The engine skips rows with OFFSET and returns as many as it is asked for. + case offset + /// The engine cannot skip rows and returns at most `maximumRows` from one statement, so the + /// only rows it can show are the leading ones. + case leadingRowsOnly(maximumRows: Int) + + var allowsSeeking: Bool { + if case .offset = self { return true } + return false + } + + var maximumRows: Int? { + if case .leadingRowsOnly(let maximumRows) = self { return maximumRows } + return nil + } + + func clampedRowCount(_ requested: Int) -> Int { + guard let maximumRows else { return requested } + return min(requested, maximumRows) + } + + static func of(_ databaseType: DatabaseType) -> PaginationCapability { + PluginMetadataRegistry.shared.snapshot(for: databaseType)?.capabilities.pagination ?? .offset + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index a521a81029..ece3ac2985 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -284,7 +284,8 @@ struct QueryTab: Identifiable, Equatable { schemaName: String? = nil, quoteIdentifier: ((String) -> String)? = nil ) throws -> String { - let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let pagination = PluginManager.shared.paginationCapability(for: databaseType) + let pageSize = pagination.clampedRowCount(AppSettingsManager.shared.dataGrid.defaultPageSize) if let pluginDriver = PluginManager.shared.queryBuildingDriver(for: databaseType), let pluginQuery = pluginDriver.buildBrowseQuery( @@ -305,6 +306,7 @@ struct QueryTab: Identifiable, Equatable { databaseType: databaseType, pluginDriver: nil, dialect: dialect, + pagination: pagination, dialectQuote: quoteIdentifier ?? quoteIdentifierFromDialect(dialect) ) return builder.buildBaseQuery( diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 4243ade912..eab0a2c32c 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -314,7 +314,8 @@ final class QueryTabManager { return false } - let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let pageSize = PluginManager.shared.paginationCapability(for: databaseType) + .clampedRowCount(AppSettingsManager.shared.dataGrid.defaultPageSize) let query = try QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: databaseType, @@ -458,7 +459,8 @@ final class QueryTabManager { schemaName: schemaName, quoteIdentifier: quoteIdentifier ) - let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let pageSize = PluginManager.shared.paginationCapability(for: databaseType) + .clampedRowCount(AppSettingsManager.shared.dataGrid.defaultPageSize) onTabRetargeted?(selectedId) diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index dbea66625c..f58e2c1ad3 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -43,6 +43,9 @@ struct ResultStatusControls: Equatable { var showsColumns = false var showsFilters = false var showsPagination = false + /// First, Previous, Next, Last and the page number, which an engine that cannot skip rows has + /// no use for. The rows-per-page menu stays, because it still sets how many leading rows load. + var showsPageNavigation = false /// The structure editor's add and remove pair, which is this bar's trailing cluster while the /// structure editor is the content. var showsStructureActions = false @@ -114,6 +117,7 @@ struct ResultStatusModel: Equatable { controls.showsColumns = viewMode.showsColumnControls && describesAResult controls.showsFilters = viewMode.showsRowFilters && isTable && snapshot.hasTableName controls.showsPagination = viewMode.showsResultScope && isTable && snapshot.hasTableName + controls.showsPageNavigation = controls.showsPagination && snapshot.paginationCapability.allowsSeeking return controls } diff --git a/TablePro/Models/Query/StatusBarSnapshot.swift b/TablePro/Models/Query/StatusBarSnapshot.swift index 1cc28bdf59..4676c20eb8 100644 --- a/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/TablePro/Models/Query/StatusBarSnapshot.swift @@ -25,6 +25,7 @@ struct StatusBarSnapshot: Equatable { let hasStructureActions: Bool let pagination: PaginationState let statusMessage: String? + let paginationCapability: PaginationCapability init( tabId: UUID?, @@ -38,7 +39,8 @@ struct StatusBarSnapshot: Equatable { availableModes: [ResultsViewMode] = [], hasStructureActions: Bool = false, pagination: PaginationState, - statusMessage: String? + statusMessage: String?, + paginationCapability: PaginationCapability = .offset ) { self.tabId = tabId self.tabType = tabType @@ -52,6 +54,7 @@ struct StatusBarSnapshot: Equatable { self.hasStructureActions = hasStructureActions self.pagination = pagination self.statusMessage = statusMessage + self.paginationCapability = paginationCapability } /// `isFetching` is the caller's answer to "is an execution running for this tab", which the tab @@ -64,7 +67,8 @@ struct StatusBarSnapshot: Equatable { tableRows: TableRows?, displayRowCount: Int? = nil, isFetching: Bool = false, - hasStructureActions: Bool = false + hasStructureActions: Bool = false, + paginationCapability: PaginationCapability = .offset ) { let loaded = tableRows?.rows.count ?? 0 let displayed = displayRowCount ?? loaded @@ -86,7 +90,8 @@ struct StatusBarSnapshot: Equatable { ), hasStructureActions: hasStructureActions, pagination: pagination, - statusMessage: tab?.execution.statusMessage + statusMessage: tab?.execution.statusMessage, + paginationCapability: paginationCapability ) } diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index a9bbc9dbd7..bf007eb8bf 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -11,6 +11,9 @@ struct PaginationControlsView: View { /// Identity of the tab these controls describe. Not used for display: a change to it is what /// discards a half-typed page number so it cannot be submitted against the next tab. let tabId: UUID? + var showsPageNavigation = true + /// The most rows the engine returns from one statement, when it caps them. + var maximumPageSize: Int? let onFirst: () -> Void let onPrevious: () -> Void let onNext: () -> Void @@ -32,10 +35,21 @@ struct PaginationControlsView: View { /// `9223372036854775807` set the page size to `Int.max` and the next status-bar render trapped. static let maximumPageSize = 1_000_000 + static func pageSizePresets(upTo maximum: Int?) -> [Int] { + guard let maximum else { return pageSizePresets } + return pageSizePresets.filter { $0 <= maximum } + } + + private var customPageSizeLimit: Int { + min(maximumPageSize ?? Self.maximumPageSize, Self.maximumPageSize) + } + var body: some View { HStack(spacing: 6) { pageSizeMenu - navigationCluster + if showsPageNavigation { + navigationCluster + } } .onChange(of: tabId) { _, _ in showJumpPopover = false @@ -53,7 +67,7 @@ struct PaginationControlsView: View { private var pageSizeMenu: some View { Menu { Picker(String(localized: "Rows per page"), selection: pageSizeBinding) { - ForEach(Self.pageSizePresets, id: \.self) { size in + ForEach(Self.pageSizePresets(upTo: maximumPageSize), id: \.self) { size in Text(size.formatted()).tag(size) } } @@ -61,8 +75,10 @@ struct PaginationControlsView: View { Divider() - Button(String(localized: "All rows…")) { onShowAll() } - .disabled(!pagination.hasExactRowCount) + if showsPageNavigation { + Button(String(localized: "All rows…")) { onShowAll() } + .disabled(!pagination.hasExactRowCount) + } Button(String(localized: "Custom…")) { customPageSize = pagination.pageSize showCustomPopover = true @@ -217,7 +233,7 @@ struct PaginationControlsView: View { caption: String(localized: "Rows per page"), value: $customPageSize, minimum: 1, - maximum: Self.maximumPageSize, + maximum: customPageSizeLimit, fieldWidth: 90, isFocused: $isCustomFocused, fieldAccessibilityLabel: String(localized: "Rows per page"), diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift new file mode 100644 index 0000000000..eb94f20444 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift @@ -0,0 +1,27 @@ +// +// ConnectionFormCoordinator+SafeMode.swift +// TablePro +// + +import Foundation + +@MainActor +extension ConnectionFormCoordinator { + var safeModeFloor: SafeModeFloor? { + SafeModeFloor.resolve( + isEngineReadOnly: services.pluginManager.isEngineReadOnly(for: network.type), + opensRemoteDatabaseFile: transport == .remoteFile, + managedMinimum: ManagedPolicyResolver.minimumSafeModeLevel(policy: ManagedPolicyReader.shared) + ) + } + + /// The level the connection will run at, which is what the form shows. Picking the level + /// already shown keeps the user's saved choice, which comes back once the floor lifts. + var effectiveSafeModeLevel: SafeModeLevel { + get { safeModeFloor?.raising(customization.safeModeLevel) ?? customization.safeModeLevel } + set { + guard newValue != effectiveSafeModeLevel else { return } + customization.safeModeLevel = newValue + } + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index e2fad9ae2d..8bd7ff34c7 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -96,11 +96,7 @@ struct OptionsPaneView: View { private var safetySection: some View { Section { - Picker(String(localized: "Safe Mode"), selection: $coordinator.customization.safeModeLevel) { - ForEach(SafeModeLevel.allCases) { level in - Text(level.displayName).tag(level) - } - } + safeModeRow if aiIsEnabled { Picker(String(localized: "AI Policy"), selection: $coordinator.advanced.aiPolicy) { Text(String(localized: "Use Default")) @@ -124,9 +120,26 @@ struct OptionsPaneView: View { } } + @ViewBuilder + private var safeModeRow: some View { + let levels = SafeModeFloor.levels(allowedBy: coordinator.safeModeFloor) + if levels.count == 1 { + LabeledContent(String(localized: "Safe Mode"), value: coordinator.effectiveSafeModeLevel.displayName) + } else { + Picker(String(localized: "Safe Mode"), selection: $coordinator.effectiveSafeModeLevel) { + ForEach(levels) { level in + Text(level.displayName).tag(level) + } + } + } + } + @ViewBuilder private var accessFooter: some View { - Group { + VStack(alignment: .leading, spacing: 4) { + if let floor = coordinator.safeModeFloor { + Text(floor.explanation) + } if aiIsEnabled { // swiftlint:disable:next line_length Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) diff --git a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift index e193fa4fc0..ddfca80491 100644 --- a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift +++ b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift @@ -58,7 +58,7 @@ struct ConnectionFormEdits: Equatable { result.cloudSQLProxyMode = cloudSQLProxyMode result.socksProxyMode = socksProxyMode result.tunnelCommandMode = tunnelCommandMode - result.safeModeLevel = safeModeLevel + result.preferredSafeModeLevel = safeModeLevel result.aiPolicy = aiPolicy result.aiRules = aiRules result.externalAccess = externalAccess diff --git a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift index fe0475022b..0c42eab4e4 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift @@ -21,6 +21,6 @@ final class CustomizationPaneViewModel { color = connection.color tagIds = connection.tagIds groupId = connection.groupId - safeModeLevel = connection.safeModeLevel + safeModeLevel = connection.preferredSafeModeLevel } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 5ecaf15729..bfe980b42f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -996,7 +996,8 @@ struct MainEditorContentView: View { tableRows: resolvedRows, displayRowCount: coordinator.displayIDs(forTab: tab.id)?.count, isFetching: isExecuting, - hasStructureActions: structureFooter.isActive + hasStructureActions: structureFooter.isActive, + paginationCapability: coordinator.paginationCapability ) return ResultStatusBar( model: ResultStatusModel( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift index 92f328a776..7beebdce34 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift @@ -6,6 +6,10 @@ import Foundation extension MainContentCoordinator { + var paginationCapability: PaginationCapability { + services.pluginManager.paginationCapability(for: connection.type) + } + func goToNextPage() { paginationCoordinator.goToNextPage() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 7ccff774b3..55110842cd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -77,6 +77,10 @@ extension MainContentCoordinator { queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType, bypassLimit: bypassLimit) } + func resolveStatement(sql: String, tabType: TabType, bypassLimit: Bool = false) -> LeadingRowsStatement { + queryExecutionCoordinator.resolveStatement(sql: sql, tabType: tabType, bypassLimit: bypassLimit) + } + func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { queryExecutionCoordinator.parseSchemaMetadata(schema) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index fcb3a1eccc..a578eba91b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -106,8 +106,10 @@ extension MainContentCoordinator { let sortWasConsumed = pendingSort.isEmpty || !resolvedSort.isEmpty // The persisted page index counts pages of the size it was taken in, so reading it in // today's default would land the tab on rows it was never showing. - let pageSize = tab.restoredPageSize ?? AppSettingsManager.shared.dataGrid.defaultPageSize - let page = max(1, tab.restoredPage ?? 1) + let pageSize = paginationCapability.clampedRowCount( + tab.restoredPageSize ?? AppSettingsManager.shared.dataGrid.defaultPageSize + ) + let page = paginationCapability.allowsSeeking ? max(1, tab.restoredPage ?? 1) : 1 tabManager.mutate(at: index) { tab in if sortWasConsumed { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 97bcfb03f2..8ff2fda18c 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -288,6 +288,10 @@ final class MainContentCommandActions { var isReadOnly: Bool { safeModeLevel.blocksAllWrites } + var canNavigatePages: Bool { + PluginManager.shared.paginationCapability(for: connection.type).allowsSeeking + } + var editorLanguage: EditorLanguage { PluginManager.shared.editorLanguage(for: connection.type) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b756fcb94e..4d5cf1eb98 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -124,8 +124,8 @@ final class MainContentCoordinator { } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } func setSafeModeLevel(_ level: SafeModeLevel) { - toolbarState.safeModeLevel = level - services.databaseManager.setSafeModeLevel(level, for: connectionId) + services.databaseManager.chooseSafeModeLevel(level, for: connectionId) + toolbarState.safeModeLevel = services.databaseManager.session(for: connectionId)?.safeModeLevel ?? level } let selectionState = GridSelectionState() let tabManager: QueryTabManager @@ -657,6 +657,7 @@ final class MainContentCoordinator { self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, + pagination: services.pluginManager.paginationCapability(for: connection.type), dialectQuote: dialect.map { quoteIdentifierFromDialect($0) } ) self.persistence = TabPersistenceCoordinator.forConnection(connection.id) @@ -1304,7 +1305,8 @@ final class MainContentCoordinator { let traceToken = adoptOrBeginExecutionTrace(tabId: tabId) traceExecutionStarted(traceToken, epoch: claim.epoch, isAutoLoad: isAutoLoad) - let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) + let statement = resolveStatement(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) + let rowCap = statement.rowCap let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) let needsMetadataFetch: Bool @@ -1365,7 +1367,7 @@ final class MainContentCoordinator { ) { [queryExecutor] driver in try await queryExecutor.executeQuery( driver: driver, - sql: sql, + sql: statement.sql, parameters: nil, rowCap: rowCap ) diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 24a2329aa4..35d02b1ad1 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -192,6 +192,8 @@ struct ResultStatusBar: View { pagination: snapshot.pagination, loadedRowCount: snapshot.rowCount, tabId: snapshot.tabId, + showsPageNavigation: model.controls.showsPageNavigation, + maximumPageSize: snapshot.paginationCapability.maximumRows, onFirst: paginationCallbacks.onFirst, onPrevious: paginationCallbacks.onPrevious, onNext: paginationCallbacks.onNext, diff --git a/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json new file mode 100644 index 0000000000..c26dbcdf85 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cloudflare-r2-sql.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg b/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg new file mode 100644 index 0000000000..37cdf584ab --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json new file mode 100644 index 0000000000..c26dbcdf85 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cloudflare-r2-sql.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg b/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg new file mode 100644 index 0000000000..37cdf584ab --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/TableProTests/Core/DataWrite/RewindPlannerTests.swift b/TableProTests/Core/DataWrite/RewindPlannerTests.swift index d929242de1..149dd63012 100644 --- a/TableProTests/Core/DataWrite/RewindPlannerTests.swift +++ b/TableProTests/Core/DataWrite/RewindPlannerTests.swift @@ -54,7 +54,7 @@ struct RewindPlannerTests { databaseType: .sqlite, pluginDriver: nil ), - queryBuilder: TableQueryBuilder(databaseType: .sqlite) + queryBuilder: TableQueryBuilder(databaseType: .sqlite, pagination: .offset) ) } @@ -114,7 +114,7 @@ struct RewindPlannerTests { tableName: target.table, schemaName: nil, columns: ["id", "name", "updated_at"], primaryKeyColumns: ["id"], databaseType: .sqlite, pluginDriver: nil ), - queryBuilder: TableQueryBuilder(databaseType: .sqlite) + queryBuilder: TableQueryBuilder(databaseType: .sqlite, pagination: .offset) ) let plan = try planner.plan(currentRows: [["7", "Grace", "2026-06-30"]]) @@ -191,7 +191,7 @@ struct RewindPlannerTests { tableName: target.table, schemaName: nil, columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .sqlite, pluginDriver: nil ), - queryBuilder: TableQueryBuilder(databaseType: .sqlite) + queryBuilder: TableQueryBuilder(databaseType: .sqlite, pagination: .offset) ) #expect(planner.readQueries().isEmpty) diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index 648d9ae2e0..9e034e309a 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -11,7 +11,7 @@ import Testing /// this registry, `docs/snippets/driver-counts.mdx`, and the marketing site. Nothing at runtime /// reconciles them, and by August 2026 they read 28, 27 and 25 at once. /// -/// The answer is 32, and the reason it once read 28 is worth keeping. Turso is served by +/// The answer is 33, and the reason it once read 28 is worth keeping. Turso is served by /// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its /// own, so it was the only type the picker could not offer before its plugin was installed. /// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated @@ -24,17 +24,17 @@ import Testing /// `docs/scripts/check-docs-against-source.py` reads the registry and holds the docs half. /// /// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. -/// Both answer 32 under XCTest, where no plugin bundle ever loads, but the registry is a +/// Both answer 33 under XCTest, where no plugin bundle ever loads, but the registry is a /// process-global singleton and suites that register a synthetic type run alongside this one. @MainActor @Suite("PluginMetadataRegistry engine count") struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ - "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "CockroachDB", - "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", - "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", - "ScyllaDB", "Snowflake", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", - "Trino", "Turso", "Typesense" + "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "Cloudflare R2 SQL", + "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", + "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", + "ScyllaDB", "Snowflake", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", "Trino", + "Turso", "Typesense" ] private static func builtInTypeIds() -> Set { @@ -43,10 +43,10 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 32 database types before any plugin loads") + @Test("The app ships 33 database types before any plugin loads") func builtInDefaultsCoverTwentyNineTypes() { let ids = Self.builtInTypeIds() - #expect(ids.count == 32) + #expect(ids.count == 33) #expect(ids == Self.expectedTypeIds) } diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 3da292e53d..d6351e6ad8 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -21,11 +21,28 @@ struct ColumnTypeClassifierTests { return false } + private func isJson(_ type: ColumnType) -> Bool { + if case .json = type { return true } + return false + } + private func isInteger(_ type: ColumnType) -> Bool { if case .integer = type { return true } return false } + // MARK: - Nested Types + + @Test("Nested container types classify as JSON", arguments: ["ARRAY", "MAP", "ROW", "STRUCT"]) + func nestedContainersAreJson(rawTypeName: String) { + #expect(isJson(classifier.classify(rawTypeName: rawTypeName))) + } + + @Test("struct classifies as JSON regardless of case") + func lowercaseStructIsJson() { + #expect(isJson(classifier.classify(rawTypeName: "struct"))) + } + private func isDecimal(_ type: ColumnType) -> Bool { if case .decimal = type { return true } return false diff --git a/TableProTests/Core/Services/LeadingRowsStatementTests.swift b/TableProTests/Core/Services/LeadingRowsStatementTests.swift new file mode 100644 index 0000000000..91db5b2347 --- /dev/null +++ b/TableProTests/Core/Services/LeadingRowsStatementTests.swift @@ -0,0 +1,109 @@ +// +// LeadingRowsStatementTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Leading-rows statements") +@MainActor +struct LeadingRowsStatementTests { + private let dialect = SqlDialect.from(databaseTypeId: DatabaseType.cloudflareR2SQL.rawValue) + + private func bound(_ sql: String, rowCap: Int?, style: AutoLimitStyle = .limit) -> LeadingRowsStatement { + LeadingRowsStatement.bound(sql, rowCap: rowCap, maximumRows: 10_000, autoLimitStyle: style, lexicalDialect: dialect) + } + + @Test("A capped read fetches one row past the cap, so a trimmed result is still detected") + func cappedRead() { + #expect(bound("SELECT * FROM logs.events", rowCap: 1_000) + == LeadingRowsStatement(sql: "SELECT * FROM logs.events\nLIMIT 1001", rowCap: 1_000)) + } + + @Test("An uncapped read asks for the engine's ceiling instead of taking its smaller default") + func uncappedRead() { + #expect(bound("SELECT * FROM logs.events", rowCap: nil) + == LeadingRowsStatement(sql: "SELECT * FROM logs.events\nLIMIT 10000", rowCap: nil)) + } + + @Test("A cap at or past the ceiling fetches the ceiling") + func capPastCeiling() { + #expect(bound("SELECT 1", rowCap: 50_000) == LeadingRowsStatement(sql: "SELECT 1\nLIMIT 10000", rowCap: 10_000)) + } + + @Test("A read the user already limited is sent as written") + func explicitLimit() { + #expect(bound("SELECT * FROM t LIMIT 5", rowCap: nil) == LeadingRowsStatement(sql: "SELECT * FROM t LIMIT 5", rowCap: nil)) + } + + @Test("A trailing semicolon is dropped and a trailing comment cannot swallow the clause") + func trailingPunctuation() { + #expect(bound("SELECT 1; ", rowCap: nil).sql == "SELECT 1\nLIMIT 10000") + #expect(bound("SELECT 1 -- all of it", rowCap: nil).sql == "SELECT 1 -- all of it\nLIMIT 10000") + } + + @Test("A FETCH FIRST dialect gets FETCH FIRST, and a TOP dialect is left alone") + func otherStyles() { + #expect(bound("SELECT 1", rowCap: nil, style: .fetchFirst).sql == "SELECT 1\nFETCH FIRST 10000 ROWS ONLY") + #expect(bound("SELECT 1", rowCap: nil, style: .top).sql == "SELECT 1") + } + + @Test("Only row-producing reads on a leading-rows engine are touched") + func resolveScope() { + #expect(LeadingRowsStatement.resolve("SHOW TABLES IN logs", rowCap: nil, databaseType: .cloudflareR2SQL).sql + == "SHOW TABLES IN logs") + #expect(LeadingRowsStatement.resolve("SELECT * FROM t", rowCap: nil, databaseType: .cloudflareR2SQL).sql + == "SELECT * FROM t\nLIMIT 10000") + #expect(LeadingRowsStatement.resolve("SELECT * FROM t", rowCap: 100, databaseType: .postgresql) + == LeadingRowsStatement(sql: "SELECT * FROM t", rowCap: 100)) + } + + @Test("A leading-rows engine is only counted on request") + func rowCountPlan() { + let unfiltered = QueryExecutionCoordinator.rowCountPlan( + isNonSQL: false, filterState: TabFilterState(), approximateRowCount: nil, threshold: 100_000, + countsAutomatically: false + ) + #expect(unfiltered == .skip) + } + + @Test("A browse on a leading-rows engine refuses an offset and clamps its limit") + func mcpBrowseLimit() throws { + let leadingRows = PaginationCapability.leadingRowsOnly(maximumRows: 10_000) + + #expect(try MCPConnectionBridge.browseLimit(for: browse(offset: 0, limit: 50_000), pagination: leadingRows) == 10_000) + #expect(throws: DatabaseAccessError.self) { + try MCPConnectionBridge.browseLimit(for: browse(offset: 100, limit: 100), pagination: leadingRows) + } + #expect(try MCPConnectionBridge.browseLimit(for: browse(offset: 100, limit: 100), pagination: .offset) == 100) + } + + @Test("An export from a leading-rows engine always states a limit no higher than the ceiling") + func exportRowLimit() { + let leadingRows = PaginationCapability.leadingRowsOnly(maximumRows: 10_000) + + #expect(ExportDataSourceAdapter.rowLimit(requested: nil, pagination: leadingRows) == 10_000) + #expect(ExportDataSourceAdapter.rowLimit(requested: 20_000, pagination: leadingRows) == 10_000) + #expect(ExportDataSourceAdapter.rowLimit(requested: 50, pagination: leadingRows) == 50) + #expect(ExportDataSourceAdapter.rowLimit(requested: nil, pagination: .offset) == nil) + } + + @Test("A query export that reached the engine's ceiling is named as partial") + func queryExportCapWarning() { + let leadingRows = PaginationCapability.leadingRowsOnly(maximumRows: 10_000) + + #expect(ExportService.leadingRowsCapWarning(exportedRows: 10_000, pagination: leadingRows) != nil) + #expect(ExportService.leadingRowsCapWarning(exportedRows: 9_999, pagination: leadingRows) == nil) + #expect(ExportService.leadingRowsCapWarning(exportedRows: 50_000, pagination: .offset) == nil) + } + + private func browse(offset: Int, limit: Int) -> MCPBrowseRequest { + MCPBrowseRequest( + table: "events", columns: nil, filters: [], logicMode: .and, sort: [], limit: limit, offset: offset + ) + } +} diff --git a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift index 1400117d5f..d2e9508f5a 100644 --- a/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderFilterTests.swift @@ -23,7 +23,7 @@ struct TableQueryBuilderFilteredQueryTests { likeEscapeStyle: .implicit, paginationStyle: .limit ) - private let builder = TableQueryBuilder(databaseType: .mysql, dialect: Self.mysqlDialect) + private let builder = TableQueryBuilder(databaseType: .mysql, dialect: Self.mysqlDialect, pagination: .offset) @Test("buildFilteredQuery with enabled filter produces WHERE clause") func filteredQueryWithEnabledFilter() { @@ -95,7 +95,7 @@ struct TableQueryBuilderFilteredCountTests { ) private var builder: TableQueryBuilder { - TableQueryBuilder(databaseType: .mysql, dialect: Self.mysqlDialect) + TableQueryBuilder(databaseType: .mysql, dialect: Self.mysqlDialect, pagination: .offset) } private func makeFilter(_ column: String, _ value: String, _ op: FilterOperator = .equal) -> TableFilter { @@ -140,7 +140,7 @@ struct TableQueryBuilderFilteredCountTests { @Test("buildFilteredCountQuery returns nil without a dialect") func filteredCountNilWithoutDialect() { - let noDialect = TableQueryBuilder(databaseType: .mysql) + let noDialect = TableQueryBuilder(databaseType: .mysql, pagination: .offset) #expect(noDialect.buildFilteredCountQuery(tableName: "users", filters: [makeFilter("name", "Alice")]) == nil) } } @@ -166,7 +166,7 @@ struct TableQueryBuilderPaginationTests { ) private func builder(_ dialect: SQLDialectDescriptor) -> TableQueryBuilder { - TableQueryBuilder(databaseType: .postgresql, dialect: dialect) + TableQueryBuilder(databaseType: .postgresql, dialect: dialect, pagination: .offset) } private func enabledFilter(_ column: String, _ value: String) -> TableFilter { @@ -217,7 +217,7 @@ struct TableQueryBuilderPaginationTests { @Suite("Table Query Builder - NoSQL Nil Dialect Fallback") struct TableQueryBuilderNoSQLTests { // MongoDB has no SQL dialect — should produce bare SELECT without WHERE - private let builder = TableQueryBuilder(databaseType: .mongodb) + private let builder = TableQueryBuilder(databaseType: .mongodb, pagination: .offset) @Test("NoSQL type produces no WHERE for filtered query") func noSqlFilteredQueryNoWhere() { diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index d902649afb..3bacae282b 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -23,6 +23,7 @@ struct TableQueryBuilderMSSQLTests { databaseType: .mssql, pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), dialect: dialect, + pagination: .offset, dialectQuote: dialectQuote ) } @@ -105,6 +106,7 @@ struct TableQueryBuilderMSSQLTests { databaseType: .mssql, pluginDriver: nil, dialect: dialect, + pagination: .offset, dialectQuote: dialect.map(quoteIdentifierFromDialect) ) let query = fallback.buildBaseQuery(tableName: "users") diff --git a/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift b/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift index f54e0a4684..cf00b24a97 100644 --- a/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderSortScopeTests.swift @@ -83,7 +83,7 @@ struct TableQueryBuilderSortScopeTests { private let displayColumns = ["_id", "name", "email", "createdAt"] private func makeBuilder(_ driver: SortRecordingDriver) -> TableQueryBuilder { - TableQueryBuilder(databaseType: .mongodb, pluginDriver: driver) + TableQueryBuilder(databaseType: .mongodb, pluginDriver: driver, pagination: .offset) } /// Issue #2234: after hiding columns, the sort index landed outside the scoped list and the @@ -188,7 +188,7 @@ struct TableQueryBuilderSortScopeTests { let sortState = SortState(columns: [ SortColumn(columnIndex: 99, direction: .descending, columnName: "createdAt"), ]) - let query = TableQueryBuilder(databaseType: .mysql).buildBaseQuery( + let query = TableQueryBuilder(databaseType: .mysql, pagination: .offset).buildBaseQuery( tableName: "events", sortState: sortState, columns: displayColumns, diff --git a/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift b/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift index b953a8a713..3db4ec0162 100644 --- a/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift +++ b/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift @@ -90,6 +90,16 @@ struct SyncRecordMapperConnectionTests { #expect(decoded.sortOrder == connection.sortOrder) } + @Test("A connection the engine holds at Read-Only syncs the user's own level") + func enforcedReadOnlyIsNotSynced() { + let connection = DatabaseConnection(name: "Iceberg", type: .cloudflareR2SQL, safeModeLevel: .alert) + + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + + #expect(record["safeModeLevel"] as? String == SafeModeLevel.alert.rawValue) + #expect(record["isReadOnly"] as? Int64 == 0) + } + @Test( "iOS safe mode wire values map to the nearest macOS level", arguments: [ diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierBeancountTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierBeancountTests.swift new file mode 100644 index 0000000000..8bb8a6cdd1 --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierBeancountTests.swift @@ -0,0 +1,38 @@ +// +// QueryClassifierBeancountTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("QueryClassifier on Beancount") +struct QueryClassifierBeancountTests { + @Test( + "Every statement a Beancount ledger answers is a read", + arguments: [ + "BQL: SELECT account, sum(position) GROUP BY account", + "bql BALANCES", + "BQL: JOURNAL 'Assets:Checking'", + "BQL: PRINT FROM year = 2026", + "SELECT * FROM transactions", + "PRAGMA table_info(transactions)", + "pragma database_list" + ] + ) + func readsAreSafe(sql: String) { + #expect(QueryClassifier.classifyTier(sql, databaseType: .beancount) == .safe) + } + + @Test("A write typed against a ledger is still a write, so Read-Only refuses it with its own message") + func writesStayWrites() { + #expect(QueryClassifier.classifyTier("DELETE FROM transactions", databaseType: .beancount) != .safe) + #expect(QueryClassifier.classifyTier("PRAGMA journal_mode = WAL", databaseType: .beancount) != .safe) + } + + @Test("The BQL prefix means nothing on another engine") + func prefixIsBeancountOnly() { + #expect(QueryClassifier.classifyTier("BQL: SELECT 1", databaseType: .sqlite) != .safe) + } +} diff --git a/TableProTests/Models/PaginationCapabilityTests.swift b/TableProTests/Models/PaginationCapabilityTests.swift new file mode 100644 index 0000000000..b9e926c467 --- /dev/null +++ b/TableProTests/Models/PaginationCapabilityTests.swift @@ -0,0 +1,129 @@ +// +// PaginationCapabilityTests.swift +// TableProTests +// + +import AppKit +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Pagination capability") +@MainActor +struct PaginationCapabilityTests { + private let leadingRows = PaginationCapability.leadingRowsOnly(maximumRows: 10_000) + + @Test("An offset engine seeks and caps nothing") + func offsetEngine() { + #expect(PaginationCapability.offset.allowsSeeking) + #expect(PaginationCapability.offset.maximumRows == nil) + #expect(PaginationCapability.offset.clampedRowCount(50_000) == 50_000) + } + + @Test("A leading-rows engine never seeks and clamps to its ceiling") + func leadingRowsEngine() { + #expect(!leadingRows.allowsSeeking) + #expect(leadingRows.maximumRows == 10_000) + #expect(leadingRows.clampedRowCount(50_000) == 10_000) + #expect(leadingRows.clampedRowCount(500) == 500) + } + + @Test("Cloudflare R2 SQL reads its capability from the catalog, and other engines keep offset paging") + func catalog() { + #expect(PaginationCapability.of(.cloudflareR2SQL) == .leadingRowsOnly(maximumRows: 10_000)) + #expect(PaginationCapability.of(.postgresql) == .offset) + } + + @Test("A leading-rows table query states a clamped LIMIT and never an OFFSET") + func builderNeverOffsets() { + let builder = TableQueryBuilder(databaseType: .cloudflareR2SQL, pagination: leadingRows) + let query = builder.buildBaseQuery(tableName: "events", schemaName: "logs", limit: 50_000, offset: 0) + + #expect(query == #"SELECT * FROM "logs"."events" LIMIT 10000"#) + #expect(!query.contains("OFFSET")) + } + + @Test("An offset table query keeps LIMIT and OFFSET") + func builderOffsets() { + let builder = TableQueryBuilder(databaseType: .postgresql, pagination: .offset) + let query = builder.buildBaseQuery(tableName: "events", limit: 100, offset: 200) + + #expect(query.hasSuffix("LIMIT 100 OFFSET 200")) + } + + private func snapshot(rowCount: Int, pageSize: Int, capability: PaginationCapability) -> StatusBarSnapshot { + StatusBarSnapshot( + tabId: UUID(), + tabType: .table, + hasRows: rowCount > 0, + hasColumns: true, + rowCount: rowCount, + hasTableName: true, + pagination: PaginationState(pageSize: pageSize), + statusMessage: nil, + paginationCapability: capability + ) + } + + @Test("A leading-rows table keeps the rows-per-page menu and drops page navigation") + func controls() { + let capped = ResultStatusModel( + snapshot: snapshot(rowCount: 500, pageSize: 500, capability: leadingRows), + viewMode: .data, + selectedRowCount: 0 + ) + let paged = ResultStatusModel( + snapshot: snapshot(rowCount: 500, pageSize: 500, capability: .offset), + viewMode: .data, + selectedRowCount: 0 + ) + + #expect(capped.controls.showsPagination && !capped.controls.showsPageNavigation) + #expect(paged.controls.showsPagination && paged.controls.showsPageNavigation) + } + + @Test("The readout says what loaded: a range of unknown total at the limit, a count below it") + func readout() { + let atLimit = ResultStatusModel( + snapshot: snapshot(rowCount: 500, pageSize: 500, capability: leadingRows), + viewMode: .data, + selectedRowCount: 0 + ) + let belowLimit = ResultStatusModel( + snapshot: snapshot(rowCount: 37, pageSize: 500, capability: leadingRows), + viewMode: .data, + selectedRowCount: 0 + ) + + #expect(atLimit.readout == .rangeOfUnknownTotal(start: 1, end: 500)) + #expect(belowLimit.readout == .rowCount(37)) + } + + @Test("Page-size presets stop at the engine's ceiling") + func presets() { + #expect(PaginationControlsView.pageSizePresets(upTo: nil) == [5, 10, 20, 100, 500, 1_000]) + #expect(PaginationControlsView.pageSizePresets(upTo: 100) == [5, 10, 20, 100]) + } + + @Test("Page commands are dimmed where the engine cannot skip rows") + func pageCommands() { + let selectors = [ + #selector(MainSplitViewController.goToFirstPage(_:)), + #selector(MainSplitViewController.goToPreviousPage(_:)), + #selector(MainSplitViewController.goToNextPage(_:)), + #selector(MainSplitViewController.goToLastPage(_:)) + ] + var context = MenuValidationContext() + context.isConnected = true + for selector in selectors { + #expect(!MainSplitViewController.isEnabled(selector, context: context)) + } + + context.canNavigatePages = true + for selector in selectors { + #expect(MainSplitViewController.isEnabled(selector, context: context)) + } + } +} diff --git a/TableProTests/Models/Query/QueryTabBaseQueryTests.swift b/TableProTests/Models/Query/QueryTabBaseQueryTests.swift index 91b947693b..34ece6ba3c 100644 --- a/TableProTests/Models/Query/QueryTabBaseQueryTests.swift +++ b/TableProTests/Models/Query/QueryTabBaseQueryTests.swift @@ -27,6 +27,7 @@ struct QueryTabBaseQueryTests { databaseType: .mssql, pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), dialect: dialect, + pagination: .offset, dialectQuote: quote ).buildBaseQuery(tableName: "users", schemaName: nil, limit: pageSize, offset: 0) diff --git a/TableProTests/Models/SafeModeFloorTests.swift b/TableProTests/Models/SafeModeFloorTests.swift new file mode 100644 index 0000000000..2f2b09c50d --- /dev/null +++ b/TableProTests/Models/SafeModeFloorTests.swift @@ -0,0 +1,190 @@ +// +// SafeModeFloorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Safe Mode floor") +@MainActor +struct SafeModeFloorTests { + private func remoteFileConnection(preferred: SafeModeLevel = .silent) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Remote", type: .sqlite, safeModeLevel: preferred) + connection.sshTunnelMode = .inline( + SSHConfiguration(enabled: true, host: "ssh.example.com", remoteFilePath: "/srv/app.db") + ) + return connection + } + + @Test("A read-only engine outranks a remote file, and both outrank the profile") + func resolveOrder() { + let engine = SafeModeFloor.resolve(isEngineReadOnly: true, opensRemoteDatabaseFile: true, managedMinimum: .alert) + let remote = SafeModeFloor.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: true, managedMinimum: .alert) + let managed = SafeModeFloor.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: false, managedMinimum: .alert) + + #expect(engine == SafeModeFloor(level: .readOnly, reason: .readOnlyEngine)) + #expect(remote == SafeModeFloor(level: .readOnly, reason: .remoteDatabaseFile)) + #expect(managed == SafeModeFloor(level: .alert, reason: .managedPolicy)) + } + + @Test("No condition and no profile, or a profile at Silent, leaves no floor", arguments: [nil, SafeModeLevel.silent]) + func noFloor(managedMinimum: SafeModeLevel?) { + #expect( + SafeModeFloor.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: false, managedMinimum: managedMinimum) + == nil + ) + } + + @Test("A floor allows its own level and every stricter one", arguments: SafeModeLevel.allCases) + func allowsStricterLevels(candidate: SafeModeLevel) { + let floor = SafeModeFloor(level: .safeMode, reason: .managedPolicy) + let stricter: Set = [.safeMode, .safeModeFull, .readOnly] + + #expect(floor.allows(candidate) == stricter.contains(candidate)) + #expect(floor.raising(candidate) == (stricter.contains(candidate) ? candidate : .safeMode)) + } + + @Test("The choosable levels are the ones at or above the floor") + func choosableLevels() { + #expect(SafeModeFloor.levels(allowedBy: nil) == SafeModeLevel.allCases) + #expect(SafeModeFloor.levels(allowedBy: SafeModeFloor(level: .readOnly, reason: .readOnlyEngine)) == [.readOnly]) + #expect( + SafeModeFloor.levels(allowedBy: SafeModeFloor(level: .alertFull, reason: .managedPolicy)) + == [.alertFull, .safeMode, .safeModeFull, .readOnly] + ) + } + + @Test("The profile's explanation names the level it requires") + func managedExplanationNamesLevel() { + let floor = SafeModeFloor(level: .safeModeFull, reason: .managedPolicy) + #expect(floor.explanation.contains(SafeModeLevel.safeModeFull.displayName)) + } + + @Test("A read-only engine reads as Read-Only and keeps the user's own level", arguments: [ + DatabaseType.cloudflareR2SQL, DatabaseType.beancount + ]) + func readOnlyEngine(type: DatabaseType) { + let connection = DatabaseConnection(name: "Engine", type: type, safeModeLevel: .alert) + + #expect(connection.safeModeFloor?.reason == .readOnlyEngine) + #expect(connection.safeModeLevel == .readOnly) + #expect(connection.preferredSafeModeLevel == .alert) + } + + @Test("An engine that takes writes reads as the user's own level") + func writableEngine() { + let connection = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .alert) + + #expect(connection.safeModeFloor == nil) + #expect(connection.safeModeLevel == .alert) + } + + @Test("A connection that opens a remote database file reads as Read-Only") + func remoteFile() { + let connection = remoteFileConnection() + + #expect(connection.safeModeFloor?.reason == .remoteDatabaseFile) + #expect(connection.safeModeLevel == .readOnly) + #expect(connection.preferredSafeModeLevel == .silent) + } + + @Test("Assigning the level sets the user's own choice") + func assignmentSetsPreference() { + var connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL) + connection.safeModeLevel = .safeMode + + #expect(connection.preferredSafeModeLevel == .safeMode) + #expect(connection.safeModeLevel == .readOnly) + } + + @Test("Encoding writes the user's own level, never the enforced one") + func codableRoundTrip() throws { + let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .silent) + + let data = try JSONEncoder().encode(connection) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + + #expect(object["safeModeLevel"] as? String == SafeModeLevel.silent.rawValue) + #expect(decoded.preferredSafeModeLevel == .silent) + #expect(decoded.safeModeLevel == .readOnly) + } + + @Test("The stored record carries the user's own level") + func persistenceCarriesPreference() { + let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .alert) + + #expect(StoredConnection(from: connection).safeModeLevel == SafeModeLevel.alert.rawValue) + } + + @Test("A session starts at the enforced level") + func sessionSeedsEnforcedLevel() { + #expect(ConnectionSession(connection: remoteFileConnection()).safeModeLevel == .readOnly) + let engine = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .silent) + #expect(ConnectionSession(connection: engine).safeModeLevel == .readOnly) + } + + @Test("Choosing a weaker level on an enforced session keeps it Read-Only") + func setSafeModeLevelKeepsEnforcement() { + let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .readOnly) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + DatabaseManager.shared.setSafeModeLevel(.silent, for: connection.id) + + let session = DatabaseManager.shared.session(for: connection.id) + #expect(session?.safeModeLevel == .readOnly) + #expect(session?.connection.safeModeLevel == .readOnly) + #expect(session?.connection.preferredSafeModeLevel == .silent) + } + + @Test("Picking the level already in force on a held connection leaves the saved level alone") + func chooseOnHeldConnectionKeepsPreference() { + let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .silent) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + DatabaseManager.shared.chooseSafeModeLevel(.readOnly, for: connection.id) + + let session = DatabaseManager.shared.session(for: connection.id) + #expect(session?.connection.preferredSafeModeLevel == .silent) + #expect(session?.safeModeLevel == .readOnly) + } + + @Test("Picking a level below the floor changes nothing") + func chooseBelowFloorIsIgnored() { + let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .alert) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + DatabaseManager.shared.chooseSafeModeLevel(.silent, for: connection.id) + + #expect(DatabaseManager.shared.session(for: connection.id)?.connection.preferredSafeModeLevel == .alert) + } + + @Test("Picking a level on an ordinary connection applies it") + func chooseOnOrdinaryConnectionApplies() { + let connection = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .silent) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + DatabaseManager.shared.chooseSafeModeLevel(.safeMode, for: connection.id) + + #expect(DatabaseManager.shared.session(for: connection.id)?.safeModeLevel == .safeMode) + } + + @Test("Choosing a level on an ordinary session applies it") + func setSafeModeLevelOnWritableEngine() { + let connection = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .silent) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + DatabaseManager.shared.setSafeModeLevel(.alert, for: connection.id) + + #expect(DatabaseManager.shared.session(for: connection.id)?.safeModeLevel == .alert) + } +} diff --git a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift new file mode 100644 index 0000000000..9739be1d50 --- /dev/null +++ b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift @@ -0,0 +1,78 @@ +// +// CloudflareR2SQLMetadataParityTests.swift +// TableProTests +// +// The app shows Cloudflare R2 SQL in the database picker, form and editor before its registry +// plugin is installed, from a curated copy of the plugin's metadata. `CloudflareR2SQLMetadata` is +// the plugin's own file, compiled into this target, so the two copies are compared here instead of +// drifting apart until the plugin loads and silently replaces one with the other. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Cloudflare R2 SQL curated metadata parity") +struct CloudflareR2SQLMetadataParityTests { + private func curated() throws -> PluginMetadataSnapshot { + try #require( + PluginMetadataRegistry.shared.builtInDefaults().first { $0.typeId == "Cloudflare R2 SQL" }?.snapshot + ) + } + + @Test("Identity and schema vocabulary match the plugin") + func identity() throws { + let snapshot = try curated() + + #expect(snapshot.displayName == CloudflareR2SQLMetadata.displayName) + #expect(snapshot.iconName == CloudflareR2SQLMetadata.iconName) + #expect(snapshot.brandColorHex == CloudflareR2SQLMetadata.brandColorHex) + #expect(snapshot.schema.defaultSchemaName == CloudflareR2SQLMetadata.defaultSchemaName) + #expect(snapshot.schema.schemaEntityName == CloudflareR2SQLMetadata.schemaEntityName) + #expect(snapshot.schema.containerEntityName == CloudflareR2SQLMetadata.containerEntityName) + #expect(snapshot.schema.structureColumnFields == CloudflareR2SQLMetadata.structureColumnFields) + } + + @Test("Editor metadata matches the plugin") + func editor() throws { + let snapshot = try curated() + let dialect = try #require(snapshot.editor.sqlDialect) + let shipped = CloudflareR2SQLMetadata.dialect + + #expect(dialect.identifierQuote == shipped.identifierQuote) + #expect(dialect.keywords == shipped.keywords) + #expect(dialect.functions == shipped.functions) + #expect(dialect.dataTypes == shipped.dataTypes) + #expect(dialect.paginationStyle == shipped.paginationStyle) + #expect(dialect.booleanLiteralStyle == shipped.booleanLiteralStyle) + #expect(dialect.likeEscapeStyle == shipped.likeEscapeStyle) + #expect(dialect.regexSyntax == shipped.regexSyntax) + #expect(snapshot.editor.columnTypesByCategory == CloudflareR2SQLMetadata.columnTypesByCategory) + #expect(snapshot.editor.statementCompletions.map { [$0.label, $0.insertText] } + == CloudflareR2SQLMetadata.statementCompletions.map { [$0.label, $0.insertText] }) + #expect(snapshot.explainVariants.map { [$0.id, $0.label, $0.sqlPrefix] } + == CloudflareR2SQLMetadata.explainVariants.map { [$0.id, $0.label, $0.sqlPrefix] }) + } + + @Test("Connection fields match the plugin") + func connectionFields() throws { + let fields = try curated().connection.additionalConnectionFields + let shipped = CloudflareR2SQLMetadata.connectionFields + + #expect(fields.map(\.id) == shipped.map(\.id)) + #expect(fields.map(\.label) == shipped.map(\.label)) + #expect(fields.map(\.placeholder) == shipped.map(\.placeholder)) + #expect(fields.map(\.isRequired) == shipped.map(\.isRequired)) + #expect(fields.map(\.section) == shipped.map(\.section)) + } + + @Test("The app-only capabilities describe a read-only engine that cannot skip rows") + func appOnlyCapabilities() throws { + let capabilities = try curated().capabilities + + #expect(capabilities.isEngineReadOnly) + #expect(capabilities.pagination == .leadingRowsOnly(maximumRows: CloudflareR2SQLMetadata.maximumRows)) + } +} diff --git a/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift b/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift index bec9073bbe..b74957a91d 100644 --- a/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift +++ b/TableProTests/ViewModels/ConnectionFormEditsCoverageTests.swift @@ -33,7 +33,7 @@ struct ConnectionFormEditsCoverageTests { "cloudSQLProxyMode", "socksProxyMode", "tunnelCommandMode", - "safeModeLevel", + "preferredSafeModeLevel", "aiPolicy", "aiRules", "externalAccess", @@ -125,7 +125,7 @@ struct ConnectionFormEditsCoverageTests { cloudSQLProxyMode: original.cloudSQLProxyMode, socksProxyMode: original.socksProxyMode, tunnelCommandMode: original.tunnelCommandMode, - safeModeLevel: original.safeModeLevel, + safeModeLevel: original.preferredSafeModeLevel, aiPolicy: original.aiPolicy, aiRules: original.aiRules, externalAccess: original.externalAccess, diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index 0ce00f1e2f..632a697c86 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -135,6 +135,7 @@ Metadata connections, the extra ones TablePro opens to read a database's object | [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | No | No | | [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | No | No | | [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | No | +| [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) | Cloud API | No | No | No | No | No | No | | [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | No | SQLite is the one driver offering **Remote Database File**; it reaches an SSH server without forwarding a port. A driver with no SSL/TLS column is either a local file or an HTTPS API that manages its own encryption. [SSL/TLS](/connections/ssl) has the per-driver defaults. diff --git a/docs/connections/ssh-tunneling.mdx b/docs/connections/ssh-tunneling.mdx index 3ebf3b5dd0..887e7e766e 100644 --- a/docs/connections/ssh-tunneling.mdx +++ b/docs/connections/ssh-tunneling.mdx @@ -37,7 +37,7 @@ To share one SSH config across connections, save it with **Save Current as Profi Network section with SSH Tunnel selected and a saved profile in the Profile picker -**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, DynamoDB, Elasticsearch, Typesense, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. +**SSH Tunnel** is not offered on SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, Elasticsearch, Typesense, or Snowflake: each is reached over a local file, a loopback socket, or a vendor HTTP API. ## Authentication methods diff --git a/docs/connections/ssl.mdx b/docs/connections/ssl.mdx index 89d450e22b..e5c5ac9cbb 100644 --- a/docs/connections/ssl.mdx +++ b/docs/connections/ssl.mdx @@ -47,7 +47,7 @@ A new connection starts on the mode that matches the driver's own default, and t | etcd | Disabled | Nothing. The driver never reads these fields. Set **TLS Mode** on the Options section instead, and see [etcd](/databases/etcd) | | Trino | Disabled | Sends every request over HTTPS with no fallback, again like Required | | Oracle | Disabled | Connects in plain TCP, so it behaves like Disabled. A red warning appears under the picker; use Required to enforce TCPS | -| Snowflake, BigQuery, DynamoDB, Cloudflare D1, libSQL / Turso | Always encrypted | These drivers are HTTPS and manage TLS themselves. No SSL/TLS section | +| Snowflake, BigQuery, DynamoDB, Cloudflare D1, Cloudflare R2 SQL, libSQL / Turso | Always encrypted | These drivers are HTTPS and manage TLS themselves. No SSL/TLS section | | SQLite, DuckDB, Beancount, PGlite | None | Local files or an in-process engine. No SSL/TLS section | ## Behind a tunnel diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 224de31c9f..4e88b06e40 100644 --- a/docs/databases/beancount.mdx +++ b/docs/databases/beancount.mdx @@ -147,7 +147,7 @@ Table browsing, row counts, and pagination work on a BQL result. SQL parameters ## Limitations -- No writes. INSERT, UPDATE, DELETE, and every form of schema editing are rejected. Edit the ledger in a text editor; the next query picks the change up. +- No writes. The connection runs at [Safe Mode Read-Only](/features/safe-mode#connections-that-are-always-read-only), and INSERT, UPDATE, DELETE, and every form of schema editing are rejected. Edit the ledger in a text editor; the next query picks the change up. - No import, SSH, SSL, or ledger switching. One connection is one ledger file. - BQL needs `rledger` even when the ledger opened on the Python backend. The query is refused. Install `rledger`, or drop the `BQL:` prefix and query the projected tables. - Directives outside those tables are not projected. They stay in the source files. diff --git a/docs/databases/cloudflare-r2-sql.mdx b/docs/databases/cloudflare-r2-sql.mdx new file mode 100644 index 0000000000..ac70117632 --- /dev/null +++ b/docs/databases/cloudflare-r2-sql.mdx @@ -0,0 +1,108 @@ +--- +title: Cloudflare R2 SQL +description: Query Apache Iceberg tables in an R2 bucket with Cloudflare's read-only SQL engine +--- + +import RegistryPlugin from "/snippets/registry-plugin.mdx"; + +One connection reads one R2 bucket that has R2 Data Catalog turned on. Queries travel as HTTPS requests to `api.sql.cloudflarestorage.com`, R2 SQL runs them against the bucket's Iceberg tables, and it bills by the bytes each query scans. + + + +## Connection settings + +| Field | Required | Description | +|-------|----------|-------------| +| **Account ID** | Yes | Your Cloudflare account ID | +| **Bucket** | Yes | The R2 bucket whose catalog holds the tables | +| **API Token** | Yes | Token with R2 SQL, R2 Data Catalog and R2 Storage access, entered in the password field (labeled **API Token** here). Stored in the macOS Keychain | + +There is no host, port, Database field, SSH tunnel or SSL/TLS section: the endpoint is fixed and always HTTPS, and the warehouse name comes from the account ID and bucket. Click **Test Connection**, then **Save & Connect**. + +## Connection URL + +R2 SQL has no connection URL. Fill in the form instead. + +## Getting your credentials + + + + In the [Cloudflare dashboard](https://dash.cloudflare.com), open the bucket under **R2 Object Storage**, then **Settings > R2 Data Catalog**, and enable it. A bucket without the catalog has no tables to query. + + + It is in the dashboard's right sidebar, or run `npx wrangler whoami`. + + + Create an [R2 API token](https://developers.cloudflare.com/r2/api/tokens/) with R2 SQL read, R2 Data Catalog read, and R2 Storage access. R2 SQL needs all three: the catalog for the table list, storage for the data files, and SQL to run the query. + + + + +The token reaches every bucket its permissions cover, not only the one this connection names. + + +## Namespaces and tables + +Iceberg groups tables into namespaces, and each namespace is a schema here: the sidebar lists the bucket's namespaces with their tables inside, and the toolbar switcher reads **Namespace**. The list comes from `SHOW NAMESPACES` and `SHOW TABLES IN`, and a table's columns from `DESCRIBE`. None of the three scans data. A column's Iceberg `doc` shows as its comment in the Structure tab. + +Name the namespace in a query tab: + +```sql +SELECT user_id, event, ts +FROM logs.events +WHERE ts >= '2026-01-01T00:00:00Z' +ORDER BY ts DESC +LIMIT 1000 +``` + +R2 SQL converts nothing implicitly: quote strings, leave numbers bare, and give timestamps a time zone. + +## Read-only connections + +R2 SQL runs `SELECT`, `SHOW`, `DESCRIBE` and `EXPLAIN`, and rejects every write. The connection runs at Safe Mode **Read-Only** whatever level it was given, so cell editing, row insert and delete, and import are off, and the Structure tab only reads. Export works. See [Safe Mode](/features/safe-mode#connections-that-are-always-read-only). + +## Pagination + +R2 SQL cannot skip rows and returns at most 10,000 from one query. A table tab shows its leading rows: the rows-per-page menu stops at 10,000, the page buttons are gone, and the status bar reads `Rows 1-500` until **Count Exactly** fills in the total. Filter and sort to decide which rows load, because both run in the query. See [Pages and row counts](/features/data-grid#pages-and-row-counts). + +Past the first 10,000, page by key in a query tab, carrying the last value of the previous page forward: + +```sql +SELECT * FROM logs.events +WHERE event_id > '01HQ7Z2K3M4N5P6Q7R8S9T0V' +ORDER BY event_id +LIMIT 1000 +``` + +A query with no `LIMIT` would stop at R2 SQL's own default of 500 rows, so every read you did not limit is sent with one: the [row cap](/customization/data-settings) plus one row, or 10,000 for **Fetch All** and exports. + +## Cost + +Each query is billed on the bytes it scans, with a minimum per query, and a table tab's automatic row count would be one more scan every time the tab loaded. That count only runs when you click **Count Exactly**. `SHOW`, `DESCRIBE` and `EXPLAIN` scan nothing. [R2 SQL pricing](https://developers.cloudflare.com/r2-sql/platform/pricing/) has the current rates. + +## Limitations + +- No writes, DDL, or transactions. Load tables through an Iceberg writer such as Spark, PyIceberg, or R2 Pipelines. +- 10,000 rows per query. A table export or copy stops there and names each table it cut short; export a filtered or keyed subset for the rest. +- `OFFSET` is rejected. Page by key, as in [Pagination](#pagination). +- Two output columns with the same name come back as one. Alias them in the query. +- No primary keys, foreign keys, or indexes, so rows cannot be edited and the ER diagram has no relationships. +- One bucket per connection. Add a connection for each bucket. + +## Troubleshooting + +### `Authentication error` + +The token is missing a permission or belongs to another account. Check it carries R2 SQL, R2 Data Catalog and R2 Storage access, and that **Account ID** is the account that owns the bucket. + +### No namespaces after connecting + +The bucket has R2 Data Catalog turned off, or the catalog holds no tables yet. Turn the catalog on in the bucket's settings, then refresh the sidebar. + +### `SHOW TABLES returned columns TablePro does not recognize: …` + +R2 SQL answered a catalog statement in a shape this version of the driver cannot read. Update the plugin from **Settings > Plugins**. + +### `unsupported feature: OFFSET clause is not supported` + +A query tab query uses `OFFSET`. Rewrite it with keyset paging, as in [Pagination](#pagination). diff --git a/docs/databases/index.mdx b/docs/databases/index.mdx index 4d8e001808..7409b01262 100644 --- a/docs/databases/index.mdx +++ b/docs/databases/index.mdx @@ -1,11 +1,11 @@ --- title: Supported Databases -description: All 32 engines TablePro connects to, their default ports, and which ones need a plugin +description: All 33 engines TablePro connects to, their default ports, and which ones need a plugin --- import DriverCounts from "/snippets/driver-counts.mdx"; -Thirty-two engines, and every one of them is free to use. What differs between them is where the +Thirty-three engines, and every one of them is free to use. What differs between them is where the driver comes from, not what the license covers. @@ -20,6 +20,7 @@ driver comes from, not what the license covers. | [Cassandra](/databases/cassandra) | 9042 | Plugin | | [ClickHouse](/databases/clickhouse) | 8123 | Built-in | | [Cloudflare D1](/databases/cloudflare-d1) | API-based | Plugin | +| [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) | API-based | Plugin | | [CockroachDB](/databases/cockroachdb) | 26257 | Built-in | | [Dameng DM8](/databases/dameng) | 5236 | Plugin | | [Databend](/databases/databend) | 3307 | Built-in | diff --git a/docs/databases/sqlite.mdx b/docs/databases/sqlite.mdx index 80329909d5..cc2ede636a 100644 --- a/docs/databases/sqlite.mdx +++ b/docs/databases/sqlite.mdx @@ -53,7 +53,7 @@ One connection is one file, with no database to switch between, and the object l ## A database on another machine -The **Remote File** pane points the connection at a database on an SSH server. It is fetched over SFTP and opened read-only from a copy on this Mac, and the original is never written to. +The **Remote File** pane points the connection at a database on an SSH server. It is fetched over SFTP and opened from a copy on this Mac, and the original is never written to. The connection runs at Safe Mode [**Read-Only**](/features/safe-mode#connections-that-are-always-read-only), so the grid and the editor refuse edits that would only change the copy. Where the server has `sqlite3` 3.27 or newer, the copy is a `VACUUM INTO` snapshot, which stays consistent even while other programs write to the database. See [Remote Database Files](/connections/remote-database-files). diff --git a/docs/docs.json b/docs/docs.json index be6ac25618..b1749b0034 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -150,6 +150,7 @@ "databases/cassandra", "databases/clickhouse", "databases/cloudflare-d1", + "databases/cloudflare-r2-sql", "databases/cockroachdb", "databases/dameng", "databases/databend", diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index db878aa11c..2b78be43f6 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -101,6 +101,8 @@ A filter is `{ column, operator, value, second_value, case_sensitive }`. `column A sort entry is `{ column, direction }`, `direction` being `ascending` (default) or `descending`, and entries apply in the order given. `offset` defaults to 0. +On an engine that cannot skip rows, such as Cloudflare R2 SQL, a nonzero `offset` is refused as an invalid argument and `limit` is lowered to the engine's maximum. The result echoes the `limit` actually used and sets `is_truncated` when the rows reached it. + Sorting on a column left out of `columns` appends it to the result. The columns you asked for keep the positions you asked for, and the sort column follows them, because some drivers can only order by a column they select. ### `count_rows` diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 737e95d86a..dd8110ceae 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -115,6 +115,8 @@ A large table shows an estimated total prefixed with `~` instead of running a sl A query tab does not page. It stops at the [row cap](/customization/data-settings) instead and offers **Fetch All** to load the rest, and a query carrying its own `LIMIT`, `FETCH FIRST`, or `TOP` is never capped. `Cmd+.` cancels a running query or a Fetch All. +Some engines cannot skip rows and return a fixed maximum from one query. On those, a table tab shows its leading rows only: First, Previous, Next, Last and **All rows…** are gone, the rows-per-page menu stops at the engine's maximum, and the total is counted only when you click **Count Exactly**. Filter or sort to decide which rows load. [Cloudflare R2 SQL](/databases/cloudflare-r2-sql#pagination) works this way. + ## Copying Click a cell to select it, drag or `Shift`-click for a range, and click a row number for a whole row. The row-number gutter stays at the left edge on a table wider than the window, so whole rows are still selectable when the columns have scrolled past it. `Shift+Space` widens whatever is selected to every row it touches. Copy acts on the whole selection. diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 72ba09cf0f..243d6cea08 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -143,6 +143,7 @@ Properties the driver reported as false or zero are left out of **Details**. | Snowflake | Explain (Text) | Raw text only | | Trino | Logical, Distributed, IO, Validate, Analyze | Raw text only | | SurrealDB | Explain, Explain Full | Raw text only | +| Cloudflare R2 SQL | Explain, Explain (JSON) | Raw text only | | BigQuery | Dry Run (Cost) | A dry run cost estimate, no plan | | MongoDB | Explain | The `explain` runCommand with execution stats | | Redis | Explain | `DEBUG OBJECT` for the command's key | diff --git a/docs/features/plugins.mdx b/docs/features/plugins.mdx index e8494e653d..f52fb1215d 100644 --- a/docs/features/plugins.mdx +++ b/docs/features/plugins.mdx @@ -38,6 +38,7 @@ These install from the registry when you need them: | Cassandra | Cassandra, ScyllaDB | | Etcd | etcd | | Cloudflare D1 | Cloudflare D1 | +| Cloudflare R2 SQL | Cloudflare R2 SQL | | DynamoDB | DynamoDB | | BigQuery | BigQuery | | Snowflake | Snowflake | diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 1877486b44..1f7d83de40 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -20,6 +20,16 @@ New connections start at **Silent**, which is the right choice for a local datab Four things the table cannot carry. The confirmation dialog previews the SQL it is about to run. Touch ID falls back to your macOS password on a Mac without it. **Silent** is not a free pass: `DROP`, `TRUNCATE`, and a `DELETE` with no `WHERE` still raise the built-in dangerous query warning even there. And **Read-Only** goes past queries to the interface itself, disabling inline cell editing, adding, deleting and duplicating rows, table truncate and drop, and import. +## Connections that are always read-only + +A connection that cannot take a write runs at **Read-Only** whatever level it was given. Its edit form shows the level as fixed text, and every other level is dimmed in the toolbar padlock and in **Database > Safe Mode Level**. The level you chose stays saved and applies again once the condition no longer holds. + +| Connection | Condition | +|------------|-----------| +| [Beancount](/databases/beancount) | The ledger is a projection of text files, and nothing writes it back | +| [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) | The engine runs read queries only | +| [SQLite](/databases/sqlite) with **Remote File** | The file is a copy fetched over SFTP, and nothing writes it back | + ## What the level gates Safe Mode sits in front of query execution, saving cell edits, structure and table changes, sidebar operations, imports, and maintenance jobs, including the ones the [AI assistant](/features/ai-assistant) and the MCP tools ask for. @@ -78,7 +88,7 @@ An administrator can impose a minimum level through a macOS configuration profil |---|---|---| | `com.TablePro.policy.minimumSafeModeLevel` | String | `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, or `readOnly` | -A connection set below the floor is raised to it, and a stricter choice is left alone: the policy is a floor, never a ceiling. A value TablePro does not recognize imposes no floor at all. While the policy is in force the matching control appears dimmed. +A connection set below the floor runs at it, and a stricter choice is left alone: the policy is a floor, never a ceiling. A value TablePro does not recognize imposes no floor at all. While the policy is in force, the levels below the floor are dimmed in the toolbar padlock and in **Database > Safe Mode Level**, and the connection form lists only the levels at or above it. The level you chose stays saved and applies again once the profile is removed. This is a floor on TablePro's own behavior, not on the database. It stops the app issuing a write; it does not stop the same person connecting with `psql`. Pair it with server-side privileges for anything that has to hold. diff --git a/docs/index.mdx b/docs/index.mdx index 311fb3b266..5850dc6615 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 27 more +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 28 more --- import DriverCounts from "/snippets/driver-counts.mdx"; diff --git a/docs/snippets/driver-counts.mdx b/docs/snippets/driver-counts.mdx index f76ccaf0b7..f0dce480d6 100644 --- a/docs/snippets/driver-counts.mdx +++ b/docs/snippets/driver-counts.mdx @@ -1,3 +1,3 @@ -Five drivers ship inside the app and cover eleven databases. Nineteen registry plugins cover the -other twenty-one and install on the first connection that needs one. See +Five drivers ship inside the app and cover eleven databases. Twenty registry plugins cover the +other twenty-two and install on the first connection that needs one. See [Plugins & Themes](/features/plugins). diff --git a/project.yml b/project.yml index 8e4f9cde9f..5f0887862e 100644 --- a/project.yml +++ b/project.yml @@ -407,6 +407,7 @@ targets: - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift - Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift + - Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift - Plugins/CassandraDriverPlugin/CassandraIndexStatements.swift - Plugins/DamengDriverPlugin/DamengParameterBinder.swift - Plugins/DamengDriverPlugin/DamengIndexStatements.swift @@ -960,6 +961,15 @@ targets: folder: CloudflareD1DriverPlugin principalClass: CloudflareD1Plugin + CloudflareR2SQLDriverPlugin: + templates: [DriverPlugin] + templateAttributes: + folder: CloudflareR2SQLDriverPlugin + principalClass: CloudflareR2SQLPlugin + dependencies: + - package: TableProCore + product: TableProR2SQLCore + DuckDBDriver: templates: [DriverPlugin] templateAttributes: @@ -1218,6 +1228,7 @@ aggregateTargets: - CassandraDriver - ClickHouseDriver - CloudflareD1DriverPlugin + - CloudflareR2SQLDriverPlugin - DamengDriver - DuckDBDriver - DynamoDBDriverPlugin