From b4f52b23f4f0bd439e02bde2d66f3aeed4aa62c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 6 Aug 2026 23:38:25 +0700 Subject: [PATCH 1/7] feat(plugins): add a Cloudflare R2 SQL driver (#3885) Claude-Session: https://claude.ai/code/session_01NtGEvGSCkym8Kb24YeXFez --- .github/workflows/build-plugin.yml | 5 + CHANGELOG.md | 4 + Packages/TableProCore/Package.swift | 13 +- .../TableProCoreTypes/DatabaseType.swift | 4 +- .../R2SQLConnectionConfig.swift | 63 +++++++ .../TableProR2SQLCore/R2SQLError.swift | 61 ++++++ .../R2SQLErrorClassifier.swift | 62 +++++++ .../R2SQLIntrospectionSQL.swift | 15 ++ .../TableProR2SQLCore/R2SQLJSONValue.swift | 135 ++++++++++++++ .../TableProR2SQLCore/R2SQLLimits.swift | 11 ++ .../TableProR2SQLCore/R2SQLLiteral.swift | 29 +++ .../TableProR2SQLCore/R2SQLQueryBuilder.swift | 169 +++++++++++++++++ .../R2SQLRequestBuilder.swift | 26 +++ .../TableProR2SQLCore/R2SQLRowMapper.swift | 41 +++++ .../TableProR2SQLCore/R2SQLTransport.swift | 29 +++ .../TableProR2SQLCore/R2SQLTypeMapper.swift | 73 ++++++++ .../TableProR2SQLCore/R2SQLValue.swift | 7 + .../TableProR2SQLCore/R2SQLWireTypes.swift | 112 ++++++++++++ .../DatabaseTypeTests.swift | 10 +- .../R2SQLEnvelopeDecodingTests.swift | 120 ++++++++++++ .../R2SQLIntrospectionSQLTests.swift | 44 +++++ .../R2SQLJSONValueTests.swift | 59 ++++++ .../R2SQLQueryBuilderTests.swift | 173 ++++++++++++++++++ .../R2SQLRequestBuilderTests.swift | 84 +++++++++ .../R2SQLRowMapperTests.swift | 75 ++++++++ .../R2SQLTypeMapperTests.swift | 78 ++++++++ .../CloudflareR2SQLPlugin.swift | 124 +++++++++++++ .../CloudflareR2SQLPluginDriver+Query.swift | 162 ++++++++++++++++ .../CloudflareR2SQLPluginDriver+Schema.swift | 97 ++++++++++ .../CloudflareR2SQLPluginDriver.swift | 117 ++++++++++++ .../CloudflareR2SQLDriverPlugin/Info.plist | 12 ++ .../R2SQLURLSessionTransport.swift | 63 +++++++ TablePro.xcodeproj/project.pbxproj | 131 +++++++++++++ .../Contents.json | 16 ++ .../cloudflare-r2-sql.svg | 13 ++ .../Coordinators/PaginationCoordinator.swift | 5 + .../Plugins/PluginManager+Registration.swift | 10 + ...PluginMetadataRegistry+R2SQLDefaults.swift | 120 ++++++++++++ ...ginMetadataRegistry+RegistryDefaults.swift | 1 + .../Core/Plugins/PluginMetadataRegistry.swift | 6 +- .../Core/Services/ColumnTypeClassifier.swift | 2 +- .../Execution/DefaultExecutionGate.swift | 6 +- .../Execution/ExecutionGateProvider.swift | 5 +- .../Services/Query/TableQueryBuilder.swift | 6 + TablePro/Models/Query/QueryTab.swift | 1 + TablePro/Models/Query/StatusBarSnapshot.swift | 11 +- .../Components/PaginationControlsView.swift | 26 ++- .../ConnectionFormCoordinator.swift | 3 + .../Panes/CustomizationPaneView.swift | 10 + .../Main/Child/MainEditorContentView.swift | 6 +- .../Views/Main/Child/MainStatusBarView.swift | 1 + ...ainContentCoordinator+TableFirstLoad.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 4 + TablePro/Views/Sidebar/SidebarView.swift | 7 +- .../Contents.json | 16 ++ .../cloudflare-r2-sql.svg | 13 ++ .../Contents.json | 16 ++ .../cloudflare-r2-sql.svg | 13 ++ .../Services/ColumnTypeClassifierTests.swift | 17 ++ .../Execution/ExecutionGateTests.swift | 2 +- docs/customization/settings.mdx | 2 +- docs/databases/cloudflare-r2-sql.mdx | 114 ++++++++++++ docs/databases/overview.mdx | 3 +- docs/databases/ssh-tunneling.mdx | 2 +- docs/development/architecture.mdx | 1 + docs/development/plugin-registry.mdx | 1 + docs/docs.json | 3 +- docs/features/er-diagram.mdx | 2 +- docs/features/explain-visualization.mdx | 1 + docs/features/plugins.mdx | 3 +- docs/features/safe-mode.mdx | 2 + docs/features/ssl.mdx | 2 +- docs/index.mdx | 7 +- scripts/release-all-plugins.sh | 1 + 74 files changed, 2663 insertions(+), 27 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLError.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLErrorClassifier.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLValue.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/Info.plist create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift create mode 100644 TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg create mode 100644 TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/cloudflare-r2-sql-icon.imageset/cloudflare-r2-sql.svg create mode 100644 docs/databases/cloudflare-r2-sql.mdx diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 802a3c84ad..e80dba872f 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -203,6 +203,11 @@ jobs: DISPLAY_NAME="Cloudflare D1 Driver"; SUMMARY="Cloudflare D1 serverless SQLite-compatible database driver via REST API" DB_TYPE_IDS='["Cloudflare D1"]'; ICON="cloudflare-d1-icon"; BUNDLE_NAME="CloudflareD1DriverPlugin" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-d1" ;; + cloudflare-r2-sql) + TARGET="CloudflareR2SQLDriverPlugin"; BUNDLE_ID="com.TablePro.CloudflareR2SQLDriverPlugin" + DISPLAY_NAME="Cloudflare R2 SQL Driver"; SUMMARY="Read-only Cloudflare R2 SQL driver for Iceberg tables in R2 Data Catalog via REST API" + DB_TYPE_IDS='["Cloudflare R2 SQL"]'; ICON="cloudflare-r2-sql-icon"; BUNDLE_NAME="CloudflareR2SQLDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-r2-sql" ;; libsql) TARGET="LibSQLDriverPlugin"; BUNDLE_ID="com.TablePro.LibSQLDriverPlugin" DISPLAY_NAME="libSQL / Turso Driver"; SUMMARY="libSQL and Turso database support via Hrana HTTP protocol" diff --git a/CHANGELOG.md b/CHANGELOG.md index b741424112..db461806e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Cloudflare R2 SQL support as a downloadable, read-only driver. Connect with an account ID, a bucket, and an API token, browse Iceberg namespaces and tables, and run SELECT queries against them. (#3885) + ## [0.63.0] - 2026-08-05 ### Added diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 9325ef5b8d..30136e8e4d 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -20,7 +20,8 @@ let package = Package( .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]), .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]), .library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]), - .library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]) + .library(name: "TableProTrinoCore", targets: ["TableProTrinoCore"]), + .library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]) ], targets: [ .target( @@ -84,6 +85,11 @@ let package = Package( dependencies: [], path: "Sources/TableProTrinoCore" ), + .target( + name: "TableProR2SQLCore", + dependencies: [], + path: "Sources/TableProR2SQLCore" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], @@ -124,6 +130,11 @@ let package = Package( dependencies: ["TableProTrinoCore"], path: "Tests/TableProTrinoCoreTests" ), + .testTarget( + name: "TableProR2SQLCoreTests", + dependencies: ["TableProR2SQLCore"], + path: "Tests/TableProR2SQLCoreTests" + ), .testTarget( name: "TableProSyncTests", dependencies: ["TableProSync", "TableProSyncTransport", "TableProModels"], diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 2ddde73cb2..9291c1e708 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -34,12 +34,13 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let surrealdb = DatabaseType(rawValue: "SurrealDB") public static let teradata = DatabaseType(rawValue: "Teradata") public static let trino = DatabaseType(rawValue: "Trino") + public static let cloudflareR2SQL = DatabaseType(rawValue: "Cloudflare R2 SQL") public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount, - .surrealdb, .teradata, .trino + .surrealdb, .teradata, .trino, .cloudflareR2SQL ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -67,6 +68,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .surrealdb: return "surrealdb-icon" case .teradata: return "teradata-icon" case .trino: return "trino-icon" + case .cloudflareR2SQL: return "cloudflare-r2-sql-icon" default: return "externaldrive" } } diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift new file mode 100644 index 0000000000..086558f23e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct R2SQLConnectionConfig: Sendable, Equatable { + public static let queryHost = "api.sql.cloudflarestorage.com" + + public let accountId: String + public let bucket: String + public let token: String + public let defaultNamespace: String + public let timeoutSeconds: Int + + public init( + accountId: String, + bucket: String, + token: String, + defaultNamespace: String = "", + timeoutSeconds: Int = 60 + ) { + self.accountId = accountId.trimmingCharacters(in: .whitespacesAndNewlines) + self.bucket = bucket.trimmingCharacters(in: .whitespacesAndNewlines) + self.token = token + self.defaultNamespace = defaultNamespace.trimmingCharacters(in: .whitespacesAndNewlines) + self.timeoutSeconds = timeoutSeconds + } + + public var warehouse: String { + "\(accountId)_\(bucket)" + } + + public var queryURL: URL? { + var components = URLComponents() + components.scheme = "https" + components.host = Self.queryHost + components.path = "/api/v1/accounts/\(accountId)/r2-sql/query/\(bucket)" + return components.url + } + + public func validate() -> R2SQLError? { + if accountId.isEmpty { + return .configuration(R2SQLErrorText.missingAccountId) + } + if bucket.isEmpty { + return .configuration(R2SQLErrorText.missingBucket) + } + if token.isEmpty { + return .configuration(R2SQLErrorText.missingToken) + } + if queryURL == nil { + return .configuration(R2SQLErrorText.invalidEndpoint) + } + return nil + } +} + +public enum R2SQLWarehouse { + public static func split(_ warehouse: String) -> (accountId: String, bucket: String)? { + guard let separator = warehouse.firstIndex(of: "_") else { return nil } + let accountId = String(warehouse[warehouse.startIndex.. = [missingTokenCode, invalidTokenCode] + + public static func decode(_ response: R2SQLHTTPResponse) -> Result { + guard let envelope = try? JSONDecoder().decode(R2SQLEnvelope.self, from: response.body) else { + return .failure(.malformedResponse( + status: response.statusCode, + body: String(data: response.body, encoding: .utf8) ?? "" + )) + } + + guard envelope.success else { + return .failure(classify(errors: envelope.errors, statusCode: response.statusCode)) + } + + guard let result = envelope.result else { + return .success(R2SQLResult(schema: [], rows: [])) + } + return .success(result) + } + + public static func classify(errors: [R2SQLAPIError], statusCode: Int) -> R2SQLError { + guard let first = errors.first else { + return authenticationStatus(statusCode) ?? .malformedResponse(status: statusCode, body: "") + } + + if authenticationCodes.contains(first.code) { + return .authentication(authenticationGuidance(first.message)) + } + if first.code == invalidAccountCode { + return .authentication("\(first.message). Check the Account ID on this connection.") + } + if let status = authenticationStatus(statusCode) { + return status + } + return errors.count == 1 ? .query(first) : .api(errors) + } + + private static func authenticationStatus(_ statusCode: Int) -> R2SQLError? { + switch statusCode { + case 401: + return .authentication(authenticationGuidance("Unauthenticated.")) + case 403: + return .authentication(authenticationGuidance("Forbidden.")) + default: + return nil + } + } + + private static func authenticationGuidance(_ message: String) -> String { + """ + \(message) The API token needs the R2 SQL, R2 Data Catalog and R2 Storage permission groups \ + for this account. + """ + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift new file mode 100644 index 0000000000..d712f6def8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum R2SQLIntrospectionSQL { + public static func showNamespaces() -> String { + "SHOW NAMESPACES" + } + + public static func showTables(namespace: String) -> String { + "SHOW TABLES IN \(R2SQLLiteral.quoteQualifiedName(namespace))" + } + + public static func describe(namespace: String, table: String) -> String { + "DESCRIBE \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift new file mode 100644 index 0000000000..74e00ac0c2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift @@ -0,0 +1,135 @@ +import Foundation + +public enum R2SQLJSONValue: Decodable, Sendable, Equatable { + case null + case bool(Bool) + case int(Int64) + case uint(UInt64) + case double(Double) + 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 + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Int64.self) { + self = .int(value) + return + } + if let value = try? container.decode(UInt64.self) { + self = .uint(value) + return + } + if let value = try? container.decode(Double.self) { + self = .double(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([R2SQLJSONValue].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: R2SQLJSONValue].self) { + self = .object(value) + return + } + self = .null + } + + public var isNull: Bool { + if case .null = self { return true } + return false + } + + public var foundationObject: Any { + switch self { + case .null: + return NSNull() + case .bool(let value): + return value + case .int(let value): + return NSNumber(value: value) + case .uint(let value): + return NSNumber(value: value) + case .double(let value): + return NSNumber(value: value) + case .string(let value): + return value + case .array(let values): + return values.map(\.foundationObject) + case .object(let values): + return values.mapValues(\.foundationObject) + } + } + + public func jsonText() -> String { + switch self { + case .null: + return "null" + case .bool(let value): + return value ? "true" : "false" + case .int(let value): + return String(value) + case .uint(let value): + return String(value) + case .double(let value): + return Self.format(double: value) + case .string(let value): + return Self.encode(string: value) + case .array, .object: + guard let data = try? JSONSerialization.data( + withJSONObject: foundationObject, + options: [.sortedKeys, .fragmentsAllowed] + ), let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } + } + + public var scalarText: String? { + switch self { + case .null: + return nil + case .bool(let value): + return value ? "true" : "false" + case .int(let value): + return String(value) + case .uint(let value): + return String(value) + case .double(let value): + return Self.format(double: value) + case .string(let value): + return value + case .array, .object: + return jsonText() + } + } + + static func format(double value: Double) -> String { + if value == value.rounded(), abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + } + + static func encode(string value: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) else { + return "\"\"" + } + return text + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift new file mode 100644 index 0000000000..5cc0e889c3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum R2SQLLimits { + public static let defaultLimit = 500 + public static let minLimit = 1 + public static let maxLimit = 10_000 + + public static func clampLimit(_ limit: Int) -> Int { + min(max(limit, minLimit), maxLimit) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift new file mode 100644 index 0000000000..af51007e5b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum R2SQLLiteral { + public static func quoteIdentifier(_ identifier: String) -> String { + "\"" + identifier.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + public static func quoteQualifiedName(_ name: String) -> String { + name + .split(separator: ".", omittingEmptySubsequences: false) + .map { quoteIdentifier(String($0)) } + .joined(separator: ".") + } + + public static func escapeStringLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\u{0}", with: "") + .replacingOccurrences(of: "'", with: "''") + } + + public static func stringLiteral(_ value: String) -> String { + "'" + escapeStringLiteral(value) + "'" + } + + public static func qualifiedName(namespace: String, table: String) -> String { + guard !namespace.isEmpty else { return quoteIdentifier(table) } + return quoteQualifiedName(namespace) + "." + quoteIdentifier(table) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift new file mode 100644 index 0000000000..7151f30d78 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift @@ -0,0 +1,169 @@ +import Foundation + +public struct R2SQLSortColumn: Sendable, Equatable { + public let name: String + public let ascending: Bool + + public init(name: String, ascending: Bool) { + self.name = name + self.ascending = ascending + } +} + +public struct R2SQLFilter: Sendable, Equatable { + public let column: String + public let op: String + public let value: String + + public init(column: String, op: String, value: String) { + self.column = column + self.op = op + self.value = value + } +} + +public enum R2SQLQueryBuilder { + public static func selectList(_ columns: [String]) -> String { + guard !columns.isEmpty else { return "*" } + return columns.map(R2SQLLiteral.quoteIdentifier).joined(separator: ", ") + } + + public static func orderByClause(_ sortColumns: [R2SQLSortColumn]) -> String? { + let parts = sortColumns + .filter { !$0.name.isEmpty } + .map { R2SQLLiteral.quoteIdentifier($0.name) + ($0.ascending ? " ASC" : " DESC") } + guard !parts.isEmpty else { return nil } + return "ORDER BY " + parts.joined(separator: ", ") + } + + public static func browseQuery( + namespace: String, + table: String, + columns: [String] = [], + sortColumns: [R2SQLSortColumn] = [], + limit: Int + ) -> String { + compose( + namespace: namespace, + table: table, + columns: columns, + whereClause: nil, + sortColumns: sortColumns, + limit: limit + ) + } + + public static func filteredQuery( + namespace: String, + table: String, + filters: [R2SQLFilter], + matchAll: Bool, + columns: [String] = [], + sortColumns: [R2SQLSortColumn] = [], + limit: Int + ) -> String { + compose( + namespace: namespace, + table: table, + columns: columns, + whereClause: whereClause(filters: filters, matchAll: matchAll), + sortColumns: sortColumns, + limit: limit + ) + } + + public static func countQuery( + namespace: String, + table: String, + filters: [R2SQLFilter] = [], + matchAll: Bool = true + ) -> String { + var sql = "SELECT COUNT(*) AS total FROM \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" + if let clause = whereClause(filters: filters, matchAll: matchAll) { + sql += " WHERE \(clause)" + } + return sql + } + + public static func whereClause(filters: [R2SQLFilter], matchAll: Bool) -> String? { + let parts = filters.compactMap(predicate(for:)) + guard !parts.isEmpty else { return nil } + return parts.joined(separator: matchAll ? " AND " : " OR ") + } + + private static func compose( + namespace: String, + table: String, + columns: [String], + whereClause: String?, + sortColumns: [R2SQLSortColumn], + limit: Int + ) -> String { + var sql = "SELECT \(selectList(columns))" + sql += " FROM \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" + if let whereClause { + sql += " WHERE \(whereClause)" + } + if let orderBy = orderByClause(sortColumns) { + sql += " \(orderBy)" + } + sql += " LIMIT \(R2SQLLimits.clampLimit(limit))" + return sql + } + + private static func predicate(for filter: R2SQLFilter) -> String? { + let column = filter.column.trimmingCharacters(in: .whitespacesAndNewlines) + guard !column.isEmpty else { return nil } + let quoted = R2SQLLiteral.quoteIdentifier(column) + let op = filter.op.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + + switch op { + case "IS NULL", "ISNULL": + return "\(quoted) IS NULL" + case "IS NOT NULL", "NOTNULL": + return "\(quoted) IS NOT NULL" + case "CONTAINS": + return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("%\(filter.value)%"))" + case "STARTS WITH", "BEGINS WITH": + return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("\(filter.value)%"))" + case "ENDS WITH": + return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("%\(filter.value)"))" + case "IN", "NOT IN": + return listPredicate(quoted: quoted, op: op, value: filter.value) + case "=", "!=", "<>", "<", "<=", ">", ">=", "LIKE", "NOT LIKE": + return "\(quoted) \(op) \(literal(for: filter.value))" + default: + return nil + } + } + + private static func listPredicate(quoted: String, op: String, value: String) -> String? { + let items = value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !items.isEmpty else { return nil } + let rendered = items.map(literal(for:)).joined(separator: ", ") + return "\(quoted) \(op) (\(rendered))" + } + + private static func literal(for value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + if lowered == "true" || lowered == "false" { + return lowered + } + if lowered == "null" { + return "NULL" + } + if isNumeric(trimmed) { + return trimmed + } + return R2SQLLiteral.stringLiteral(value) + } + + private static func isNumeric(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + return Double(text) != nil && text.allSatisfy { $0.isNumber || "+-.eE".contains($0) } + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift new file mode 100644 index 0000000000..86e7a21069 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum R2SQLRequestBuilder { + public static func queryRequest(config: R2SQLConnectionConfig, sql: String) throws -> R2SQLHTTPRequest { + if let error = config.validate() { + throw error + } + guard let url = config.queryURL else { + throw R2SQLError.configuration(R2SQLErrorText.invalidEndpoint) + } + let body = R2SQLRequestBody(warehouse: config.warehouse, query: sql) + guard let encoded = try? JSONEncoder().encode(body) else { + throw R2SQLError.configuration("Could not encode the query request") + } + return R2SQLHTTPRequest( + url: url, + headers: [ + "Authorization": "Bearer \(config.token)", + "Content-Type": "application/json", + "Accept": "application/json" + ], + body: encoded, + timeoutSeconds: config.timeoutSeconds + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift new file mode 100644 index 0000000000..ad5eebc7be --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift @@ -0,0 +1,41 @@ +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 static let empty = R2SQLResultSet(columns: [], columnTypeNames: [], rows: []) +} + +public enum R2SQLRowMapper { + public static func map(_ result: R2SQLResult) -> R2SQLResultSet { + let columns = result.schema.map(\.name) + let rawTypeNames = result.schema.map(\.typeName) + let columnTypeNames = rawTypeNames.map { R2SQLTypeMapper.displayTypeName(rawTypeName: $0) } + + let rows = result.rows.map { row in + zip(columns, rawTypeNames).map { name, rawTypeName in + R2SQLTypeMapper.value(for: row[name], rawTypeName: rawTypeName) + } + } + + return R2SQLResultSet(columns: columns, columnTypeNames: columnTypeNames, rows: rows) + } + + public static func firstColumnStrings(_ result: R2SQLResult) -> [String] { + let mapped = map(result) + guard !mapped.columns.isEmpty else { return [] } + return mapped.rows.compactMap { row in + guard let first = row.first, case .text(let value) = first else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + } +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift new file mode 100644 index 0000000000..2e7e724707 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct R2SQLHTTPRequest: Sendable, Equatable { + public let url: URL + public let headers: [String: String] + public let body: Data + public let timeoutSeconds: Int + + public init(url: URL, headers: [String: String], body: Data, timeoutSeconds: Int) { + self.url = url + self.headers = headers + self.body = body + self.timeoutSeconds = timeoutSeconds + } +} + +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 +} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift new file mode 100644 index 0000000000..753e76f994 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum R2SQLTypeCategory: Sendable, Equatable { + case scalar + case binary + case structured +} + +public enum R2SQLTypeMapper { + private static let structuredBases: Set = [ + "list", "largelist", "fixedsizelist", "array", "struct", "map", "union", "dictionary" + ] + + private static let binaryBases: Set = [ + "binary", "largebinary", "fixedsizebinary" + ] + + private static let normalizedBases: [String: String] = [ + "utf8": "STRING", + "largeutf8": "STRING", + "utf8view": "STRING", + "list": "ARRAY", + "largelist": "ARRAY", + "fixedsizelist": "ARRAY", + "struct": "STRUCT", + "map": "MAP", + "binaryview": "BINARY", + "largebinary": "BINARY", + "fixedsizebinary": "BINARY" + ] + + public static func baseName(_ typeName: String) -> String { + let trimmed = typeName.trimmingCharacters(in: .whitespacesAndNewlines) + guard let paren = trimmed.firstIndex(of: "(") else { return trimmed } + return String(trimmed[trimmed.startIndex.. String { + displayTypeName(rawTypeName: field.typeName) + } + + public static func displayTypeName(rawTypeName: String) -> String { + let raw = rawTypeName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return "" } + let base = baseName(raw) + guard let normalized = normalizedBases[base.lowercased()] else { return raw } + return normalized + } + + public static func category(rawTypeName: String) -> R2SQLTypeCategory { + let base = baseName(rawTypeName).lowercased() + if structuredBases.contains(base) { return .structured } + if binaryBases.contains(base) { return .binary } + return .scalar + } + + public static func value(for json: R2SQLJSONValue?, rawTypeName: String) -> R2SQLValue { + guard let json, !json.isNull else { return .null } + switch category(rawTypeName: rawTypeName) { + case .scalar: + if case .array = json { return .text(json.jsonText()) } + if case .object = json { return .text(json.jsonText()) } + return .text(json.scalarText ?? "") + case .binary: + if case .string(let encoded) = json, let data = Data(base64Encoded: encoded) { + return .bytes([UInt8](data)) + } + return .text(json.scalarText ?? "") + case .structured: + return .text(json.jsonText()) + } + } +} 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..d051354a35 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift @@ -0,0 +1,112 @@ +import Foundation + +public struct R2SQLRequestBody: Encodable, Sendable, Equatable { + public let warehouse: String + public let query: String + + public init(warehouse: String, query: String) { + self.warehouse = warehouse + self.query = query + } +} + +public struct R2SQLField: Decodable, Sendable, Equatable { + public let name: String + public let rawType: R2SQLJSONValue? + + public init(name: String, rawType: R2SQLJSONValue?) { + self.name = name + self.rawType = rawType + } + + public init(name: String, type: String) { + self.init(name: name, rawType: .string(type)) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = (try? container.decodeIfPresent(String.self, forKey: .name)) ?? "" + rawType = try? container.decodeIfPresent(R2SQLJSONValue.self, forKey: .type) + } + + public var typeName: String { + switch rawType { + case .string(let value): + return value + case .object(let fields): + if case .string(let value)? = fields["name"] { return value } + return "" + case .none, .null: + return "" + default: + return rawType?.scalarText ?? "" + } + } + + private enum CodingKeys: String, CodingKey { + case name, type + } +} + +public struct R2SQLMetrics: Decodable, Sendable, Equatable { + public let r2RequestsCount: Int? + public let filesScanned: Int? + public let bytesScanned: Int? + + private enum CodingKeys: String, CodingKey { + case r2RequestsCount = "r2_requests_count" + case filesScanned = "files_scanned" + case bytesScanned = "bytes_scanned" + } +} + +public struct R2SQLResult: Decodable, Sendable, Equatable { + public let requestId: String? + public let schema: [R2SQLField] + public let rows: [[String: R2SQLJSONValue]] + public let metrics: R2SQLMetrics? + + public init( + requestId: String? = nil, + schema: [R2SQLField], + rows: [[String: R2SQLJSONValue]], + metrics: R2SQLMetrics? = nil + ) { + self.requestId = requestId + self.schema = schema + self.rows = rows + self.metrics = metrics + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + requestId = try? container.decodeIfPresent(String.self, forKey: .requestId) + schema = (try? container.decodeIfPresent([R2SQLField].self, forKey: .schema)) ?? [] + rows = (try? container.decodeIfPresent([[String: R2SQLJSONValue]].self, forKey: .rows)) ?? [] + metrics = try? container.decodeIfPresent(R2SQLMetrics.self, forKey: .metrics) + } + + private enum CodingKeys: String, CodingKey { + case requestId = "request_id" + case schema, rows, metrics + } +} + +public struct R2SQLEnvelope: Decodable, Sendable, Equatable { + public let result: R2SQLResult? + public let success: Bool + public let errors: [R2SQLAPIError] + public let messages: [String] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + result = try? container.decodeIfPresent(R2SQLResult.self, forKey: .result) + success = (try? container.decodeIfPresent(Bool.self, forKey: .success)) ?? false + errors = (try? container.decodeIfPresent([R2SQLAPIError].self, forKey: .errors)) ?? [] + messages = (try? container.decodeIfPresent([String].self, forKey: .messages)) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case result, success, errors, messages + } +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index 97e55b829a..dafe162966 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -53,7 +53,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 22) + #expect(DatabaseType.allKnownTypes.count == 23) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) @@ -62,6 +62,14 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.surrealdb)) #expect(DatabaseType.allKnownTypes.contains(.teradata)) #expect(DatabaseType.allKnownTypes.contains(.trino)) + #expect(DatabaseType.allKnownTypes.contains(.cloudflareR2SQL)) + } + + @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") diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift new file mode 100644 index 0000000000..257ee7e4e6 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLEnvelopeDecodingTests: XCTestCase { + private func response(_ json: String, status: Int = 200) -> R2SQLHTTPResponse { + R2SQLHTTPResponse(statusCode: status, body: Data(json.utf8)) + } + + func testSuccessEnvelopeDecodesSchemaAndRows() throws { + let json = """ + {"result":{"request_id":"dqe-prod-test", + "schema":[{"name":"id","type":"Int64"},{"name":"label","type":"Utf8"}], + "rows":[{"id":1,"label":"a"},{"id":2,"label":"b"}], + "metrics":{"r2_requests_count":3,"files_scanned":2,"bytes_scanned":1024}}, + "success":true,"errors":[],"messages":[]} + """ + let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) + XCTAssertEqual(result.requestId, "dqe-prod-test") + XCTAssertEqual(result.schema.map(\.name), ["id", "label"]) + XCTAssertEqual(result.rows.count, 2) + XCTAssertEqual(result.metrics?.bytesScanned, 1024) + } + + func testEmptyResultSetIsSuccess() throws { + let json = """ + {"result":{"schema":[],"rows":[],"metrics":{"r2_requests_count":0,"files_scanned":0,"bytes_scanned":0}}, + "success":true,"errors":[],"messages":[]} + """ + let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) + XCTAssertTrue(result.rows.isEmpty) + XCTAssertTrue(result.schema.isEmpty) + } + + func testMissingMetricsStillDecodes() throws { + let json = """ + {"result":{"schema":[{"name":"a","type":"Int64"}],"rows":[{"a":1}]},"success":true,"errors":[],"messages":[]} + """ + let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) + XCTAssertNil(result.metrics) + XCTAssertEqual(result.rows.count, 1) + } + + func testSuccessTrueWithNullResultYieldsEmptyResult() throws { + let json = #"{"result":null,"success":true,"errors":[],"messages":[]}"# + let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) + XCTAssertTrue(result.rows.isEmpty) + } + + func testErrorEnvelopeIsClassifiedAsFailure() { + let json = #"{"result":null,"success":false,"errors":[{"code":80007,"message":"Unauthenticated."}]}"# + guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 401)) else { + return XCTFail("Expected a failure") + } + guard case .authentication = error else { + return XCTFail("Expected an authentication error, got \(error)") + } + } + + func testSuccessFalseUnderHTTP200IsStillFailure() { + let json = #"{"result":null,"success":false,"errors":[{"code":40003,"message":"bad SQL"}]}"# + guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 200)) else { + return XCTFail("Expected a failure") + } + XCTAssertEqual(error, .query(R2SQLAPIError(code: 40_003, message: "bad SQL"))) + } + + func testSuccessTrueUnderHTTP500IsStillSuccess() { + let json = #"{"result":{"schema":[],"rows":[]},"success":true,"errors":[],"messages":[]}"# + guard case .success = R2SQLErrorClassifier.decode(response(json, status: 500)) else { + return XCTFail("success flag must decide the outcome, not the HTTP status") + } + } + + func testNonJSONBodyBecomesMalformedResponse() { + let plain = R2SQLHTTPResponse(statusCode: 405, body: Data("Method not allowed.".utf8)) + guard case .failure(let error) = R2SQLErrorClassifier.decode(plain) else { + return XCTFail("Expected a failure") + } + XCTAssertEqual(error, .malformedResponse(status: 405, body: "Method not allowed.")) + } + + func testEmptyBodyBecomesMalformedResponse() { + let empty = R2SQLHTTPResponse(statusCode: 502, body: Data()) + guard case .failure(let error) = R2SQLErrorClassifier.decode(empty) else { + return XCTFail("Expected a failure") + } + XCTAssertEqual(error, .malformedResponse(status: 502, body: "")) + } + + func testMultipleErrorsAreAllSurfaced() { + let json = """ + {"result":null,"success":false,"errors":[{"code":40003,"message":"first"},{"code":40004,"message":"second"}]} + """ + guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json)) else { + return XCTFail("Expected a failure") + } + let description = error.errorDescription ?? "" + XCTAssertTrue(description.contains("first")) + XCTAssertTrue(description.contains("second")) + } + + func testInvalidAccountIdErrorMentionsAccountId() { + let json = #"{"result":null,"success":false,"errors":[{"code":80016,"message":"Invalid account id"}]}"# + guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 400)) else { + return XCTFail("Expected a failure") + } + XCTAssertTrue(error.errorDescription?.contains("Account ID") ?? false) + } + + func testAuthenticationGuidanceNamesThePermissionGroups() { + let error = R2SQLErrorClassifier.classify( + errors: [R2SQLAPIError(code: 80_011, message: "Invalid token.")], + statusCode: 403 + ) + let description = error.errorDescription ?? "" + XCTAssertTrue(description.contains("R2 SQL")) + XCTAssertTrue(description.contains("R2 Data Catalog")) + XCTAssertTrue(description.contains("R2 Storage")) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift new file mode 100644 index 0000000000..1227a9f755 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLIntrospectionSQLTests: XCTestCase { + func testShowNamespaces() { + XCTAssertEqual(R2SQLIntrospectionSQL.showNamespaces(), "SHOW NAMESPACES") + } + + func testShowTablesQuotesNamespace() { + XCTAssertEqual(R2SQLIntrospectionSQL.showTables(namespace: "analytics"), "SHOW TABLES IN \"analytics\"") + } + + func testDescribeQualifiesNamespaceAndTable() { + XCTAssertEqual( + R2SQLIntrospectionSQL.describe(namespace: "analytics", table: "events"), + "DESCRIBE \"analytics\".\"events\"" + ) + } + + func testDottedNamespaceIsQuotedPerSegmentNotReSplit() { + XCTAssertEqual( + R2SQLIntrospectionSQL.describe(namespace: "a.b", table: "t"), + "DESCRIBE \"a\".\"b\".\"t\"" + ) + } + + func testNamespaceWithEmbeddedQuoteIsEscaped() { + XCTAssertEqual( + R2SQLIntrospectionSQL.showTables(namespace: "we\"ird"), + "SHOW TABLES IN \"we\"\"ird\"" + ) + } + + func testIntrospectionStatementsNeverContainOffset() { + let statements = [ + R2SQLIntrospectionSQL.showNamespaces(), + R2SQLIntrospectionSQL.showTables(namespace: "ns"), + R2SQLIntrospectionSQL.describe(namespace: "ns", table: "t") + ] + for statement in statements { + XCTAssertFalse(statement.uppercased().contains("OFFSET")) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift new file mode 100644 index 0000000000..7a826d3630 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLJSONValueTests: XCTestCase { + private func decode(_ json: String) throws -> R2SQLJSONValue { + try JSONDecoder().decode(R2SQLJSONValue.self, from: Data(json.utf8)) + } + + func testLargeInt64KeepsExactPrecision() throws { + let value = try decode("9223372036854775807") + XCTAssertEqual(value, .int(Int64.max)) + XCTAssertEqual(value.scalarText, "9223372036854775807") + } + + func testIntegerBeyondInt64MaxDecodesAsUnsigned() throws { + let value = try decode("9223372036854775808") + XCTAssertEqual(value, .uint(9_223_372_036_854_775_808)) + XCTAssertEqual(value.scalarText, "9223372036854775808") + } + + func testIntegerAboveTwoToTheFiftyThreeIsNotRoundedByDouble() throws { + let value = try decode("9007199254740993") + XCTAssertEqual(value.scalarText, "9007199254740993") + } + + func testNegativeIntegerDecodes() throws { + XCTAssertEqual(try decode("-42"), .int(-42)) + } + + func testFractionalNumberDecodesAsDouble() throws { + XCTAssertEqual(try decode("1.5"), .double(1.5)) + } + + func testBooleansDecodeAsBool() throws { + XCTAssertEqual(try decode("true"), .bool(true)) + XCTAssertEqual(try decode("false"), .bool(false)) + } + + func testNullDecodesAsNull() throws { + XCTAssertTrue(try decode("null").isNull) + XCTAssertNil(try decode("null").scalarText) + } + + func testNestedNullInsideObjectIsPreserved() throws { + let value = try decode(#"{"count":null}"#) + XCTAssertEqual(value.jsonText(), #"{"count":null}"#) + } + + func testNestedArrayOfStructsSerializesStably() throws { + let value = try decode(#"[{"b":2,"a":1},{"a":3,"b":4}]"#) + XCTAssertEqual(value.jsonText(), #"[{"a":1,"b":2},{"a":3,"b":4}]"#) + } + + func testStringWithQuotesIsEscapedInJSONText() throws { + let value = try decode(#""he said \"hi\"""#) + XCTAssertEqual(value.scalarText, #"he said "hi""#) + XCTAssertEqual(value.jsonText(), #""he said \"hi\"""#) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift new file mode 100644 index 0000000000..02507a9939 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLQueryBuilderTests: XCTestCase { + func testBrowseQueryNeverEmitsOffset() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "analytics", table: "events", limit: 1_000) + XCTAssertFalse(sql.uppercased().contains("OFFSET")) + XCTAssertEqual(sql, "SELECT * FROM \"analytics\".\"events\" LIMIT 1000") + } + + func testFilteredQueryNeverEmitsOffset() { + let sql = R2SQLQueryBuilder.filteredQuery( + namespace: "analytics", + table: "events", + filters: [R2SQLFilter(column: "status", op: "=", value: "ok")], + matchAll: true, + limit: 100 + ) + XCTAssertFalse(sql.uppercased().contains("OFFSET")) + XCTAssertEqual(sql, "SELECT * FROM \"analytics\".\"events\" WHERE \"status\" = 'ok' LIMIT 100") + } + + func testLimitIsClampedToEngineMaximum() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "t", limit: 50_000) + XCTAssertTrue(sql.hasSuffix("LIMIT 10000")) + } + + func testLimitIsClampedToEngineMinimum() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "t", limit: 0) + XCTAssertTrue(sql.hasSuffix("LIMIT 1")) + } + + func testDottedNamespaceIsQuotedPerSegment() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "a.b", table: "t", limit: 10) + XCTAssertEqual(sql, "SELECT * FROM \"a\".\"b\".\"t\" LIMIT 10") + } + + func testEmptyNamespaceOmitsQualification() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "", table: "t", limit: 10) + XCTAssertEqual(sql, "SELECT * FROM \"t\" LIMIT 10") + } + + func testExplicitColumnsAreQuoted() { + let sql = R2SQLQueryBuilder.browseQuery( + namespace: "ns", + table: "t", + columns: ["id", "user name"], + limit: 10 + ) + XCTAssertEqual(sql, "SELECT \"id\", \"user name\" FROM \"ns\".\"t\" LIMIT 10") + } + + func testOrderByRendersDirectionPerColumn() { + let sql = R2SQLQueryBuilder.browseQuery( + namespace: "ns", + table: "t", + sortColumns: [ + R2SQLSortColumn(name: "ts", ascending: false), + R2SQLSortColumn(name: "id", ascending: true) + ], + limit: 10 + ) + XCTAssertEqual(sql, "SELECT * FROM \"ns\".\"t\" ORDER BY \"ts\" DESC, \"id\" ASC LIMIT 10") + } + + func testIdentifierWithEmbeddedQuoteIsEscaped() { + let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "na\"me", limit: 10) + XCTAssertEqual(sql, "SELECT * FROM \"ns\".\"na\"\"me\" LIMIT 10") + } + + func testInjectionAttemptStaysInsideStringLiteral() { + let sql = R2SQLQueryBuilder.filteredQuery( + namespace: "ns", + table: "t", + filters: [R2SQLFilter(column: "name", op: "=", value: "'; DROP TABLE users; --")], + matchAll: true, + limit: 10 + ) + XCTAssertEqual( + sql, + "SELECT * FROM \"ns\".\"t\" WHERE \"name\" = '''; DROP TABLE users; --' LIMIT 10" + ) + } + + func testNullBytesAreStrippedFromLiterals() { + XCTAssertEqual(R2SQLLiteral.escapeStringLiteral("a\u{0}b"), "ab") + } + + func testOrFiltersJoinWithOr() { + let clause = R2SQLQueryBuilder.whereClause( + filters: [ + R2SQLFilter(column: "a", op: "=", value: "1"), + R2SQLFilter(column: "b", op: "=", value: "2") + ], + matchAll: false + ) + XCTAssertEqual(clause, "\"a\" = 1 OR \"b\" = 2") + } + + func testNullPredicatesRenderWithoutValue() { + XCTAssertEqual( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "a", op: "IS NULL", value: "")], + matchAll: true + ), + "\"a\" IS NULL" + ) + } + + func testContainsBecomesLikeWithWildcards() { + XCTAssertEqual( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "a", op: "contains", value: "abc")], + matchAll: true + ), + "\"a\" LIKE '%abc%'" + ) + } + + func testInListRendersEachItem() { + XCTAssertEqual( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "a", op: "IN", value: "1, 2, x")], + matchAll: true + ), + "\"a\" IN (1, 2, 'x')" + ) + } + + func testUnknownOperatorIsDroppedRatherThanInterpolated() { + XCTAssertNil( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "a", op: "; DROP TABLE t", value: "1")], + matchAll: true + ) + ) + } + + func testEmptyColumnFilterIsDropped() { + XCTAssertNil( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "", op: "=", value: "1")], + matchAll: true + ) + ) + } + + func testBooleanLiteralsAreNotQuoted() { + XCTAssertEqual( + R2SQLQueryBuilder.whereClause( + filters: [R2SQLFilter(column: "a", op: "=", value: "TRUE")], + matchAll: true + ), + "\"a\" = true" + ) + } + + func testCountQueryHasNoLimitAndNoOffset() { + let sql = R2SQLQueryBuilder.countQuery(namespace: "ns", table: "t") + XCTAssertEqual(sql, "SELECT COUNT(*) AS total FROM \"ns\".\"t\"") + XCTAssertFalse(sql.uppercased().contains("OFFSET")) + XCTAssertFalse(sql.uppercased().contains("LIMIT")) + } + + func testCountQueryAppliesFilters() { + let sql = R2SQLQueryBuilder.countQuery( + namespace: "ns", + table: "t", + filters: [R2SQLFilter(column: "a", op: ">", value: "5")] + ) + XCTAssertEqual(sql, "SELECT COUNT(*) AS total FROM \"ns\".\"t\" WHERE \"a\" > 5") + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift new file mode 100644 index 0000000000..bf58e911c3 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLRequestBuilderTests: XCTestCase { + private let config = R2SQLConnectionConfig( + accountId: "abc123", + bucket: "my-bucket", + token: "secret-token" + ) + + func testWarehouseIsAccountIdUnderscoreBucket() { + XCTAssertEqual(config.warehouse, "abc123_my-bucket") + } + + func testWarehouseRoundTripsThroughSplit() { + let parts = R2SQLWarehouse.split(config.warehouse) + XCTAssertEqual(parts?.accountId, "abc123") + XCTAssertEqual(parts?.bucket, "my-bucket") + } + + func testWarehouseSplitUsesFirstUnderscoreOnly() { + let parts = R2SQLWarehouse.split("acct_my_bucket_with_underscores") + XCTAssertEqual(parts?.accountId, "acct") + XCTAssertEqual(parts?.bucket, "my_bucket_with_underscores") + } + + func testWarehouseSplitRejectsMissingSeparator() { + XCTAssertNil(R2SQLWarehouse.split("nounderscore")) + } + + func testQueryURLMatchesDocumentedEndpoint() { + XCTAssertEqual( + config.queryURL?.absoluteString, + "https://api.sql.cloudflarestorage.com/api/v1/accounts/abc123/r2-sql/query/my-bucket" + ) + } + + func testRequestCarriesBearerTokenAndJSONContentType() throws { + let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1 FROM t") + XCTAssertEqual(request.headers["Authorization"], "Bearer secret-token") + XCTAssertEqual(request.headers["Content-Type"], "application/json") + } + + func testRequestBodyCarriesBothWarehouseAndQuery() throws { + let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT * FROM ns.t LIMIT 10") + let decoded = try XCTUnwrap( + JSONSerialization.jsonObject(with: request.body) as? [String: String] + ) + XCTAssertEqual(decoded["warehouse"], "abc123_my-bucket") + XCTAssertEqual(decoded["query"], "SELECT * FROM ns.t LIMIT 10") + XCTAssertEqual(decoded.count, 2) + } + + func testRequestUsesConfiguredTimeout() throws { + let timed = R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "t", timeoutSeconds: 15) + let request = try R2SQLRequestBuilder.queryRequest(config: timed, sql: "SELECT 1 FROM t") + XCTAssertEqual(request.timeoutSeconds, 15) + } + + func testMissingAccountIdIsRejected() { + let invalid = R2SQLConnectionConfig(accountId: "", bucket: "b", token: "t") + XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingAccountId)) + XCTAssertThrowsError(try R2SQLRequestBuilder.queryRequest(config: invalid, sql: "SELECT 1 FROM t")) + } + + func testMissingBucketIsRejected() { + let invalid = R2SQLConnectionConfig(accountId: "a", bucket: "", token: "t") + XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingBucket)) + } + + func testMissingTokenIsRejected() { + let invalid = R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "") + XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingToken)) + } + + func testValidConfigurationPassesValidation() { + XCTAssertNil(config.validate()) + } + + func testWhitespaceIsTrimmedFromIdentifiers() { + let padded = R2SQLConnectionConfig(accountId: " abc123 ", bucket: " my-bucket\n", token: "t") + XCTAssertEqual(padded.warehouse, "abc123_my-bucket") + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift new file mode 100644 index 0000000000..d8c658190b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLRowMapperTests: XCTestCase { + func testColumnOrderComesFromSchemaNotRowKeyOrder() { + let result = R2SQLResult( + schema: [ + R2SQLField(name: "zebra", type: "Utf8"), + R2SQLField(name: "alpha", type: "Int64") + ], + rows: [["alpha": .int(1), "zebra": .string("z")]] + ) + let mapped = R2SQLRowMapper.map(result) + XCTAssertEqual(mapped.columns, ["zebra", "alpha"]) + XCTAssertEqual(mapped.rows.first, [.text("z"), .text("1")]) + } + + func testMissingKeyBecomesNullRatherThanFailing() { + let result = R2SQLResult( + schema: [R2SQLField(name: "a", type: "Int64"), R2SQLField(name: "b", type: "Utf8")], + rows: [["a": .int(1)]] + ) + let mapped = R2SQLRowMapper.map(result) + XCTAssertEqual(mapped.rows.first, [.text("1"), .null]) + } + + func testExplicitJSONNullBecomesNull() { + let result = R2SQLResult( + schema: [R2SQLField(name: "a", type: "Int64")], + rows: [["a": .null]] + ) + XCTAssertEqual(R2SQLRowMapper.map(result).rows.first, [.null]) + } + + func testStructColumnSerializesAsJSONText() { + let result = R2SQLResult( + schema: [R2SQLField(name: "s", type: "Struct(a Int64)")], + rows: [["s": .object(["a": .int(1)])]] + ) + let mapped = R2SQLRowMapper.map(result) + XCTAssertEqual(mapped.columnTypeNames, ["STRUCT"]) + XCTAssertEqual(mapped.rows.first, [.text("{\"a\":1}")]) + } + + func testListColumnSerializesAsJSONArray() { + let result = R2SQLResult( + schema: [R2SQLField(name: "l", type: "List(Int64)")], + rows: [["l": .array([.int(1), .int(2)])]] + ) + let mapped = R2SQLRowMapper.map(result) + XCTAssertEqual(mapped.columnTypeNames, ["ARRAY"]) + XCTAssertEqual(mapped.rows.first, [.text("[1,2]")]) + } + + func testEmptySchemaProducesEmptyResultSet() { + let mapped = R2SQLRowMapper.map(R2SQLResult(schema: [], rows: [])) + XCTAssertEqual(mapped, .empty) + } + + func testFirstColumnStringsExtractsNames() { + let result = R2SQLResult( + schema: [R2SQLField(name: "namespace", type: "Utf8")], + rows: [["namespace": .string("analytics")], ["namespace": .string("logs")]] + ) + XCTAssertEqual(R2SQLRowMapper.firstColumnStrings(result), ["analytics", "logs"]) + } + + func testFirstColumnStringsSkipsBlanksAndNulls() { + let result = R2SQLResult( + schema: [R2SQLField(name: "n", type: "Utf8")], + rows: [["n": .string("a")], ["n": .null], ["n": .string(" ")]] + ) + XCTAssertEqual(R2SQLRowMapper.firstColumnStrings(result), ["a"]) + } +} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift new file mode 100644 index 0000000000..afcced479b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import TableProR2SQLCore + +final class R2SQLTypeMapperTests: XCTestCase { + func testArrowTextTypesNormalizeToString() { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Utf8"), "STRING") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "LargeUtf8"), "STRING") + } + + func testArrowListTypesNormalizeToArray() { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "List(Int64)"), "ARRAY") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "LargeList(Utf8)"), "ARRAY") + } + + func testStructAndMapNormalize() { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Struct(a Int64)"), "STRUCT") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Map(Utf8, Int64)"), "MAP") + } + + func testTypesTablePromAlreadyUnderstandsArePassedThrough() { + for name in ["Int64", "Float64", "Boolean", "Date32", "Decimal128(10, 2)", "Timestamp(Microsecond, None)"] { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: name), name) + } + } + + func testNormalizationIsCaseInsensitive() { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "utf8"), "STRING") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "STRUCT"), "STRUCT") + } + + func testEmptyTypeStaysEmpty() { + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: ""), "") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(for: R2SQLField(name: "a", rawType: nil)), "") + } + + func testCategoryClassifiesStructuredBinaryAndScalar() { + XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Struct(a Int64)"), .structured) + XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "List(Int64)"), .structured) + XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Map(Utf8, Int64)"), .structured) + XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Binary"), .binary) + XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Int64"), .scalar) + } + + func testBinaryValueDecodesFromBase64() { + let value = R2SQLTypeMapper.value(for: .string("AQID"), rawTypeName: "Binary") + XCTAssertEqual(value, .bytes([1, 2, 3])) + } + + func testBinaryValueFallsBackToTextWhenNotBase64() { + let value = R2SQLTypeMapper.value(for: .string("not base64!"), rawTypeName: "Binary") + XCTAssertEqual(value, .text("not base64!")) + } + + func testNilAndNullBecomeNullValue() { + XCTAssertEqual(R2SQLTypeMapper.value(for: nil, rawTypeName: "Int64"), .null) + XCTAssertEqual(R2SQLTypeMapper.value(for: .null, rawTypeName: "Int64"), .null) + } + + func testFieldTypeNameReadsObjectShapedType() { + let field = R2SQLField(name: "a", rawType: .object(["name": .string("struct")])) + XCTAssertEqual(field.typeName, "struct") + XCTAssertEqual(R2SQLTypeMapper.displayTypeName(for: field), "STRUCT") + } + + func testFieldDecodesFlatStringType() throws { + let json = #"{"name":"id","type":"Int64"}"# + let field = try JSONDecoder().decode(R2SQLField.self, from: Data(json.utf8)) + XCTAssertEqual(field.name, "id") + XCTAssertEqual(field.typeName, "Int64") + } + + func testFieldDecodesObjectShapedTypeWithoutFailing() throws { + let json = #"{"name":"s","type":{"name":"struct","fields":[]}}"# + let field = try JSONDecoder().decode(R2SQLField.self, from: Data(json.utf8)) + XCTAssertEqual(field.name, "s") + XCTAssertEqual(field.typeName, "struct") + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift new file mode 100644 index 0000000000..4afa744984 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift @@ -0,0 +1,124 @@ +// +// 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 over Apache Iceberg tables in R2" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Cloudflare R2 SQL" + static let databaseDisplayName = "Cloudflare R2 SQL" + static let iconName = "cloudflare-r2-sql-icon" + static let defaultPort = 0 + + // MARK: - UI/Capability Metadata + + 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 supportsTriggers = false + static let supportsTriggerEditing = 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 = true + static let supportsQueryProgress = false + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let schemaEntityName = "Namespace" + static let containerEntityName = "Bucket" + static let brandColorHex = "#F6821F" + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + + 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] + + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["INT32", "INT64", "INTEGER"], + "Float": ["FLOAT32", "FLOAT64", "DECIMAL128"], + "String": ["STRING", "UTF8"], + "Date": ["DATE32", "TIMESTAMP"], + "Binary": ["BINARY"], + "Boolean": ["BOOLEAN"], + "Nested": ["ARRAY", "STRUCT", "MAP"] + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "QUALIFY", + "ORDER", "ASC", "DESC", "LIMIT", "AS", "ON", "USING", + "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", + "AND", "OR", "NOT", "IN", "EXISTS", "LIKE", "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", "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", "ARROW_CAST", "ARROW_TYPEOF", + "ARRAY_LENGTH", "ARRAY_MAX", "ARRAY_MIN", "MAP_KEYS", "MAP_VALUES" + ], + dataTypes: [ + "BOOLEAN", "INT32", "INT64", "FLOAT32", "FLOAT64", "DECIMAL128", + "STRING", "UTF8", "DATE32", "TIMESTAMP", "BINARY", + "ARRAY", "STRUCT", "MAP" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + static let additionalConnectionFields: [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 + ) + ] + + 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..f52a8dc789 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift @@ -0,0 +1,162 @@ +// +// CloudflareR2SQLPluginDriver+Query.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProR2SQLCore + +extension CloudflareR2SQLPluginDriver { + func execute(query: String) async throws -> PluginQueryResult { + let started = Date() + let result = try await run(sql: query) + return Self.pluginResult(result, executionTime: Date().timeIntervalSince(started)) + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildBrowseQuery( + table: table, + schema: currentSchema, + sortColumns: sortColumns, + columns: columns, + limit: limit, + offset: offset + ) + } + + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + guard let namespace = resolveNamespace(schema) else { return nil } + return R2SQLQueryBuilder.browseQuery( + namespace: namespace, + table: table, + columns: columns, + sortColumns: Self.sortColumns(sortColumns, in: columns), + limit: limit + ) + } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildFilteredQuery( + table: table, + schema: currentSchema, + filters: filters, + logicMode: logicMode, + sortColumns: sortColumns, + columns: columns, + limit: limit, + offset: offset + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + guard let namespace = resolveNamespace(schema) else { return nil } + return R2SQLQueryBuilder.filteredQuery( + namespace: namespace, + table: table, + filters: filters.map { R2SQLFilter(column: $0.column, op: $0.op, value: $0.value) }, + matchAll: logicMode.lowercased() != "or", + columns: columns, + sortColumns: Self.sortColumns(sortColumns, in: columns), + limit: limit + ) + } + + func fetchExactRowCount( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? { + guard let namespace = resolveNamespace(schema) else { return nil } + let sql = R2SQLQueryBuilder.countQuery( + namespace: namespace, + table: table, + filters: filters.map { R2SQLFilter(column: $0.column, op: $0.op, value: $0.value) }, + matchAll: logicMode.lowercased() != "or" + ) + let result = try await run(sql: sql) + return R2SQLRowMapper.firstColumnStrings(result).first.flatMap(Int.init) + } + + func defaultExportQuery(table: String, schema: String?) -> String? { + guard let namespace = resolveNamespace(schema) else { return nil } + return R2SQLQueryBuilder.browseQuery( + namespace: namespace, + table: table, + limit: R2SQLLimits.maxLimit + ) + } + + func quoteIdentifier(_ name: String) -> String { + R2SQLLiteral.quoteIdentifier(name) + } + + func escapeStringLiteral(_ value: String) -> String { + R2SQLLiteral.escapeStringLiteral(value) + } + + static func sortColumns( + _ sortColumns: [(columnIndex: Int, ascending: Bool)], + in columns: [String] + ) -> [R2SQLSortColumn] { + sortColumns.compactMap { sort in + guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } + return R2SQLSortColumn(name: columns[sort.columnIndex], ascending: sort.ascending) + } + } + + static func pluginResult(_ result: R2SQLResult, executionTime: TimeInterval) -> PluginQueryResult { + let mapped = R2SQLRowMapper.map(result) + return PluginQueryResult( + columns: mapped.columns, + columnTypeNames: mapped.columnTypeNames, + rows: mapped.rows.map { $0.map(cellValue) }, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: false, + statusMessage: nil + ) + } + + 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..bc542a2cd2 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift @@ -0,0 +1,97 @@ +// +// CloudflareR2SQLPluginDriver+Schema.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProR2SQLCore + +extension CloudflareR2SQLPluginDriver { + func fetchDatabases() async throws -> [String] { + guard let bucket = resolvedConfig?.bucket, !bucket.isEmpty else { return [] } + return [bucket] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchSchemas() async throws -> [String] { + let result = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) + return R2SQLRowMapper.firstColumnStrings(result).sorted() + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let namespace = resolveNamespace(schema) else { return [] } + let result = try await run(sql: R2SQLIntrospectionSQL.showTables(namespace: namespace)) + return R2SQLRowMapper.firstColumnStrings(result).sorted().map { name in + PluginTableInfo(name: name, type: "TABLE", schema: namespace, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + guard let namespace = resolveNamespace(schema) else { return [] } + let result = try await run(sql: R2SQLIntrospectionSQL.describe(namespace: namespace, table: table)) + let mapped = R2SQLRowMapper.map(result) + + return mapped.rows.compactMap { row -> PluginColumnInfo? in + guard let name = Self.text(row.first), !name.isEmpty else { return nil } + let rawType = row.count > 1 ? Self.text(row[1]) ?? "" : "" + let nullable = Self.parseNullable(row.count > 2 ? Self.text(row[2]) : nil) + return PluginColumnInfo( + name: name, + dataType: R2SQLTypeMapper.displayTypeName(rawTypeName: rawType), + isNullable: nullable, + defaultValue: nil, + comment: nil + ) + } + } + + 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 { + guard let namespace = resolveNamespace(schema) else { + throw R2SQLError.configuration(R2SQLErrorText.noNamespace) + } + let columns = try await fetchColumns(table: table, schema: namespace) + guard !columns.isEmpty else { + throw R2SQLError.query(R2SQLAPIError(code: 0, message: "No columns found for \(table)")) + } + let body = columns + .map { " \(R2SQLLiteral.quoteIdentifier($0.name)) \($0.dataType)\($0.isNullable ? "" : " NOT NULL")" } + .joined(separator: ",\n") + let name = R2SQLLiteral.qualifiedName(namespace: namespace, table: table) + return "CREATE TABLE \(name) (\n\(body)\n)" + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw R2SQLError.unsupported(R2SQLErrorText.noViews) + } + + static func text(_ value: R2SQLValue?) -> String? { + guard case .text(let text)? = value else { return nil } + return text + } + + static func parseNullable(_ value: String?) -> Bool { + guard let value else { return true } + switch value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "NO", "FALSE", "0", "NOT NULL": + return false + default: + return true + } + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift new file mode 100644 index 0000000000..06ff3aeb3f --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift @@ -0,0 +1,117 @@ +// +// 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 connectionConfig: R2SQLConnectionConfig? + private var namespace: String? + private var isConnected = false + + let transport: R2SQLTransport + let config: DriverConnectionConfig + + init(config: DriverConnectionConfig, transport: R2SQLTransport? = nil) { + self.config = config + self.transport = transport ?? URLSessionR2SQLTransport() + } + + // MARK: - Capabilities + + var capabilities: PluginCapabilities { + [.cancelQuery] + } + + var supportsSchemas: Bool { true } + + var supportsTransactions: Bool { false } + + var serverVersion: String? { nil } + + // MARK: - Connection State + + var resolvedConfig: R2SQLConnectionConfig? { + lock.withLock { connectionConfig } + } + + var currentSchema: String? { + lock.withLock { namespace } + } + + func switchSchema(to schema: String) async throws { + lock.withLock { namespace = schema } + } + + func resolveNamespace(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + let current = currentSchema + if let current, !current.isEmpty { return current } + return nil + } + + // MARK: - Lifecycle + + func connect() async throws { + let resolved = Self.buildConfig(from: config) + if let error = resolved.validate() { + throw error + } + lock.withLock { + connectionConfig = resolved + if namespace == nil { + namespace = resolved.defaultNamespace.isEmpty ? nil : resolved.defaultNamespace + } + isConnected = true + } + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) + } + + func disconnect() { + lock.withLock { + connectionConfig = nil + isConnected = false + } + } + + func ping() async throws { + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) + } + + func cancelQuery() throws { + (transport as? URLSessionR2SQLTransport)?.cancelInFlight() + } + + // MARK: - Transport + + func run(sql: String) async throws -> R2SQLResult { + guard let resolved = resolvedConfig else { + throw R2SQLError.notConnected + } + let request = try R2SQLRequestBuilder.queryRequest(config: resolved, sql: sql) + let response = try await transport.send(request) + switch R2SQLErrorClassifier.decode(response) { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + private static func buildConfig(from config: DriverConnectionConfig) -> R2SQLConnectionConfig { + R2SQLConnectionConfig( + accountId: config.additionalFields["r2AccountId"] ?? "", + bucket: config.additionalFields["r2Bucket"] ?? "", + token: config.password, + defaultNamespace: config.additionalFields["r2Namespace"] ?? "", + timeoutSeconds: 60 + ) + } +} diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist new file mode 100644 index 0000000000..1a97b71ce0 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + 19 + TableProProvidesDatabaseTypeIds + + Cloudflare R2 SQL + + + diff --git a/Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift b/Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift new file mode 100644 index 0000000000..9bf1462e09 --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift @@ -0,0 +1,63 @@ +// +// R2SQLURLSessionTransport.swift +// TablePro +// + +import Foundation +import TableProR2SQLCore + +final class URLSessionR2SQLTransport: NSObject, R2SQLTransport, @unchecked Sendable { + private let session: URLSession + private let lock = NSLock() + private var inFlight: URLSessionDataTask? + + override init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + session = URLSession(configuration: configuration) + super.init() + } + + func cancelInFlight() { + let task = lock.withLock { inFlight } + task?.cancel() + } + + func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = TimeInterval(request.timeoutSeconds) + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let (data, response) = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.dataTask(with: urlRequest) { data, response, error in + if let error { + if (error as? URLError)?.code == .cancelled { + continuation.resume(throwing: R2SQLError.cancelled) + } else { + continuation.resume(throwing: R2SQLError.transport(error.localizedDescription)) + } + return + } + guard let data, let response else { + continuation.resume(throwing: R2SQLError.transport("Empty response from R2 SQL")) + return + } + continuation.resume(returning: (data, response)) + } + lock.withLock { inFlight = task } + task.resume() + } + + lock.withLock { inFlight = nil } + + guard let httpResponse = response as? HTTPURLResponse else { + throw R2SQLError.transport("Response was not HTTP") + } + return R2SQLHTTPResponse(statusCode: httpResponse.statusCode, body: data) + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1424b5378e..0ad0c7de93 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -110,6 +110,9 @@ AC1D0000000000000000F003 /* TableProTrinoCore in Frameworks */ = {isa = PBXBuildFile; productRef = AC1D0000000000000000F002 /* TableProTrinoCore */; }; AC1D0000000000000000F004 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97DB8F422F8F77B13A41B15F /* Cocoa.framework */; }; AC1D0000000000000000F005 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + AC1D0000000000000000F103 /* TableProR2SQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = AC1D0000000000000000F102 /* TableProR2SQLCore */; }; + AC1D0000000000000000F104 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97DB8F422F8F77B13A41B15F /* Cocoa.framework */; }; + AC1D0000000000000000F105 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; B014CCF0DCB575301513A62D /* SnowflakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05C938D4946679DB526884C9 /* SnowflakeConnection.swift */; }; E035AC6C854B14A8D56EDD2F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; EE6F98F0581A8B96C6FE45E9 /* SnowflakeMFATokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CF18A0ED0B637448A808A4 /* SnowflakeMFATokenStore.swift */; }; @@ -433,6 +436,7 @@ 97DB8F422F8F77B13A41B15F /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */ = {isa = PBXFileReference; explicitFileType = "wrapper.plug-in"; includeInIndex = 0; name = TeradataDriver.bundle; path = TeradataDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = "wrapper.plug-in"; includeInIndex = 0; path = TrinoDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + AC1D0000000000000000F101 /* CloudflareR2SQLDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = "wrapper.plug-in"; includeInIndex = 0; path = CloudflareR2SQLDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; C146F286FAB945E9B19E5B88 /* SnowflakeBindingEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeBindingEncoder.swift; sourceTree = ""; }; DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeConnectionRegistry.swift; sourceTree = ""; }; F9D129A56E1AB45F7D82AC58 /* SnowflakeAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnowflakeAuth.swift; sourceTree = ""; }; @@ -698,6 +702,13 @@ ); target = AC1D0000000000000000F00B /* TrinoDriverPlugin */; }; + AC1D0000000000000000F10A /* Exceptions for "Plugins/CloudflareR2SQLDriverPlugin" folder in "CloudflareR2SQLDriverPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = AC1D0000000000000000F10B /* CloudflareR2SQLDriverPlugin */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -937,6 +948,14 @@ path = Plugins/TrinoDriverPlugin; sourceTree = ""; }; + AC1D0000000000000000F109 /* Plugins/CloudflareR2SQLDriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AC1D0000000000000000F10A /* Exceptions for "Plugins/CloudflareR2SQLDriverPlugin" folder in "CloudflareR2SQLDriverPlugin" target */, + ); + path = Plugins/CloudflareR2SQLDriverPlugin; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1222,6 +1241,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC1D0000000000000000F107 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AC1D0000000000000000F104 /* Cocoa.framework in Frameworks */, + AC1D0000000000000000F105 /* TableProPluginKit.framework in Frameworks */, + AC1D0000000000000000F103 /* TableProR2SQLCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C49F7C0CBE979B96ACFB9ABC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1290,6 +1319,7 @@ 8FE5E1F9D0550A0E0AACD3EB /* SnowflakeDriverPlugin */, 6976BFEA1FD6CE97AD30AA12 /* Plugins/TeradataDriverPlugin */, AC1D0000000000000000F009 /* Plugins/TrinoDriverPlugin */, + AC1D0000000000000000F109 /* Plugins/CloudflareR2SQLDriverPlugin */, ); sourceTree = ""; }; @@ -1332,6 +1362,7 @@ 5A2A9AE22FF52C7D0082A7AC /* ElasticsearchDriverPlugin.tableplugin */, A20DCD2775C0BFCD60A79A91 /* TeradataDriver.bundle */, AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */, + AC1D0000000000000000F101 /* CloudflareR2SQLDriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -2159,6 +2190,29 @@ productReference = AC1D0000000000000000F001 /* TrinoDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; }; + AC1D0000000000000000F10B /* CloudflareR2SQLDriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = AC1D0000000000000000F10E /* Build configuration list for PBXNativeTarget "CloudflareR2SQLDriverPlugin" */; + buildPhases = ( + AC1D0000000000000000F106 /* Sources */, + AC1D0000000000000000F107 /* Frameworks */, + AC1D0000000000000000F108 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AC1D0000000000000000F109 /* Plugins/CloudflareR2SQLDriverPlugin */, + ); + name = CloudflareR2SQLDriverPlugin; + packageProductDependencies = ( + AC1D0000000000000000F102 /* TableProR2SQLCore */, + ); + productName = CloudflareR2SQLDriverPlugin; + productReference = AC1D0000000000000000F101 /* CloudflareR2SQLDriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 2919EAF57187D4EFE7E47F98 /* Build configuration list for PBXNativeTarget "SnowflakeDriverPlugin" */; @@ -2323,6 +2377,7 @@ FABFFD08BCD1EEE7219EAE75 /* SnowflakeDriverPlugin */, A95F5AB41510A4EFAAA0F38C /* TeradataDriver */, AC1D0000000000000000F00B /* TrinoDriverPlugin */, + AC1D0000000000000000F10B /* CloudflareR2SQLDriverPlugin */, ); }; /* End PBXProject section */ @@ -2559,6 +2614,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC1D0000000000000000F108 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CEA116D19B343A1E94648AFE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2860,6 +2922,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC1D0000000000000000F106 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -5058,6 +5127,54 @@ }; name = Debug; }; + AC1D0000000000000000F10C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CloudflareR2SQLDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CloudflareR2SQLPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CloudflareR2SQLDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + AC1D0000000000000000F10D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CloudflareR2SQLDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CloudflareR2SQLPlugin"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CloudflareR2SQLDriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; E7AC38103A156E07E8B6F0B9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -5433,6 +5550,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AC1D0000000000000000F10E /* Build configuration list for PBXNativeTarget "CloudflareR2SQLDriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AC1D0000000000000000F10C /* Release */, + AC1D0000000000000000F10D /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -5542,6 +5668,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProTrinoCore; }; + AC1D0000000000000000F102 /* TableProR2SQLCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProR2SQLCore; + }; F2FDCCDDA7AC8E6AE30DAD35 /* TableProTeradataCore */ = { isa = XCSwiftPackageProductDependency; package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; 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 43f2dc9178..0b5f3a9885 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -21,6 +21,7 @@ final class PaginationCoordinator { // MARK: - Pagination func goToNextPage() { + guard parent.supportsOffsetPagination else { return } guard 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 } @@ -28,18 +29,22 @@ final class PaginationCoordinator { } func goToPreviousPage() { + guard parent.supportsOffsetPagination else { return } paginateIfPossible(where: \.hasPreviousPage) { $0.goToPreviousPage() } } func goToFirstPage() { + guard parent.supportsOffsetPagination else { return } paginateIfPossible(where: \.hasPreviousPage) { $0.goToFirstPage() } } func goToLastPage() { + guard parent.supportsOffsetPagination else { return } paginateIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } } func goToPage(_ page: Int) { + guard parent.supportsOffsetPagination else { return } paginateIfPossible(where: { $0.isLastPageKnown && page > 0 && page <= $0.totalPages }) { $0.goToPage(page) } } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index b73dbf9c67..d766dfd987 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -426,6 +426,16 @@ extension PluginManager { .capabilities.supportsReadOnlyMode ?? true } + func supportsOffsetPagination(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsOffsetPagination ?? true + } + + func isEngineReadOnly(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.isEngineReadOnly ?? false + } + func defaultSchemaName(for databaseType: DatabaseType) -> String { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .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..831765e1a9 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift @@ -0,0 +1,120 @@ +// +// 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: true, urlSchemes: [], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#F6821F", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: false, + supportsColumnReorder: 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, + supportsOffsetPagination: false, + 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] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + 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"), + CompletionEntry(label: "COUNT", insertText: "SELECT COUNT(*) AS total FROM namespace.table"), + CompletionEntry(label: "QUALIFY", insertText: "QUALIFY ROW_NUMBER() OVER (ORDER BY column) <= 10"), + CompletionEntry(label: "WITH", insertText: "WITH cte AS (SELECT * FROM namespace.table LIMIT 100) SELECT * FROM cte"), +] + +private let r2SQLColumnTypes: [String: [String]] = [ + "Integer": ["INT32", "INT64"], + "Float": ["FLOAT32", "FLOAT64", "DECIMAL128"], + "String": ["STRING"], + "Date": ["DATE32", "TIMESTAMP"], + "Binary": ["BINARY"], + "Boolean": ["BOOLEAN"], + "Nested": ["ARRAY", "STRUCT", "MAP"], +] diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index b041339dcf..143992786c 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1042,6 +1042,7 @@ extension PluginMetadataRegistry { ) )), ] + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + + r2SQLPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 6ad8ed58ab..d61261bb74 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -63,6 +63,8 @@ struct PluginMetadataSnapshot: Sendable { var supportsClientKeyPassphrase: Bool = false var supportsConnectionPooling: Bool = true var authenticationIsDatabaseScoped: Bool = false + var supportsOffsetPagination: Bool = true + var isEngineReadOnly: Bool = false var supportsSOCKSProxy: Bool { supportsSSH } @@ -1093,7 +1095,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, supportsCloudflareTunnel: driverType.supportsSSH, - supportsClientKeyPassphrase: existingSnapshot?.capabilities.supportsClientKeyPassphrase ?? false + supportsClientKeyPassphrase: existingSnapshot?.capabilities.supportsClientKeyPassphrase ?? false, + supportsOffsetPagination: existingSnapshot?.capabilities.supportsOffsetPagination ?? true, + isEngineReadOnly: existingSnapshot?.capabilities.isEngineReadOnly ?? false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index fc8d586a06..82ea79169b 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -63,7 +63,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/Execution/DefaultExecutionGate.swift b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift index bbbf2e33b8..d762889017 100644 --- a/TablePro/Core/Services/Execution/DefaultExecutionGate.swift +++ b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift @@ -8,13 +8,13 @@ import Foundation internal actor DefaultExecutionGate: ExecutionGate { private let confirming: OperationConfirming private let authenticating: OperationAuthenticating - private let safeModeLevelResolver: @Sendable (UUID) async -> SafeModeLevel + private let safeModeLevelResolver: @Sendable (UUID, DatabaseType) async -> SafeModeLevel private let forcesWriteResolver: @Sendable (DatabaseType) async -> Bool init( confirming: OperationConfirming, authenticating: OperationAuthenticating, - safeModeLevelResolver: @escaping @Sendable (UUID) async -> SafeModeLevel, + safeModeLevelResolver: @escaping @Sendable (UUID, DatabaseType) async -> SafeModeLevel, forcesWriteResolver: @escaping @Sendable (DatabaseType) async -> Bool ) { self.confirming = confirming @@ -24,7 +24,7 @@ internal actor DefaultExecutionGate: ExecutionGate { } func authorize(_ request: OperationRequest) async -> OperationDecision { - let level = await safeModeLevelResolver(request.connectionId) + let level = await safeModeLevelResolver(request.connectionId, request.databaseType) let caps = request.capabilities let tier = request.sql.map { QueryClassifier.classifyTier($0, databaseType: request.databaseType) } diff --git a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift index 204ab847f5..9a0fd257ae 100644 --- a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift +++ b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift @@ -9,8 +9,11 @@ internal enum ExecutionGateProvider { static let shared: ExecutionGate = DefaultExecutionGate( confirming: AlertOperationConfirming(), authenticating: BiometricOperationAuthenticating(), - safeModeLevelResolver: { connectionId in + safeModeLevelResolver: { connectionId, databaseType in await MainActor.run { + if PluginManager.shared.isEngineReadOnly(for: databaseType) { + return .readOnly + } switch DatabaseManager.shared.connectionState(connectionId) { case .live(_, let session): return session.safeModeLevel diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 5b5441edf1..bef6733a5f 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 supportsOffsetPagination: Bool private let dialectQuote: (String) -> String // MARK: - Initialization @@ -24,11 +25,13 @@ struct TableQueryBuilder { databaseType: DatabaseType, pluginDriver: (any PluginDatabaseDriver)? = nil, dialect: SQLDialectDescriptor? = nil, + supportsOffsetPagination: Bool = true, dialectQuote: ((String) -> String)? = nil ) { self.databaseType = databaseType self.pluginDriver = pluginDriver self.dialect = dialect + self.supportsOffsetPagination = supportsOffsetPagination self.dialectQuote = dialectQuote ?? { name in let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" @@ -197,6 +200,9 @@ struct TableQueryBuilder { } private func buildPaginationClause(limit: Int, offset: Int) -> String { + guard supportsOffsetPagination else { + return "LIMIT \(limit)" + } if let dialect, dialect.paginationStyle == .offsetFetch { return "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index b45d3c741f..b44d04b61d 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -134,6 +134,7 @@ struct QueryTab: Identifiable, Equatable { databaseType: databaseType, pluginDriver: nil, dialect: dialect, + supportsOffsetPagination: PluginManager.shared.supportsOffsetPagination(for: databaseType), dialectQuote: quoteIdentifier ?? quoteIdentifierFromDialect(dialect) ) return builder.buildBaseQuery( diff --git a/TablePro/Models/Query/StatusBarSnapshot.swift b/TablePro/Models/Query/StatusBarSnapshot.swift index 7636d206e3..2fa2f37046 100644 --- a/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/TablePro/Models/Query/StatusBarSnapshot.swift @@ -14,6 +14,7 @@ struct StatusBarSnapshot: Equatable { let hasTableName: Bool let pagination: PaginationState let statusMessage: String? + let supportsPaging: Bool init( tabId: UUID?, @@ -23,7 +24,8 @@ struct StatusBarSnapshot: Equatable { rowCount: Int, hasTableName: Bool, pagination: PaginationState, - statusMessage: String? + statusMessage: String?, + supportsPaging: Bool = true ) { self.tabId = tabId self.tabType = tabType @@ -33,9 +35,10 @@ struct StatusBarSnapshot: Equatable { self.hasTableName = hasTableName self.pagination = pagination self.statusMessage = statusMessage + self.supportsPaging = supportsPaging } - init(tab: QueryTab?, tableRows: TableRows?) { + init(tab: QueryTab?, tableRows: TableRows?, supportsPaging: Bool = true) { self.init( tabId: tab?.id, tabType: tab?.tabType, @@ -44,11 +47,13 @@ struct StatusBarSnapshot: Equatable { rowCount: tableRows?.rows.count ?? 0, hasTableName: tab?.tableContext.tableName != nil, pagination: tab?.pagination ?? PaginationState(), - statusMessage: tab?.execution.statusMessage + statusMessage: tab?.execution.statusMessage, + supportsPaging: supportsPaging ) } var showsPaginationControls: Bool { + guard supportsPaging else { return rowCount > 0 } if let total = pagination.totalRowCount, total > 0 { return true } return isPagedWithUnknownTotal } diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index de975bf866..8ca8081d65 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -8,6 +8,7 @@ import SwiftUI struct PaginationControlsView: View { let pagination: PaginationState let loadedRowCount: Int + var supportsPaging: Bool = true let onFirst: () -> Void let onPrevious: () -> Void let onNext: () -> Void @@ -28,8 +29,31 @@ struct PaginationControlsView: View { var body: some View { HStack(spacing: 8) { pageSizeMenu - navigationCluster + if supportsPaging { + navigationCluster + } else { + singlePageIndicator + } + } + } + + private var singlePageIndicator: some View { + HStack(spacing: 4) { + if pagination.isLoading { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "Loading rows")) + } + Text(singlePageText) + .font(.caption) + .foregroundStyle(.secondary) } + .help(String(localized: "This engine does not support paging through results. Filter or sort to narrow them.")) + .accessibilityLabel(singlePageText) + } + + private var singlePageText: String { + String(format: String(localized: "First %d rows"), loadedRowCount) } // MARK: - Page Size Menu diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index eef64f6924..8ab21e55dd 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -218,6 +218,9 @@ final class ConnectionFormCoordinator { network.applyTypeDefaults(forNewType: newType) } ssl.resetForType(newType) + if services.pluginManager.isEngineReadOnly(for: newType) { + customization.safeModeLevel = .readOnly + } } // MARK: - Save diff --git a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift index 464e19d672..225ab6c961 100644 --- a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift @@ -28,9 +28,19 @@ struct CustomizationPaneView: View { Text(level.displayName).tag(level) } } + .disabled(isEngineReadOnly) + .help(isEngineReadOnly ? Self.engineReadOnlyHelp : "") } } .formStyle(.grouped) .scrollContentBackground(.hidden) } + + private var isEngineReadOnly: Bool { + PluginManager.shared.isEngineReadOnly(for: coordinator.network.type) + } + + private static let engineReadOnlyHelp = String( + localized: "This engine only runs read queries, so the connection is always read-only." + ) } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 9f318f7235..dc9ca29d5b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -798,7 +798,11 @@ struct MainEditorContentView: View { private func statusBar(tab: QueryTab) -> some View { let resolvedRows = resolvedTableRows(for: tab) return MainStatusBarView( - snapshot: StatusBarSnapshot(tab: tab, tableRows: resolvedRows), + snapshot: StatusBarSnapshot( + tab: tab, + tableRows: resolvedRows, + supportsPaging: coordinator.supportsOffsetPagination + ), filterState: tab.filterState, selectedRowIndices: selectionState.indices, viewMode: resultsViewModeBinding(for: tab), diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 181750a682..189ac452fb 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -240,6 +240,7 @@ struct MainStatusBarView: View { PaginationControlsView( pagination: snapshot.pagination, loadedRowCount: snapshot.rowCount, + supportsPaging: snapshot.supportsPaging, onFirst: paginationCallbacks.onFirst, onPrevious: paginationCallbacks.onPrevious, onNext: paginationCallbacks.onNext, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index 3242583cb0..094f9514bd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -76,7 +76,7 @@ extension MainContentCoordinator { in: effectiveResultColumns(for: tab) ) let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize - let page = max(1, tab.restoredPage ?? 1) + let page = supportsOffsetPagination ? max(1, tab.restoredPage ?? 1) : 1 tabManager.mutate(at: index) { tab in tab.pendingRestoredSort = nil diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 23090cb685..1973d6a1e2 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -80,6 +80,9 @@ final class MainContentCoordinator { services.databaseManager.activeDatabaseName(for: connection) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } + var supportsOffsetPagination: Bool { + services.pluginManager.supportsOffsetPagination(for: connection.type) + } func setSafeModeLevel(_ level: SafeModeLevel) { toolbarState.safeModeLevel = level services.databaseManager.setSafeModeLevel(level, for: connectionId) @@ -474,6 +477,7 @@ final class MainContentCoordinator { self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, + supportsOffsetPagination: services.pluginManager.supportsOffsetPagination(for: connection.type), dialectQuote: dialect.map { quoteIdentifierFromDialect($0) } ) self.persistence = TabPersistenceCoordinator(connectionId: connection.id) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 6578987277..8f89883428 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -228,10 +228,15 @@ struct SidebarView: View { .menuIndicator(.hidden) .fixedSize() .help(String(localized: "Create a new table or view")) - .disabled(coordinator?.safeModeLevel.blocksAllWrites ?? true) + .disabled(!canCreateObjects) .accessibilityIdentifier("sidebar-create-table") } + private var canCreateObjects: Bool { + guard let coordinator, !coordinator.safeModeLevel.blocksAllWrites else { return false } + return viewModel.databaseType.supportsSchemaEditing + } + private var usesDatabaseTree: Bool { PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) && sidebarState.sidebarLayout == .tree 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/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index a277470bd2..15cea856da 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/Execution/ExecutionGateTests.swift b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift index 4706005029..477f13d72d 100644 --- a/TableProTests/Core/Services/Execution/ExecutionGateTests.swift +++ b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift @@ -55,7 +55,7 @@ struct ExecutionGateTests { DefaultExecutionGate( confirming: confirm, authenticating: auth, - safeModeLevelResolver: { _ in level }, + safeModeLevelResolver: { _, _ in level }, forcesWriteResolver: { _ in forcesWrite } ) } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 381c4e3e0b..fb056b3033 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -172,7 +172,7 @@ The **Integrations** tab runs the MCP server. **Enable MCP Server** (default off Two sub-tabs, each a split view with the plugin list on the left and details on the right. - **Installed**: bundled plugins (MySQL, PostgreSQL, SQLite, ClickHouse, and Redis drivers; CSV, JSON, SQL, XLSX, and MQL exporters; SQL, JSON, and CSV importers; CSV Inspector) plus anything you installed. Toggle a plugin on or off in the detail pane. Bundled plugins can be disabled but not uninstalled. To install from a file, click **+** below the list or drag a `.tableplugin` or `.zip` onto it. TablePro verifies the code signature before loading. -- **Browse**: install from the registry, with search and a category filter (Database Drivers, Export Formats, Import Formats, Themes, Other). Registry drivers: MongoDB, Oracle, DuckDB, SQL Server, Cassandra, Etcd, Cloudflare D1, DynamoDB, BigQuery, Snowflake, Elasticsearch, LibSQL, SurrealDB, Beancount, Teradata, and Trino. +- **Browse**: install from the registry, with search and a category filter (Database Drivers, Export Formats, Import Formats, Themes, Other). Registry drivers: MongoDB, Oracle, DuckDB, SQL Server, Cassandra, Etcd, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, BigQuery, Snowflake, Elasticsearch, LibSQL, SurrealDB, Beancount, Teradata, and Trino. See [Plugins & Themes](/features/plugins) for installing, updating, and managing plugins. diff --git a/docs/databases/cloudflare-r2-sql.mdx b/docs/databases/cloudflare-r2-sql.mdx new file mode 100644 index 0000000000..98ff76e86f --- /dev/null +++ b/docs/databases/cloudflare-r2-sql.mdx @@ -0,0 +1,114 @@ +--- +title: Cloudflare R2 SQL +description: Run read-only SQL against Apache Iceberg tables in a Cloudflare R2 bucket +--- + +TablePro connects to Cloudflare R2 SQL, the serverless query engine that reads Apache Iceberg tables stored in an R2 bucket. The bucket's tables are registered in R2 Data Catalog, and TablePro queries them over the R2 SQL HTTP API at `https://api.sql.cloudflarestorage.com`. There is no host, port, or tunnel involved. + +R2 SQL runs `SELECT` and nothing else, so these connections are read-only. See [Read-Only Connections](#read-only-connections). + +Cloudflare's own reference is the [R2 SQL documentation](https://developers.cloudflare.com/r2-sql/). + +## Install the Plugin + +Cloudflare R2 SQL is a registry driver. Pick **Cloudflare R2 SQL** in the database type chooser and TablePro offers to download it, or install it up front from **Settings > Plugins > Browse > Cloudflare R2 SQL Driver**. The driver loads without restarting the app. See [Plugins](/features/plugins). + +## Connection Settings + +| Field | Description | +|-------|-------------| +| **Account ID** | Your Cloudflare account ID. | +| **Bucket** | The R2 bucket holding the Iceberg tables. It needs R2 Data Catalog enabled. | +| **API Token** | Cloudflare API token, entered in the built-in password field (labeled **API Token** for this driver). Stored in the macOS Keychain. | + +That is the whole form. There is no username, port, SSH Tunnel pane, or SSL/TLS pane: the API is HTTPS only. The Iceberg warehouse name is derived from the account ID and the bucket, so you never type it. + +Click **Test Connection** to verify, then **Save & Connect**. + +## Getting Your Credentials + +**Account ID**: on the [Cloudflare dashboard](https://dash.cloudflare.com) right sidebar, or run `npx wrangler whoami`. + +**Bucket**: the bucket name from **R2 Object Storage** in the dashboard. Enable R2 Data Catalog on it first. A bucket without the catalog has no tables to query. + +**API Token**: + +1. Go to [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) +2. Click **Create Token** and pick the **Custom token** template +3. Add three permission groups: **R2 SQL**, **R2 Data Catalog**, and **R2 Storage** +4. Save and copy the token + +All three groups are needed, one per layer the query touches: R2 SQL runs the query, R2 Data Catalog lists the namespaces and tables, R2 Storage reads the data files. + + +The token grants access to R2 across your account, not just this bucket. Scope it to the account you need and rotate it like any other credential. + + +## Namespaces and Tables + +Iceberg groups tables into namespaces. TablePro maps a namespace to a schema, so the sidebar shows one **Namespace** node per namespace with its tables underneath, and the switcher in the toolbar reads **Namespace** too. TablePro reads the tree with `SHOW NAMESPACES`, `SHOW TABLES IN `, and `DESCRIBE .`. + +Qualify tables with their namespace in a query tab: + +```sql +SELECT user_id, event, ts +FROM default.events +WHERE ts >= TIMESTAMP '2026-01-01 00:00:00' +ORDER BY ts DESC +LIMIT 1000 +``` + +One connection covers one bucket. Add another connection for another bucket. + +## Read-Only Connections + +R2 SQL has no `INSERT`, `UPDATE`, `DELETE`, or DDL. TablePro pins the connection to Safe Mode **Read-Only** and disables the Safe Mode picker in the connection form, so: + +- Cell editing, row insert, row delete, and duplicate row are off in the data grid +- The Structure tab lists columns, types, and nullability, and creates or alters nothing +- Import is unavailable. Export works. See [Import and Export](/features/import-export) + +Loading these tables happens outside TablePro, through whichever Iceberg writer feeds the catalog. See [Safe Mode](/features/safe-mode). + +## Pagination + +R2 SQL rejects `OFFSET` and caps `LIMIT` at 10,000 rows. A query with no `LIMIT` returns 500 rows. + +Without `OFFSET` there is no way to skip rows, so a table tab loads one capped page and the First / Previous / Next / Last controls are hidden. Two ways to work through a large table: + +- Narrow it with [filters](/features/filtering) and sorting. Both compile into the query, so the server does the work. +- Write keyset pagination in a query tab, carrying the last key of the previous page forward: + +```sql +SELECT * FROM default.events +WHERE event_id > '01HQ7Z2K3M4N5P6Q7R8S9T0V' +ORDER BY event_id +LIMIT 1000 +``` + +## SQL Support + +Supported: `SELECT`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, `LIMIT`, joins, subqueries, CTEs, window functions with an inline `OVER` clause, set operations, `EXPLAIN`, and `EXPLAIN FORMAT JSON`. + +Not supported: `OFFSET`, `LATERAL`, `UNNEST`, `PIVOT` and `UNPIVOT`, joins nested in parentheses, `PERCENTILE_DISC`, and the named `WINDOW` clause. An inline `OVER (...)` covers what a named window would. + +The Explain dropdown in the query editor offers **Explain** (`EXPLAIN`) and **Explain (JSON)** (`EXPLAIN FORMAT JSON`). Both show the plan as raw text. See [Explain Visualization](/features/explain-visualization). + +## Troubleshooting + +**Authentication failed**: check the token carries all three permission groups, the Account ID belongs to the account that owns the bucket, and the token has not expired or been revoked. + +**No namespaces after connect**: the bucket has no R2 Data Catalog enabled, or the catalog holds no tables yet. + +**`unsupported feature: OFFSET clause is not supported`**: a query in the editor uses `OFFSET`. Rewrite it with keyset pagination, see [Pagination](#pagination). + +**Only 500 rows came back**: the query had no `LIMIT`, so R2 SQL applied its default. Add an explicit `LIMIT`, up to 10,000. + +## Limitations + +- Read-only. No writes, no DDL, no transactions. +- No `OFFSET`, and 10,000 rows per query is the ceiling, so table tabs show a single page. +- No primary keys, foreign keys, or indexes. The ER diagram opens with every table unconnected. +- No import. Export works. +- No SSH tunnel, Cloudflare Tunnel, SOCKS proxy, or SSL/TLS pane. The API is HTTPS only. +- One bucket per connection, and no bucket switcher in the toolbar. diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index d31c749344..d75b1e522d 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -3,7 +3,7 @@ title: Managing Connections description: Create, organize, and switch database connections, with health monitoring and startup commands. --- -TablePro connects to 25 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. +TablePro connects to 26 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. ## Supported Databases @@ -33,6 +33,7 @@ TablePro connects to 25 databases through its plugin system. This page covers cr | [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | No | | [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | No | | [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | +| [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) | Cloud API | No | No | No | No | No | | [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | Transport details: [SSH Tunneling](/databases/ssh-tunneling), [SSL/TLS](/features/ssl), [Cloudflare Tunnel](/databases/cloudflare-tunnel), [Cloud SQL Auth Proxy](/databases/cloud-sql-proxy), [SOCKS Proxy](/databases/socks-proxy). diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index c2942b3243..1d22bc7cd9 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -5,7 +5,7 @@ description: Route database connections through an SSH tunnel to reach servers i TablePro tunnels database connections through SSH using a built-in libssh2 client. No `ssh` binary or external setup is required. The tunnel opens a local forwarding port in the 60000-65000 range (this is why macOS may show a network permission prompt), sends a keep-alive every 30 seconds, and reconnects automatically if the tunnel dies. Before handing that port to the database driver, TablePro checks that the SSH server can actually reach the destination, so a wrong host or a blocked forward fails with the real reason instead of a database timeout. -The **SSH Tunnel** pane appears only for databases whose driver supports it. SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, DynamoDB, Elasticsearch, and Snowflake do not show it: they are reached over a local file, a loopback socket, or a vendor HTTP API. +The **SSH Tunnel** pane appears only for databases whose driver supports it. SQLite, PGlite, libSQL, Beancount, BigQuery, Cloudflare D1, Cloudflare R2 SQL, DynamoDB, Elasticsearch, and Snowflake do not show it: they are reached over a local file, a loopback socket, or a vendor HTTP API. ```mermaid flowchart LR diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index db83c5e000..9963ae18a2 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -94,6 +94,7 @@ The remaining 16 driver plugins are downloaded on demand from the [plugin regist | CassandraDriverPlugin | Cassandra, ScyllaDB | CCassandra | | EtcdDriverPlugin | Etcd | HTTP (gRPC-gateway JSON) | | CloudflareD1DriverPlugin | Cloudflare D1 | HTTP (URLSession) | +| CloudflareR2SQLDriverPlugin | Cloudflare R2 SQL | HTTP (URLSession), read-only | | DynamoDBDriverPlugin | DynamoDB | HTTP with hand-rolled SigV4 request signing | | BigQueryDriverPlugin | BigQuery | REST (URLSession) | | SnowflakeDriverPlugin | Snowflake | REST (URLSession) | diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 46697ba9fd..3136744571 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -112,6 +112,7 @@ The full field list is `RegistryPluginMetadata` in `TablePro/Core/Plugins/Regist | `plugin-cassandra` | `"Cassandra"`, `"ScyllaDB"` | | `plugin-etcd` | `"etcd"` | | `plugin-cloudflare-d1` | `"Cloudflare D1"` | +| `plugin-cloudflare-r2-sql` | `"Cloudflare R2 SQL"` | | `plugin-dynamodb` | `"DynamoDB"` | | `plugin-bigquery` | `"BigQuery"` | | `plugin-snowflake` | `"Snowflake"` | diff --git a/docs/docs.json b/docs/docs.json index 5ce68d7816..03486e96ec 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -85,7 +85,8 @@ "databases/dynamodb", "databases/bigquery", "databases/snowflake", - "databases/cloudflare-d1" + "databases/cloudflare-d1", + "databases/cloudflare-r2-sql" ] }, { diff --git a/docs/features/er-diagram.mdx b/docs/features/er-diagram.mdx index 3534a49514..ffdb0a17e6 100644 --- a/docs/features/er-diagram.mdx +++ b/docs/features/er-diagram.mdx @@ -91,7 +91,7 @@ These drivers report foreign keys, so their diagrams draw edges: | LibSQL / Turso | Full | | Cloudflare D1 | Full | -Every other driver reports no foreign keys: ClickHouse, BigQuery, Trino, Teradata, Cassandra / ScyllaDB, MongoDB, Redis, DynamoDB, Elasticsearch, etcd, SurrealDB, and Beancount. **View ER Diagram** still works on those connections, so the diagram opens with every table unconnected. +Every other driver reports no foreign keys: ClickHouse, BigQuery, Trino, Teradata, Cassandra / ScyllaDB, MongoDB, Redis, DynamoDB, Elasticsearch, etcd, SurrealDB, Cloudflare R2 SQL, and Beancount. **View ER Diagram** still works on those connections, so the diagram opens with every table unconnected. The diagram loads columns and foreign keys for the current schema. Foreign keys that reference a table outside the loaded schema are not drawn. diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 8a7db3c9b7..07b5eff1d1 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -57,6 +57,7 @@ Toggle between three views using the segmented control above the results: | ClickHouse | Plan, Pipeline, AST, Syntax, Estimate | Indented text, parsed into diagram and tree | | DuckDB | Explain | Indented text, parsed into diagram and tree | | Cloudflare D1 | Query Plan | EXPLAIN QUERY PLAN, raw text | +| Cloudflare R2 SQL | Explain, Explain (JSON) | `EXPLAIN` and `EXPLAIN FORMAT JSON`, raw text | | LibSQL / Turso | Query Plan | EXPLAIN QUERY PLAN, raw text | | Snowflake | Explain (Text) | Raw text | | Trino | Explain (Logical), Explain (Distributed), Explain (IO), Explain (Validate), Explain Analyze | Raw text | diff --git a/docs/features/plugins.mdx b/docs/features/plugins.mdx index 02fda56a0b..c77238605b 100644 --- a/docs/features/plugins.mdx +++ b/docs/features/plugins.mdx @@ -3,7 +3,7 @@ title: Plugins & Themes description: Install database drivers, import and export formats, and themes from the plugin registry, and keep them updated. --- -TablePro loads database drivers, import/export formats, and themes as `.tableplugin` bundles. Five driver plugins ship inside the app and cover 9 databases; 16 more download on demand from the plugin registry. +TablePro loads database drivers, import/export formats, and themes as `.tableplugin` bundles. Five driver plugins ship inside the app and cover 9 databases; 17 more download on demand from the plugin registry. ## Bundled plugins @@ -33,6 +33,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 9934209aa7..eca0408f68 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -20,6 +20,8 @@ Set the Safe Mode level in the **Customization** pane of the connection form. New connections default to **Silent**. +Some engines run read queries and nothing else. [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) is one: its connections are pinned to **Read-Only** and the Safe Mode picker in the connection form is disabled. + ## How It Works ### Silent diff --git a/docs/features/ssl.mdx b/docs/features/ssl.mdx index 62ea790382..1476d3acbd 100644 --- a/docs/features/ssl.mdx +++ b/docs/features/ssl.mdx @@ -33,7 +33,7 @@ New connections pick the mode that matches each driver's native behavior. Open t | Oracle | Disabled | Preferred connects in plain TCP. Use Required to enforce TCPS. | | Teradata | Disabled | Preferred tries TLS and drops to plain TCP if the handshake fails | | Trino | Disabled | Preferred switches the HTTP client to HTTPS with no fallback, same as Required | -| Snowflake, BigQuery, DynamoDB, Cloudflare D1, libSQL / Turso | Always encrypted | HTTPS-based drivers manage TLS themselves; no SSL/TLS pane | +| Snowflake, BigQuery, DynamoDB, Cloudflare D1, Cloudflare R2 SQL, libSQL / Turso | Always encrypted | HTTPS-based drivers manage TLS themselves; no SSL/TLS pane | | SQLite, DuckDB, Beancount | N/A | Local files, no network protocol | | PGlite | N/A | No SSL support, so the connection form has no SSL/TLS pane | diff --git a/docs/index.mdx b/docs/index.mdx index f0404b934b..b63bfc4933 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,9 +1,9 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 20 more. +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 21 more. --- -Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. +Native macOS client for 26 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. TablePro main interface @@ -21,7 +21,7 @@ Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron **[Safe Mode](/features/safe-mode)**: 6 per-connection protection levels, from no prompt at all to confirmation dialogs, Touch ID, and read-only. **[Import & Export](/features/import-export)**: CSV, JSON, SQL, XLSX, MQL. Streaming export for large datasets. **[CSV Inspector](/features/csv-inspector)**: Open `.csv` and `.tsv` files directly. Edit cells, insert and delete rows and columns, save in the original dialect. -**[Plugin System](/features/plugins)**: 5 bundled drivers covering 9 databases, plus 16 more drivers installable from the plugin registry. +**[Plugin System](/features/plugins)**: 5 bundled drivers covering 9 databases, plus 17 more drivers installable from the plugin registry. **[iCloud Sync](/features/icloud-sync)**: Connections, groups, tags, settings, SSH profiles, saved queries and folders, favorite tables, and custom AI slash commands sync across Macs. **[Themes](/customization/appearance)**: Light, dark, and custom editor themes. Per-connection color labels. @@ -46,6 +46,7 @@ Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron | Cassandra / ScyllaDB | 9042 | Plugin | | etcd | 2379 | Plugin | | Cloudflare D1 | N/A (API-based) | Plugin | +| Cloudflare R2 SQL | N/A (API-based) | Plugin | | DynamoDB | N/A (API-based) | Plugin | | BigQuery | N/A (API-based) | Plugin | | Snowflake | N/A (API-based) | Plugin | diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index b918ab09dc..4f168d4b5e 100755 --- a/scripts/release-all-plugins.sh +++ b/scripts/release-all-plugins.sh @@ -35,6 +35,7 @@ PLUGINS=( cassandra etcd cloudflare-d1 + cloudflare-r2-sql dynamodb bigquery snowflake From 83a9fad10194ebfa47e6cd459e4796b12362be6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 17:57:37 +0700 Subject: [PATCH 2/7] fix(connections): hold read-only engines and remote database files at Safe Mode Read-Only Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- CHANGELOG.md | 2 + .../Database/DatabaseManager+RemoteFile.swift | 10 +- .../Database/DatabaseManager+Sessions.swift | 12 +- ...ginMetadataRegistry+RegistryDefaults.swift | 1 + .../Execution/DefaultExecutionGate.swift | 6 +- .../Execution/ExecutionGateProvider.swift | 5 +- .../Export/ConnectionExportService.swift | 3 +- ...inSplitViewController+MenuValidation.swift | 11 ++ TablePro/Core/Storage/ConnectionStorage.swift | 6 +- TablePro/Core/Storage/StoredConnection.swift | 2 +- TablePro/Core/Sync/SyncRecordMapper.swift | 4 +- .../DatabaseConnection+SafeMode.swift | 27 ++++ .../Connection/DatabaseConnection.swift | 8 +- TablePro/Models/Connection/DatabaseType.swift | 1 + .../Connection/ReadOnlyEnforcement.swift | 40 ++++++ .../ConnectionFormCoordinator+SafeMode.swift | 16 +++ .../ConnectionFormCoordinator.swift | 3 - .../Panes/OptionsPaneView.swift | 31 +++-- .../Support/ConnectionFormEdits.swift | 2 +- .../CustomizationPaneViewModel.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 2 +- .../Execution/ExecutionGateTests.swift | 2 +- .../SyncRecordMapperConnectionTests.swift | 10 ++ .../Models/ReadOnlyEnforcementTests.swift | 125 ++++++++++++++++++ .../ConnectionFormEditsCoverageTests.swift | 4 +- docs/databases/beancount.mdx | 2 +- docs/databases/sqlite.mdx | 2 +- docs/features/safe-mode.mdx | 10 +- 28 files changed, 292 insertions(+), 57 deletions(-) create mode 100644 TablePro/Models/Connection/DatabaseConnection+SafeMode.swift create mode 100644 TablePro/Models/Connection/ReadOnlyEnforcement.swift create mode 100644 TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift create mode 100644 TableProTests/Models/ReadOnlyEnforcementTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cc80966ae5..272552d7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ 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 @@ -45,6 +46,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/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..be34133f72 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) } } @@ -590,9 +590,11 @@ extension DatabaseManager { 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/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 49a30c01c9..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( diff --git a/TablePro/Core/Services/Execution/DefaultExecutionGate.swift b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift index 25798d6be3..88b591a7ab 100644 --- a/TablePro/Core/Services/Execution/DefaultExecutionGate.swift +++ b/TablePro/Core/Services/Execution/DefaultExecutionGate.swift @@ -8,14 +8,14 @@ import Foundation internal actor DefaultExecutionGate: ExecutionGate { private let confirming: OperationConfirming private let authenticating: OperationAuthenticating - private let safeModeLevelResolver: @Sendable (UUID, DatabaseType) async -> SafeModeLevel + private let safeModeLevelResolver: @Sendable (UUID) async -> SafeModeLevel private let forcesWriteResolver: @Sendable (DatabaseType) async -> Bool private let auditLog: any ExecutionAuditLogging init( confirming: OperationConfirming, authenticating: OperationAuthenticating, - safeModeLevelResolver: @escaping @Sendable (UUID, DatabaseType) async -> SafeModeLevel, + safeModeLevelResolver: @escaping @Sendable (UUID) async -> SafeModeLevel, forcesWriteResolver: @escaping @Sendable (DatabaseType) async -> Bool, auditLog: any ExecutionAuditLogging = ExecutionAuditLog.shared ) { @@ -35,7 +35,7 @@ internal actor DefaultExecutionGate: ExecutionGate { } private func decide(_ request: OperationRequest) async -> OperationDecision { - let level = await safeModeLevelResolver(request.connectionId, request.databaseType) + let level = await safeModeLevelResolver(request.connectionId) let caps = request.capabilities let tier = request.sql.map { QueryClassifier.classifyTier($0, databaseType: request.databaseType) } diff --git a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift index 9b945cddce..2c65d5114d 100644 --- a/TablePro/Core/Services/Execution/ExecutionGateProvider.swift +++ b/TablePro/Core/Services/Execution/ExecutionGateProvider.swift @@ -9,11 +9,8 @@ internal enum ExecutionGateProvider { static let shared: ExecutionGate = DefaultExecutionGate( confirming: AlertOperationConfirming(), authenticating: BiometricOperationAuthenticating(), - safeModeLevelResolver: { connectionId, databaseType in + safeModeLevelResolver: { connectionId in let connectionLevel: SafeModeLevel = await MainActor.run { - if PluginManager.shared.isEngineReadOnly(for: databaseType) { - return .readOnly - } switch DatabaseManager.shared.connectionState(connectionId) { case .live(_, let session): return session.safeModeLevel 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/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index f5d792fb6d..e3a45341d5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -356,6 +356,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) @@ -434,6 +435,16 @@ 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 ReadOnlyEnforcement.allowsChoosing( + level, + under: commandActions?.coordinator?.connection.readOnlyEnforcement + ) + } + 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/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/Models/Connection/DatabaseConnection+SafeMode.swift b/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift new file mode 100644 index 0000000000..d4b22f7bbd --- /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 Read-Only when the + /// connection cannot be written to. + /// + /// Every reader asks this one property, so a connection that cannot be written to reads as + /// Read-Only 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 { readOnlyEnforcement == nil ? preferredSafeModeLevel : .readOnly } + set { preferredSafeModeLevel = newValue } + } + + var readOnlyEnforcement: ReadOnlyEnforcement? { + ReadOnlyEnforcement.resolve( + isEngineReadOnly: PluginMetadataRegistry.shared.snapshot(for: type)?.capabilities.isEngineReadOnly ?? false, + opensRemoteDatabaseFile: opensRemoteDatabaseFile + ) + } +} 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 ba6a315138..6353007084 100644 --- a/TablePro/Models/Connection/DatabaseType.swift +++ b/TablePro/Models/Connection/DatabaseType.swift @@ -35,6 +35,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/ReadOnlyEnforcement.swift b/TablePro/Models/Connection/ReadOnlyEnforcement.swift new file mode 100644 index 0000000000..f5a2dc8a94 --- /dev/null +++ b/TablePro/Models/Connection/ReadOnlyEnforcement.swift @@ -0,0 +1,40 @@ +// +// ReadOnlyEnforcement.swift +// TablePro +// + +import Foundation + +/// Why a connection runs at Read-Only whatever Safe Mode level the user picked. +/// +/// These are facts about the connection, not policy, so they are never written into the user's +/// own setting: switching the connection's type or turning the remote file off hands back the +/// level the user chose. +internal enum ReadOnlyEnforcement: 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 + + static func resolve(isEngineReadOnly: Bool, opensRemoteDatabaseFile: Bool) -> ReadOnlyEnforcement? { + if isEngineReadOnly { return .readOnlyEngine } + if opensRemoteDatabaseFile { return .remoteDatabaseFile } + return nil + } + + static func allowsChoosing(_ level: SafeModeLevel, under enforcement: ReadOnlyEnforcement?) -> Bool { + enforcement == nil || level == .readOnly + } + + var explanation: String { + switch self { + 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." + ) + } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift new file mode 100644 index 0000000000..80c3408ff5 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift @@ -0,0 +1,16 @@ +// +// ConnectionFormCoordinator+SafeMode.swift +// TablePro +// + +import Foundation + +@MainActor +extension ConnectionFormCoordinator { + var readOnlyEnforcement: ReadOnlyEnforcement? { + ReadOnlyEnforcement.resolve( + isEngineReadOnly: services.pluginManager.isEngineReadOnly(for: network.type), + opensRemoteDatabaseFile: transport == .remoteFile + ) + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 55272a6ea4..48261b9a65 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -241,9 +241,6 @@ final class ConnectionFormCoordinator { network.applyTypeDefaults(forNewType: newType) } ssl.resetForType(newType) - if services.pluginManager.isEngineReadOnly(for: newType) { - customization.safeModeLevel = .readOnly - } } // MARK: - Save diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index b036c0a177..f5988984fc 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -17,11 +17,6 @@ struct OptionsPaneView: View { private var databaseType: DatabaseType { coordinator.network.type } private var aiIsEnabled: Bool { AppSettingsManager.shared.ai.enabled } - private var isEngineReadOnly: Bool { PluginManager.shared.isEngineReadOnly(for: databaseType) } - - private static let engineReadOnlyHelp = String( - localized: "This engine only runs read queries, so the connection is always read-only." - ) var body: some View { Form { @@ -101,13 +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) - } - } - .disabled(isEngineReadOnly) - .help(isEngineReadOnly ? Self.engineReadOnlyHelp : "") + safeModeRow if aiIsEnabled { Picker(String(localized: "AI Policy"), selection: $coordinator.advanced.aiPolicy) { Text(String(localized: "Use Default")) @@ -131,9 +120,25 @@ struct OptionsPaneView: View { } } + @ViewBuilder + private var safeModeRow: some View { + if coordinator.readOnlyEnforcement != nil { + LabeledContent(String(localized: "Safe Mode"), value: SafeModeLevel.readOnly.displayName) + } else { + Picker(String(localized: "Safe Mode"), selection: $coordinator.customization.safeModeLevel) { + ForEach(SafeModeLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + } + } + @ViewBuilder private var accessFooter: some View { - Group { + VStack(alignment: .leading, spacing: 4) { + if let enforcement = coordinator.readOnlyEnforcement { + Text(enforcement.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/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ab70113d05..b92e8e0456 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) + toolbarState.safeModeLevel = services.databaseManager.session(for: connectionId)?.safeModeLevel ?? level } let selectionState = GridSelectionState() let tabManager: QueryTabManager diff --git a/TableProTests/Core/Services/Execution/ExecutionGateTests.swift b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift index eda0ca137e..52935199b3 100644 --- a/TableProTests/Core/Services/Execution/ExecutionGateTests.swift +++ b/TableProTests/Core/Services/Execution/ExecutionGateTests.swift @@ -55,7 +55,7 @@ struct ExecutionGateTests { DefaultExecutionGate( confirming: confirm, authenticating: auth, - safeModeLevelResolver: { _, _ in level }, + safeModeLevelResolver: { _ in level }, forcesWriteResolver: { _ in forcesWrite } ) } diff --git a/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift b/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift index b953a8a713..c30d6960d8 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: "Ledger", type: .beancount, 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/Models/ReadOnlyEnforcementTests.swift b/TableProTests/Models/ReadOnlyEnforcementTests.swift new file mode 100644 index 0000000000..be408223c8 --- /dev/null +++ b/TableProTests/Models/ReadOnlyEnforcementTests.swift @@ -0,0 +1,125 @@ +// +// ReadOnlyEnforcementTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Read-only enforcement") +@MainActor +struct ReadOnlyEnforcementTests { + 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 neither leaves no enforcement") + func resolveOrder() { + #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: true, opensRemoteDatabaseFile: true) == .readOnlyEngine) + #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: true) == .remoteDatabaseFile) + #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: false) == nil) + } + + @Test("Only Read-Only can be chosen while enforcement applies", arguments: SafeModeLevel.allCases) + func allowsChoosing(level: SafeModeLevel) { + #expect(ReadOnlyEnforcement.allowsChoosing(level, under: nil)) + #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .readOnlyEngine) == (level == .readOnly)) + #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .remoteDatabaseFile) == (level == .readOnly)) + } + + @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.readOnlyEnforcement == .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.readOnlyEnforcement == 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.readOnlyEnforcement == .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: "Ledger", type: .beancount, 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("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/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/databases/beancount.mdx b/docs/databases/beancount.mdx index 224de31c9f..1178f8d0fd 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** whatever level it was given, so INSERT, UPDATE, DELETE, cell editing and schema editing are all refused. Edit the ledger in a text editor; the next query picks the change up. See [Safe Mode](/features/safe-mode#connections-held-at-read-only). - 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/sqlite.mdx b/docs/databases/sqlite.mdx index 80329909d5..319c6022fe 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-held-at-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/features/safe-mode.mdx b/docs/features/safe-mode.mdx index beede96ee4..ccdabbdb8c 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -20,7 +20,15 @@ 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. -Some engines run read queries and nothing else. [Cloudflare R2 SQL](/databases/cloudflare-r2-sql) is one: its connections are pinned to **Read-Only** and the Safe Mode picker in the connection form is disabled. +## Connections held at 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 engine runs read queries only | +| [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 From 1ce51f73b5403615fb757bf35b569505a4f58908 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 18:18:07 +0700 Subject: [PATCH 3/7] refactor(datagrid): model engines that cannot skip rows as a pagination capability with a row ceiling Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- .../Coordinators/PaginationCoordinator.swift | 34 +++-- .../QueryExecutionCoordinator+Helpers.swift | 21 ++- ...QueryExecutionCoordinator+Parameters.swift | 15 +- .../DataWrite/Rewind/RewindExecutor.swift | 3 +- .../Access/DatabaseAccessBridge.swift | 12 +- .../Core/MCP/MCPConnectionBridge+Data.swift | 28 +++- .../Plugins/ExportDataSourceAdapter.swift | 71 ++++++++-- .../Plugins/PluginManager+Registration.swift | 5 +- ...PluginMetadataRegistry+R2SQLDefaults.swift | 61 +++++++-- .../Core/Plugins/PluginMetadataRegistry.swift | 4 +- .../Core/Services/Export/ExportService.swift | 4 +- .../Export/TableTransferService.swift | 1 + ...inSplitViewController+MenuValidation.swift | 13 +- .../Services/Query/LeadingRowsStatement.swift | 70 ++++++++++ .../Services/Query/TableQueryBuilder.swift | 10 +- .../Models/Query/PaginationCapability.swift | 39 ++++++ TablePro/Models/Query/QueryTab.swift | 5 +- TablePro/Models/Query/QueryTabManager.swift | 6 +- TablePro/Models/Query/ResultStatusModel.swift | 4 + TablePro/Models/Query/StatusBarSnapshot.swift | 10 +- .../Components/PaginationControlsView.swift | 40 +++--- .../Main/Child/MainEditorContentView.swift | 2 +- .../MainContentCoordinator+Pagination.swift | 4 +- .../MainContentCoordinator+QueryHelpers.swift | 4 + ...ainContentCoordinator+TableFirstLoad.swift | 6 +- .../Main/MainContentCommandActions.swift | 4 + .../Views/Main/MainContentCoordinator.swift | 7 +- TablePro/Views/Results/ResultStatusBar.swift | 3 +- .../Core/DataWrite/RewindPlannerTests.swift | 6 +- .../Services/LeadingRowsStatementTests.swift | 100 ++++++++++++++ .../TableQueryBuilderFilterTests.swift | 10 +- .../TableQueryBuilderMSSQLTests.swift | 2 + .../TableQueryBuilderSortScopeTests.swift | 4 +- .../Models/PaginationCapabilityTests.swift | 129 ++++++++++++++++++ .../Models/Query/QueryTabBaseQueryTests.swift | 1 + docs/external-api/mcp-tools.mdx | 2 + docs/features/data-grid.mdx | 2 + 37 files changed, 627 insertions(+), 115 deletions(-) create mode 100644 TablePro/Core/Services/Query/LeadingRowsStatement.swift create mode 100644 TablePro/Models/Query/PaginationCapability.swift create mode 100644 TableProTests/Core/Services/LeadingRowsStatementTests.swift create mode 100644 TableProTests/Models/PaginationCapabilityTests.swift diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 8e4dd3aecb..35eeebd041 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -21,36 +21,45 @@ final class PaginationCoordinator { // MARK: - Pagination func goToNextPage() { - guard parent.supportsOffsetPagination else { return } - 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() { - guard parent.supportsOffsetPagination else { return } - paginateIfPossible(where: \.hasPreviousPage) { $0.goToPreviousPage() } + seekIfPossible(where: \.hasPreviousPage) { $0.goToPreviousPage() } } func goToFirstPage() { - guard parent.supportsOffsetPagination else { return } - paginateIfPossible(where: \.hasPreviousPage) { $0.goToFirstPage() } + seekIfPossible(where: \.hasPreviousPage) { $0.goToFirstPage() } } func goToLastPage() { - guard parent.supportsOffsetPagination else { return } - paginateIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } + seekIfPossible(where: { $0.isLastPageKnown && $0.currentPage != $0.totalPages }) { $0.goToLastPage() } } func goToPage(_ page: Int) { - guard parent.supportsOffsetPagination else { return } - 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. @@ -60,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 f9ddb107e3..1d01c5a3d2 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) } @@ -620,6 +629,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() @@ -638,7 +648,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) @@ -719,12 +730,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 4e375ada2a..62c5aa0ba3 100644 --- a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -182,6 +182,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 @@ -205,8 +213,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/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 ce02757810..2888e55495 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,69 @@ 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 + } + + private func streamLeadingRows( + query: String, + table: String + ) -> AsyncThrowingStream { + guard let pluginDriver else { + return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } + } + let stream = pluginDriver.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,10 +120,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)" - } - return pluginDriver.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 c2914e2857..4cf5c7f3bd 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -473,9 +473,8 @@ extension PluginManager { .capabilities.supportsReadOnlyMode ?? true } - func supportsOffsetPagination(for databaseType: DatabaseType) -> Bool { - PluginMetadataRegistry.shared.snapshot(for: databaseType)? - .capabilities.supportsOffsetPagination ?? true + func paginationCapability(for databaseType: DatabaseType) -> PaginationCapability { + PaginationCapability.of(databaseType) } func isEngineReadOnly(for databaseType: DatabaseType) -> Bool { diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift index 320a82476e..a0b2661f13 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift @@ -41,7 +41,7 @@ extension PluginMetadataRegistry { supportsModifyPrimaryKey: false, supportsOpportunisticTLS: false, supportsCloudflareTunnel: false, - supportsOffsetPagination: false, + pagination: .leadingRowsOnly(maximumRows: 10_000), isEngineReadOnly: true ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -56,10 +56,10 @@ extension PluginMetadataRegistry { systemSchemaNames: [], fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, - structureColumnFields: [.name, .type, .nullable] + structureColumnFields: [.name, .type, .nullable, .comment] ), editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: nil, + sqlDialect: r2SQLDialect, statementCompletions: r2SQLCompletions, columnTypesByCategory: r2SQLColumnTypes ), @@ -68,7 +68,7 @@ extension PluginMetadataRegistry { category: .cloud, tagline: String(localized: "Read-only SQL over Iceberg tables in R2") ) - )), + )) ] } @@ -87,14 +87,14 @@ extension PluginMetadataRegistry { 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"), + ExplainVariant(id: "explainJson", label: "Explain (JSON)", sqlPrefix: "EXPLAIN FORMAT JSON") ] private let r2SQLCompletions: [CompletionEntry] = [ @@ -102,18 +102,49 @@ private let r2SQLCompletions: [CompletionEntry] = [ 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"), - CompletionEntry(label: "COUNT", insertText: "SELECT COUNT(*) AS total FROM namespace.table"), - CompletionEntry(label: "QUALIFY", insertText: "QUALIFY ROW_NUMBER() OVER (ORDER BY column) <= 10"), - CompletionEntry(label: "WITH", insertText: "WITH cte AS (SELECT * FROM namespace.table LIMIT 100) SELECT * FROM cte"), + CompletionEntry(label: "EXPLAIN", insertText: "EXPLAIN SELECT * FROM namespace.table LIMIT 10") ] private let r2SQLColumnTypes: [String: [String]] = [ - "Integer": ["INT32", "INT64"], - "Float": ["FLOAT32", "FLOAT64", "DECIMAL128"], - "String": ["STRING"], - "Date": ["DATE32", "TIMESTAMP"], + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "DECIMAL"], + "String": ["TEXT"], + "Date": ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ"], "Binary": ["BINARY"], "Boolean": ["BOOLEAN"], - "Nested": ["ARRAY", "STRUCT", "MAP"], + "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.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 3107e41672..8ffd43b634 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -74,7 +74,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsClientKeyPassphrase: Bool = false var supportsConnectionPooling: Bool = true var authenticationIsDatabaseScoped: Bool = false - var supportsOffsetPagination: Bool = true + var pagination: PaginationCapability = .offset var isEngineReadOnly: Bool = false /// Which connection field carries the path of the local database file this driver opens, @@ -618,7 +618,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsConnectionPooling: existingSnapshot?.capabilities.supportsConnectionPooling ?? true, authenticationIsDatabaseScoped: existingSnapshot?.capabilities .authenticationIsDatabaseScoped ?? false, - supportsOffsetPagination: existingSnapshot?.capabilities.supportsOffsetPagination ?? true, + pagination: existingSnapshot?.capabilities.pagination ?? .offset, isEngineReadOnly: existingSnapshot?.capabilities.isEngineReadOnly ?? false, localFilePathField: existingSnapshot?.capabilities.localFilePathField, supportsRemoteDatabaseFile: existingSnapshot?.capabilities diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 9b1ede026a..9efc950c10 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 ) 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 e3a45341d5..49e2739936 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(_:)): @@ -312,6 +316,7 @@ extension MainSplitViewController: NSMenuItemValidation { canPinResultTab: actions.canPinResultTab, canNavigateBack: actions.canNavigateBack, canNavigateForward: actions.canNavigateForward, + canNavigatePages: actions.canNavigatePages, canSaveAsFavorite: actions.canSaveAsFavorite, canSwitchSidebarLayout: actions.canSwitchSidebarLayout, canToggleWorkspaceRail: canToggleWorkspaceRail, diff --git a/TablePro/Core/Services/Query/LeadingRowsStatement.swift b/TablePro/Core/Services/Query/LeadingRowsStatement.swift new file mode 100644 index 0000000000..4bd175d576 --- /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 = sql.trimmingCharacters(in: .whitespacesAndNewlines) + while statement.hasSuffix(";") { + statement = String(statement.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + 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 2a60e0e41e..f41aa80aff 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -16,7 +16,7 @@ struct TableQueryBuilder { private let databaseType: DatabaseType private var pluginDriver: (any PluginDatabaseDriver)? private let dialect: SQLDialectDescriptor? - private let supportsOffsetPagination: Bool + private let pagination: PaginationCapability private let dialectQuote: (String) -> String // MARK: - Initialization @@ -25,13 +25,13 @@ struct TableQueryBuilder { databaseType: DatabaseType, pluginDriver: (any PluginDatabaseDriver)? = nil, dialect: SQLDialectDescriptor? = nil, - supportsOffsetPagination: Bool = true, + pagination: PaginationCapability, dialectQuote: ((String) -> String)? = nil ) { self.databaseType = databaseType self.pluginDriver = pluginDriver self.dialect = dialect - self.supportsOffsetPagination = supportsOffsetPagination + self.pagination = pagination self.dialectQuote = dialectQuote ?? { name in let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" @@ -222,8 +222,8 @@ struct TableQueryBuilder { } private func buildPaginationClause(limit: Int, offset: Int) -> String { - guard supportsOffsetPagination else { - return "LIMIT \(limit)" + 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/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 f1db426909..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,7 +306,7 @@ struct QueryTab: Identifiable, Equatable { databaseType: databaseType, pluginDriver: nil, dialect: dialect, - supportsOffsetPagination: PluginManager.shared.supportsOffsetPagination(for: databaseType), + 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 07465d4a1d..4676c20eb8 100644 --- a/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/TablePro/Models/Query/StatusBarSnapshot.swift @@ -25,7 +25,7 @@ struct StatusBarSnapshot: Equatable { let hasStructureActions: Bool let pagination: PaginationState let statusMessage: String? - let supportsPaging: Bool + let paginationCapability: PaginationCapability init( tabId: UUID?, @@ -40,7 +40,7 @@ struct StatusBarSnapshot: Equatable { hasStructureActions: Bool = false, pagination: PaginationState, statusMessage: String?, - supportsPaging: Bool = true + paginationCapability: PaginationCapability = .offset ) { self.tabId = tabId self.tabType = tabType @@ -54,7 +54,7 @@ struct StatusBarSnapshot: Equatable { self.hasStructureActions = hasStructureActions self.pagination = pagination self.statusMessage = statusMessage - self.supportsPaging = supportsPaging + self.paginationCapability = paginationCapability } /// `isFetching` is the caller's answer to "is an execution running for this tab", which the tab @@ -68,7 +68,7 @@ struct StatusBarSnapshot: Equatable { displayRowCount: Int? = nil, isFetching: Bool = false, hasStructureActions: Bool = false, - supportsPaging: Bool = true + paginationCapability: PaginationCapability = .offset ) { let loaded = tableRows?.rows.count ?? 0 let displayed = displayRowCount ?? loaded @@ -91,7 +91,7 @@ struct StatusBarSnapshot: Equatable { hasStructureActions: hasStructureActions, pagination: pagination, statusMessage: tab?.execution.statusMessage, - supportsPaging: supportsPaging + paginationCapability: paginationCapability ) } diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index 1790394162..bf007eb8bf 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -11,7 +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 supportsPaging: Bool = true + 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 @@ -33,13 +35,20 @@ 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 - if supportsPaging { + if showsPageNavigation { navigationCluster - } else { - singlePageIndicator } } .onChange(of: tabId) { _, _ in @@ -50,19 +59,6 @@ struct PaginationControlsView: View { } } - private var singlePageIndicator: some View { - Text(singlePageText) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - .help(String(localized: "This engine does not support paging through results. Filter or sort to narrow them.")) - .accessibilityLabel(singlePageText) - } - - private var singlePageText: String { - String(format: String(localized: "First %d rows"), loadedRowCount) - } - // MARK: - Page Size /// A bordered pull-down rather than a borderless one. Measured on macOS 27, a borderless @@ -71,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) } } @@ -79,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 @@ -235,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/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index f0e58ce85e..bfe980b42f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -997,7 +997,7 @@ struct MainEditorContentView: View { displayRowCount: coordinator.displayIDs(forTab: tab.id)?.count, isFetching: isExecuting, hasStructureActions: structureFooter.isActive, - supportsPaging: coordinator.supportsOffsetPagination + 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 e222afeebb..7beebdce34 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift @@ -6,8 +6,8 @@ import Foundation extension MainContentCoordinator { - var supportsOffsetPagination: Bool { - services.pluginManager.supportsOffsetPagination(for: connection.type) + var paginationCapability: PaginationCapability { + services.pluginManager.paginationCapability(for: connection.type) } func 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 0b19d9c8ef..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 = supportsOffsetPagination ? max(1, tab.restoredPage ?? 1) : 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 728dd30b4f..f7fe12a9b7 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 b92e8e0456..82adc3c13e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -657,7 +657,7 @@ final class MainContentCoordinator { self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, - supportsOffsetPagination: services.pluginManager.supportsOffsetPagination(for: connection.type), + pagination: services.pluginManager.paginationCapability(for: connection.type), dialectQuote: dialect.map { quoteIdentifierFromDialect($0) } ) self.persistence = TabPersistenceCoordinator.forConnection(connection.id) @@ -1309,7 +1309,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 @@ -1370,7 +1371,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 179954afc2..35d02b1ad1 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -192,7 +192,8 @@ struct ResultStatusBar: View { pagination: snapshot.pagination, loadedRowCount: snapshot.rowCount, tabId: snapshot.tabId, - supportsPaging: snapshot.supportsPaging, + showsPageNavigation: model.controls.showsPageNavigation, + maximumPageSize: snapshot.paginationCapability.maximumRows, onFirst: paginationCallbacks.onFirst, onPrevious: paginationCallbacks.onPrevious, onNext: paginationCallbacks.onNext, 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/Services/LeadingRowsStatementTests.swift b/TableProTests/Core/Services/LeadingRowsStatementTests.swift new file mode 100644 index 0000000000..890ad8b9d8 --- /dev/null +++ b/TableProTests/Core/Services/LeadingRowsStatementTests.swift @@ -0,0 +1,100 @@ +// +// 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) + } + + 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/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/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. From d9ffd10a34090b4c97642fcab5c00da3da085e4e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 18:23:40 +0700 Subject: [PATCH 4/7] fix(plugin-cloudflare-r2-sql): read the live R2 SQL envelope and drop the plugin's own query builders Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- CLAUDE.md | 4 +- .../R2SQLConnectionConfig.swift | 48 +---- .../TableProR2SQLCore/R2SQLError.swift | 62 +++---- .../R2SQLErrorClassifier.swift | 62 ------- .../R2SQLIntrospectionSQL.swift | 101 +++++++++- .../TableProR2SQLCore/R2SQLJSONValue.swift | 127 +++---------- .../TableProR2SQLCore/R2SQLLimits.swift | 11 -- .../TableProR2SQLCore/R2SQLLiteral.swift | 29 --- .../TableProR2SQLCore/R2SQLQueryBuilder.swift | 169 ----------------- .../R2SQLRequestBuilder.swift | 22 +-- .../R2SQLResponseDecoder.swift | 28 +++ .../TableProR2SQLCore/R2SQLRowMapper.swift | 30 +-- .../TableProR2SQLCore/R2SQLTransport.swift | 29 --- .../TableProR2SQLCore/R2SQLTypeMapper.swift | 130 +++++++------ .../R2SQLURLSessionTransport.swift | 81 ++++++++ .../TableProR2SQLCore/R2SQLWireTypes.swift | 148 +++++++-------- .../R2SQLEnvelopeDecodingTests.swift | 151 +++++++-------- .../R2SQLIntrospectionSQLTests.swift | 85 ++++++--- .../R2SQLJSONValueTests.swift | 70 +++---- .../R2SQLQueryBuilderTests.swift | 173 ------------------ .../R2SQLRequestBuilderTests.swift | 108 +++-------- .../R2SQLRowMapperTests.swift | 90 ++++----- .../R2SQLTypeMapperTests.swift | 102 +++-------- .../R2SQLURLSessionTransportTests.swift | 86 +++++++++ .../CloudflareR2SQLMetadata.swift | 100 ++++++++++ .../CloudflareR2SQLPlugin.swift | 95 ++-------- .../CloudflareR2SQLPluginDriver+Query.swift | 137 +------------- .../CloudflareR2SQLPluginDriver+Schema.swift | 71 +++---- .../CloudflareR2SQLPluginDriver.swift | 102 +++++------ .../R2SQLURLSessionTransport.swift | 63 ------- ...PluginMetadataRegistry+R2SQLDefaults.swift | 2 +- .../CloudflareR2SQLMetadataParityTests.swift | 78 ++++++++ docs/databases/beancount.mdx | 2 +- docs/databases/cloudflare-r2-sql.mdx | 116 ++++++------ docs/databases/sqlite.mdx | 2 +- docs/features/safe-mode.mdx | 2 +- project.yml | 1 + 37 files changed, 1081 insertions(+), 1636 deletions(-) delete mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLErrorClassifier.swift delete mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift delete mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift delete mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLResponseDecoder.swift delete mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift create mode 100644 Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLURLSessionTransport.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift create mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift create mode 100644 Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift delete mode 100644 Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift create mode 100644 TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift 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/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift index 086558f23e..62dd79eb18 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLConnectionConfig.swift @@ -6,25 +6,11 @@ public struct R2SQLConnectionConfig: Sendable, Equatable { public let accountId: String public let bucket: String public let token: String - public let defaultNamespace: String - public let timeoutSeconds: Int - public init( - accountId: String, - bucket: String, - token: String, - defaultNamespace: String = "", - timeoutSeconds: Int = 60 - ) { + public init(accountId: String, bucket: String, token: String) { self.accountId = accountId.trimmingCharacters(in: .whitespacesAndNewlines) self.bucket = bucket.trimmingCharacters(in: .whitespacesAndNewlines) - self.token = token - self.defaultNamespace = defaultNamespace.trimmingCharacters(in: .whitespacesAndNewlines) - self.timeoutSeconds = timeoutSeconds - } - - public var warehouse: String { - "\(accountId)_\(bucket)" + self.token = token.trimmingCharacters(in: .whitespacesAndNewlines) } public var queryURL: URL? { @@ -35,29 +21,13 @@ public struct R2SQLConnectionConfig: Sendable, Equatable { return components.url } - public func validate() -> R2SQLError? { - if accountId.isEmpty { - return .configuration(R2SQLErrorText.missingAccountId) - } - if bucket.isEmpty { - return .configuration(R2SQLErrorText.missingBucket) - } - if token.isEmpty { - return .configuration(R2SQLErrorText.missingToken) + 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.") } - if queryURL == nil { - return .configuration(R2SQLErrorText.invalidEndpoint) - } - return nil - } -} - -public enum R2SQLWarehouse { - public static func split(_ warehouse: String) -> (accountId: String, bucket: String)? { - guard let separator = warehouse.firstIndex(of: "_") else { return nil } - let accountId = String(warehouse[warehouse.startIndex.. String? { + let messages = errors.map(\.message).filter { !$0.isEmpty } + return messages.isEmpty ? nil : messages.joined(separator: "\n") + } } diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLErrorClassifier.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLErrorClassifier.swift deleted file mode 100644 index 55db4c91a4..0000000000 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLErrorClassifier.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -public enum R2SQLErrorClassifier { - public static let missingTokenCode = 80_007 - public static let invalidTokenCode = 80_011 - public static let invalidAccountCode = 80_016 - - private static let authenticationCodes: Set = [missingTokenCode, invalidTokenCode] - - public static func decode(_ response: R2SQLHTTPResponse) -> Result { - guard let envelope = try? JSONDecoder().decode(R2SQLEnvelope.self, from: response.body) else { - return .failure(.malformedResponse( - status: response.statusCode, - body: String(data: response.body, encoding: .utf8) ?? "" - )) - } - - guard envelope.success else { - return .failure(classify(errors: envelope.errors, statusCode: response.statusCode)) - } - - guard let result = envelope.result else { - return .success(R2SQLResult(schema: [], rows: [])) - } - return .success(result) - } - - public static func classify(errors: [R2SQLAPIError], statusCode: Int) -> R2SQLError { - guard let first = errors.first else { - return authenticationStatus(statusCode) ?? .malformedResponse(status: statusCode, body: "") - } - - if authenticationCodes.contains(first.code) { - return .authentication(authenticationGuidance(first.message)) - } - if first.code == invalidAccountCode { - return .authentication("\(first.message). Check the Account ID on this connection.") - } - if let status = authenticationStatus(statusCode) { - return status - } - return errors.count == 1 ? .query(first) : .api(errors) - } - - private static func authenticationStatus(_ statusCode: Int) -> R2SQLError? { - switch statusCode { - case 401: - return .authentication(authenticationGuidance("Unauthenticated.")) - case 403: - return .authentication(authenticationGuidance("Forbidden.")) - default: - return nil - } - } - - private static func authenticationGuidance(_ message: String) -> String { - """ - \(message) The API token needs the R2 SQL, R2 Data Catalog and R2 Storage permission groups \ - for this account. - """ - } -} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift index d712f6def8..b8cb4bb289 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLIntrospectionSQL.swift @@ -1,15 +1,106 @@ import Foundation -public enum R2SQLIntrospectionSQL { - public static func showNamespaces() -> String { - "SHOW NAMESPACES" +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 \(R2SQLLiteral.quoteQualifiedName(namespace))" + "SHOW TABLES IN \(quoteIdentifier(namespace))" } public static func describe(namespace: String, table: String) -> String { - "DESCRIBE \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" + "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 index 74e00ac0c2..daa43a3f17 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLJSONValue.swift @@ -1,133 +1,58 @@ 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 int(Int64) - case uint(UInt64) - case double(Double) + 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 - return - } - if let value = try? container.decode(Bool.self) { + } else if let value = try? container.decode(Bool.self) { self = .bool(value) - return - } - if let value = try? container.decode(Int64.self) { - self = .int(value) - return - } - if let value = try? container.decode(UInt64.self) { - self = .uint(value) - return - } - if let value = try? container.decode(Double.self) { - self = .double(value) - return - } - if let value = try? container.decode(String.self) { + } else if let value = try? container.decode(Decimal.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { self = .string(value) - return - } - if let value = try? container.decode([R2SQLJSONValue].self) { + } else if let value = try? container.decode([R2SQLJSONValue].self) { self = .array(value) - return - } - if let value = try? container.decode([String: R2SQLJSONValue].self) { - self = .object(value) - return - } - self = .null - } - - public var isNull: Bool { - if case .null = self { return true } - return false - } - - public var foundationObject: Any { - switch self { - case .null: - return NSNull() - case .bool(let value): - return value - case .int(let value): - return NSNumber(value: value) - case .uint(let value): - return NSNumber(value: value) - case .double(let value): - return NSNumber(value: value) - case .string(let value): - return value - case .array(let values): - return values.map(\.foundationObject) - case .object(let values): - return values.mapValues(\.foundationObject) + } else { + self = .object(try container.decode([String: R2SQLJSONValue].self)) } } - public func jsonText() -> String { + public var jsonText: String { switch self { case .null: return "null" case .bool(let value): return value ? "true" : "false" - case .int(let value): - return String(value) - case .uint(let value): - return String(value) - case .double(let value): - return Self.format(double: value) + case .number(let value): + return value.description case .string(let value): - return Self.encode(string: value) - case .array, .object: - guard let data = try? JSONSerialization.data( - withJSONObject: foundationObject, - options: [.sortedKeys, .fragmentsAllowed] - ), let text = String(data: data, encoding: .utf8) else { - return "" + 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 text - } - } - - public var scalarText: String? { - switch self { - case .null: - return nil - case .bool(let value): - return value ? "true" : "false" - case .int(let value): - return String(value) - case .uint(let value): - return String(value) - case .double(let value): - return Self.format(double: value) - case .string(let value): - return value - case .array, .object: - return jsonText() - } - } - - static func format(double value: Double) -> String { - if value == value.rounded(), abs(value) < 1e15 { - return String(Int64(value)) + return "{" + members.joined(separator: ",") + "}" } - return String(value) } - static func encode(string value: String) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]), - let text = String(data: data, encoding: .utf8) else { + 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/R2SQLLimits.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift deleted file mode 100644 index 5cc0e889c3..0000000000 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLimits.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -public enum R2SQLLimits { - public static let defaultLimit = 500 - public static let minLimit = 1 - public static let maxLimit = 10_000 - - public static func clampLimit(_ limit: Int) -> Int { - min(max(limit, minLimit), maxLimit) - } -} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift deleted file mode 100644 index af51007e5b..0000000000 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLLiteral.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -public enum R2SQLLiteral { - public static func quoteIdentifier(_ identifier: String) -> String { - "\"" + identifier.replacingOccurrences(of: "\"", with: "\"\"") + "\"" - } - - public static func quoteQualifiedName(_ name: String) -> String { - name - .split(separator: ".", omittingEmptySubsequences: false) - .map { quoteIdentifier(String($0)) } - .joined(separator: ".") - } - - public static func escapeStringLiteral(_ value: String) -> String { - value - .replacingOccurrences(of: "\u{0}", with: "") - .replacingOccurrences(of: "'", with: "''") - } - - public static func stringLiteral(_ value: String) -> String { - "'" + escapeStringLiteral(value) + "'" - } - - public static func qualifiedName(namespace: String, table: String) -> String { - guard !namespace.isEmpty else { return quoteIdentifier(table) } - return quoteQualifiedName(namespace) + "." + quoteIdentifier(table) - } -} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift deleted file mode 100644 index 7151f30d78..0000000000 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLQueryBuilder.swift +++ /dev/null @@ -1,169 +0,0 @@ -import Foundation - -public struct R2SQLSortColumn: Sendable, Equatable { - public let name: String - public let ascending: Bool - - public init(name: String, ascending: Bool) { - self.name = name - self.ascending = ascending - } -} - -public struct R2SQLFilter: Sendable, Equatable { - public let column: String - public let op: String - public let value: String - - public init(column: String, op: String, value: String) { - self.column = column - self.op = op - self.value = value - } -} - -public enum R2SQLQueryBuilder { - public static func selectList(_ columns: [String]) -> String { - guard !columns.isEmpty else { return "*" } - return columns.map(R2SQLLiteral.quoteIdentifier).joined(separator: ", ") - } - - public static func orderByClause(_ sortColumns: [R2SQLSortColumn]) -> String? { - let parts = sortColumns - .filter { !$0.name.isEmpty } - .map { R2SQLLiteral.quoteIdentifier($0.name) + ($0.ascending ? " ASC" : " DESC") } - guard !parts.isEmpty else { return nil } - return "ORDER BY " + parts.joined(separator: ", ") - } - - public static func browseQuery( - namespace: String, - table: String, - columns: [String] = [], - sortColumns: [R2SQLSortColumn] = [], - limit: Int - ) -> String { - compose( - namespace: namespace, - table: table, - columns: columns, - whereClause: nil, - sortColumns: sortColumns, - limit: limit - ) - } - - public static func filteredQuery( - namespace: String, - table: String, - filters: [R2SQLFilter], - matchAll: Bool, - columns: [String] = [], - sortColumns: [R2SQLSortColumn] = [], - limit: Int - ) -> String { - compose( - namespace: namespace, - table: table, - columns: columns, - whereClause: whereClause(filters: filters, matchAll: matchAll), - sortColumns: sortColumns, - limit: limit - ) - } - - public static func countQuery( - namespace: String, - table: String, - filters: [R2SQLFilter] = [], - matchAll: Bool = true - ) -> String { - var sql = "SELECT COUNT(*) AS total FROM \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" - if let clause = whereClause(filters: filters, matchAll: matchAll) { - sql += " WHERE \(clause)" - } - return sql - } - - public static func whereClause(filters: [R2SQLFilter], matchAll: Bool) -> String? { - let parts = filters.compactMap(predicate(for:)) - guard !parts.isEmpty else { return nil } - return parts.joined(separator: matchAll ? " AND " : " OR ") - } - - private static func compose( - namespace: String, - table: String, - columns: [String], - whereClause: String?, - sortColumns: [R2SQLSortColumn], - limit: Int - ) -> String { - var sql = "SELECT \(selectList(columns))" - sql += " FROM \(R2SQLLiteral.qualifiedName(namespace: namespace, table: table))" - if let whereClause { - sql += " WHERE \(whereClause)" - } - if let orderBy = orderByClause(sortColumns) { - sql += " \(orderBy)" - } - sql += " LIMIT \(R2SQLLimits.clampLimit(limit))" - return sql - } - - private static func predicate(for filter: R2SQLFilter) -> String? { - let column = filter.column.trimmingCharacters(in: .whitespacesAndNewlines) - guard !column.isEmpty else { return nil } - let quoted = R2SQLLiteral.quoteIdentifier(column) - let op = filter.op.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - - switch op { - case "IS NULL", "ISNULL": - return "\(quoted) IS NULL" - case "IS NOT NULL", "NOTNULL": - return "\(quoted) IS NOT NULL" - case "CONTAINS": - return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("%\(filter.value)%"))" - case "STARTS WITH", "BEGINS WITH": - return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("\(filter.value)%"))" - case "ENDS WITH": - return "\(quoted) LIKE \(R2SQLLiteral.stringLiteral("%\(filter.value)"))" - case "IN", "NOT IN": - return listPredicate(quoted: quoted, op: op, value: filter.value) - case "=", "!=", "<>", "<", "<=", ">", ">=", "LIKE", "NOT LIKE": - return "\(quoted) \(op) \(literal(for: filter.value))" - default: - return nil - } - } - - private static func listPredicate(quoted: String, op: String, value: String) -> String? { - let items = value - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - guard !items.isEmpty else { return nil } - let rendered = items.map(literal(for:)).joined(separator: ", ") - return "\(quoted) \(op) (\(rendered))" - } - - private static func literal(for value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let lowered = trimmed.lowercased() - if lowered == "true" || lowered == "false" { - return lowered - } - if lowered == "null" { - return "NULL" - } - if isNumeric(trimmed) { - return trimmed - } - return R2SQLLiteral.stringLiteral(value) - } - - private static func isNumeric(_ text: String) -> Bool { - guard !text.isEmpty else { return false } - return Double(text) != nil && text.allSatisfy { $0.isNumber || "+-.eE".contains($0) } - } -} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift index 86e7a21069..5106c613df 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRequestBuilder.swift @@ -1,17 +1,13 @@ import Foundation public enum R2SQLRequestBuilder { - public static func queryRequest(config: R2SQLConnectionConfig, sql: String) throws -> R2SQLHTTPRequest { - if let error = config.validate() { - throw error - } - guard let url = config.queryURL else { - throw R2SQLError.configuration(R2SQLErrorText.invalidEndpoint) - } - let body = R2SQLRequestBody(warehouse: config.warehouse, query: sql) - guard let encoded = try? JSONEncoder().encode(body) else { - throw R2SQLError.configuration("Could not encode the query request") - } + 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: [ @@ -19,8 +15,8 @@ public enum R2SQLRequestBuilder { "Content-Type": "application/json", "Accept": "application/json" ], - body: encoded, - timeoutSeconds: config.timeoutSeconds + 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 index ad5eebc7be..c0eb95a92b 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLRowMapper.swift @@ -10,32 +10,20 @@ public struct R2SQLResultSet: Sendable, Equatable { self.columnTypeNames = columnTypeNames self.rows = rows } - - public static let empty = R2SQLResultSet(columns: [], columnTypeNames: [], 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 columns = result.schema.map(\.name) - let rawTypeNames = result.schema.map(\.typeName) - let columnTypeNames = rawTypeNames.map { R2SQLTypeMapper.displayTypeName(rawTypeName: $0) } - + let kinds = result.schema.map { R2SQLTypeMapper.valueKind($0.typeName) } let rows = result.rows.map { row in - zip(columns, rawTypeNames).map { name, rawTypeName in - R2SQLTypeMapper.value(for: row[name], rawTypeName: rawTypeName) - } - } - - return R2SQLResultSet(columns: columns, columnTypeNames: columnTypeNames, rows: rows) - } - - public static func firstColumnStrings(_ result: R2SQLResult) -> [String] { - let mapped = map(result) - guard !mapped.columns.isEmpty else { return [] } - return mapped.rows.compactMap { row in - guard let first = row.first, case .text(let value) = first else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + 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/R2SQLTransport.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift deleted file mode 100644 index 2e7e724707..0000000000 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTransport.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -public struct R2SQLHTTPRequest: Sendable, Equatable { - public let url: URL - public let headers: [String: String] - public let body: Data - public let timeoutSeconds: Int - - public init(url: URL, headers: [String: String], body: Data, timeoutSeconds: Int) { - self.url = url - self.headers = headers - self.body = body - self.timeoutSeconds = timeoutSeconds - } -} - -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 -} diff --git a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift index 753e76f994..031b3dc365 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLTypeMapper.swift @@ -1,73 +1,95 @@ import Foundation -public enum R2SQLTypeCategory: Sendable, Equatable { - case scalar +/// 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 structured + 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 static let structuredBases: Set = [ - "list", "largelist", "fixedsizelist", "array", "struct", "map", "union", "dictionary" - ] - - private static let binaryBases: Set = [ - "binary", "largebinary", "fixedsizebinary" - ] + private struct Entry { + let displayName: String + let kind: R2SQLValueKind + } - private static let normalizedBases: [String: String] = [ - "utf8": "STRING", - "largeutf8": "STRING", - "utf8view": "STRING", - "list": "ARRAY", - "largelist": "ARRAY", - "fixedsizelist": "ARRAY", - "struct": "STRUCT", - "map": "MAP", - "binaryview": "BINARY", - "largebinary": "BINARY", - "fixedsizebinary": "BINARY" + 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 baseName(_ typeName: String) -> String { - let trimmed = typeName.trimmingCharacters(in: .whitespacesAndNewlines) - guard let paren = trimmed.firstIndex(of: "(") else { return trimmed } - return String(trimmed[trimmed.startIndex.. String { + entry(typeName)?.displayName ?? typeName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } - public static func displayTypeName(for field: R2SQLField) -> String { - displayTypeName(rawTypeName: field.typeName) + public static func valueKind(_ typeName: String) -> R2SQLValueKind { + entry(typeName)?.kind ?? .text } - public static func displayTypeName(rawTypeName: String) -> String { - let raw = rawTypeName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { return "" } - let base = baseName(raw) - guard let normalized = normalizedBases[base.lowercased()] else { return raw } - return normalized + 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) + } } - public static func category(rawTypeName: String) -> R2SQLTypeCategory { - let base = baseName(rawTypeName).lowercased() - if structuredBases.contains(base) { return .structured } - if binaryBases.contains(base) { return .binary } - return .scalar + private static func text(_ number: Decimal, kind: R2SQLValueKind) -> String { + guard kind == .floatingPoint else { return number.description } + return Double(truncating: number as NSDecimalNumber).description } - public static func value(for json: R2SQLJSONValue?, rawTypeName: String) -> R2SQLValue { - guard let json, !json.isNull else { return .null } - switch category(rawTypeName: rawTypeName) { - case .scalar: - if case .array = json { return .text(json.jsonText()) } - if case .object = json { return .text(json.jsonText()) } - return .text(json.scalarText ?? "") - case .binary: - if case .string(let encoded) = json, let data = Data(base64Encoded: encoded) { - return .bytes([UInt8](data)) - } - return .text(json.scalarText ?? "") - case .structured: - return .text(json.jsonText()) - } + 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/R2SQLWireTypes.swift b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift index d051354a35..e73f76b97e 100644 --- a/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift +++ b/Packages/TableProCore/Sources/TableProR2SQLCore/R2SQLWireTypes.swift @@ -1,112 +1,114 @@ import Foundation public struct R2SQLRequestBody: Encodable, Sendable, Equatable { - public let warehouse: String public let query: String - public init(warehouse: String, query: String) { - self.warehouse = warehouse + public init(query: String) { self.query = query } } -public struct R2SQLField: Decodable, Sendable, Equatable { - public let name: String - public let rawType: R2SQLJSONValue? - - public init(name: String, rawType: R2SQLJSONValue?) { - self.name = name - self.rawType = rawType - } +/// 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? - public init(name: String, type: String) { - self.init(name: name, rawType: .string(type)) + private enum CodingKeys: String, CodingKey { + case success, errors, result } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - name = (try? container.decodeIfPresent(String.self, forKey: .name)) ?? "" - rawType = try? container.decodeIfPresent(R2SQLJSONValue.self, forKey: .type) + 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 var typeName: String { - switch rawType { - case .string(let value): - return value - case .object(let fields): - if case .string(let value)? = fields["name"] { return value } - return "" - case .none, .null: - return "" - default: - return rawType?.scalarText ?? "" - } +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 name, type + 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) ?? [] } } -public struct R2SQLMetrics: Decodable, Sendable, Equatable { - public let r2RequestsCount: Int? - public let filesScanned: Int? - public let bytesScanned: Int? +/// 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 r2RequestsCount = "r2_requests_count" - case filesScanned = "files_scanned" - case bytesScanned = "bytes_scanned" + case name, descriptor } -} -public struct R2SQLResult: Decodable, Sendable, Equatable { - public let requestId: String? - public let schema: [R2SQLField] - public let rows: [[String: R2SQLJSONValue]] - public let metrics: R2SQLMetrics? - - public init( - requestId: String? = nil, - schema: [R2SQLField], - rows: [[String: R2SQLJSONValue]], - metrics: R2SQLMetrics? = nil - ) { - self.requestId = requestId - self.schema = schema - self.rows = rows - self.metrics = metrics + 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) - requestId = try? container.decodeIfPresent(String.self, forKey: .requestId) - schema = (try? container.decodeIfPresent([R2SQLField].self, forKey: .schema)) ?? [] - rows = (try? container.decodeIfPresent([[String: R2SQLJSONValue]].self, forKey: .rows)) ?? [] - metrics = try? container.decodeIfPresent(R2SQLMetrics.self, forKey: .metrics) + 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 } +} - private enum CodingKeys: String, CodingKey { - case requestId = "request_id" - case schema, rows, metrics +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 R2SQLEnvelope: Decodable, Sendable, Equatable { - public let result: R2SQLResult? - public let success: Bool - public let errors: [R2SQLAPIError] - public let messages: [String] +public struct R2SQLHTTPResponse: Sendable, Equatable { + public let statusCode: Int + public let body: Data - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - result = try? container.decodeIfPresent(R2SQLResult.self, forKey: .result) - success = (try? container.decodeIfPresent(Bool.self, forKey: .success)) ?? false - errors = (try? container.decodeIfPresent([R2SQLAPIError].self, forKey: .errors)) ?? [] - messages = (try? container.decodeIfPresent([String].self, forKey: .messages)) ?? [] + public init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body } +} - private enum CodingKeys: String, CodingKey { - case result, success, errors, messages - } +public protocol R2SQLTransport: Sendable { + func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse + func cancelAll() } diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift index 257ee7e4e6..c18cf83967 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift @@ -1,120 +1,95 @@ -import XCTest +import Foundation +import Testing @testable import TableProR2SQLCore -final class R2SQLEnvelopeDecodingTests: XCTestCase { +@Suite("R2 SQL envelope decoding") +struct R2SQLEnvelopeDecodingTests { private func response(_ json: String, status: Int = 200) -> R2SQLHTTPResponse { R2SQLHTTPResponse(statusCode: status, body: Data(json.utf8)) } - func testSuccessEnvelopeDecodesSchemaAndRows() throws { + @Test("The live envelope carries each column's type under descriptor.type.name") + func descriptorEnvelope() throws { let json = """ - {"result":{"request_id":"dqe-prod-test", - "schema":[{"name":"id","type":"Int64"},{"name":"label","type":"Utf8"}], - "rows":[{"id":1,"label":"a"},{"id":2,"label":"b"}], - "metrics":{"r2_requests_count":3,"files_scanned":2,"bytes_scanned":1024}}, - "success":true,"errors":[],"messages":[]} + {"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 XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) - XCTAssertEqual(result.requestId, "dqe-prod-test") - XCTAssertEqual(result.schema.map(\.name), ["id", "label"]) - XCTAssertEqual(result.rows.count, 2) - XCTAssertEqual(result.metrics?.bytesScanned, 1024) - } + let result = try R2SQLResponseDecoder.decode(response(json)) - func testEmptyResultSetIsSuccess() throws { - let json = """ - {"result":{"schema":[],"rows":[],"metrics":{"r2_requests_count":0,"files_scanned":0,"bytes_scanned":0}}, - "success":true,"errors":[],"messages":[]} - """ - let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) - XCTAssertTrue(result.rows.isEmpty) - XCTAssertTrue(result.schema.isEmpty) + #expect(result.schema == [ + R2SQLField(name: "category", typeName: "utf8", isNullable: true), + R2SQLField(name: "cnt", typeName: "int64", isNullable: false) + ]) + #expect(result.rows.first?["cnt"] == .number(12345)) } - func testMissingMetricsStillDecodes() throws { + @Test("A nested list of structs decodes its outer type and keeps the value tree") + func nestedDescriptor() throws { let json = """ - {"result":{"schema":[{"name":"a","type":"Int64"}],"rows":[{"a":1}]},"success":true,"errors":[],"messages":[]} + {"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 XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) - XCTAssertNil(result.metrics) - XCTAssertEqual(result.rows.count, 1) - } + let result = try R2SQLResponseDecoder.decode(response(json)) - func testSuccessTrueWithNullResultYieldsEmptyResult() throws { - let json = #"{"result":null,"success":true,"errors":[],"messages":[]}"# - let result = try XCTUnwrap(try? R2SQLErrorClassifier.decode(response(json)).get()) - XCTAssertTrue(result.rows.isEmpty) - } - - func testErrorEnvelopeIsClassifiedAsFailure() { - let json = #"{"result":null,"success":false,"errors":[{"code":80007,"message":"Unauthenticated."}]}"# - guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 401)) else { - return XCTFail("Expected a failure") - } - guard case .authentication = error else { - return XCTFail("Expected an authentication error, got \(error)") - } + #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}]"#)]]) } - func testSuccessFalseUnderHTTP200IsStillFailure() { - let json = #"{"result":null,"success":false,"errors":[{"code":40003,"message":"bad SQL"}]}"# - guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 200)) else { - return XCTFail("Expected a failure") - } - XCTAssertEqual(error, .query(R2SQLAPIError(code: 40_003, message: "bad SQL"))) + @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)) } } - func testSuccessTrueUnderHTTP500IsStillSuccess() { - let json = #"{"result":{"schema":[],"rows":[]},"success":true,"errors":[],"messages":[]}"# - guard case .success = R2SQLErrorClassifier.decode(response(json, status: 500)) else { - return XCTFail("success flag must decide the outcome, not the HTTP status") + @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}"#)) } } - func testNonJSONBodyBecomesMalformedResponse() { - let plain = R2SQLHTTPResponse(statusCode: 405, body: Data("Method not allowed.".utf8)) - guard case .failure(let error) = R2SQLErrorClassifier.decode(plain) else { - return XCTFail("Expected a failure") + @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)) } - XCTAssertEqual(error, .malformedResponse(status: 405, body: "Method not allowed.")) } - func testEmptyBodyBecomesMalformedResponse() { - let empty = R2SQLHTTPResponse(statusCode: 502, body: Data()) - guard case .failure(let error) = R2SQLErrorClassifier.decode(empty) else { - return XCTFail("Expected a failure") - } - XCTAssertEqual(error, .malformedResponse(status: 502, body: "")) + @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) } - func testMultipleErrorsAreAllSurfaced() { - let json = """ - {"result":null,"success":false,"errors":[{"code":40003,"message":"first"},{"code":40004,"message":"second"}]} - """ - guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json)) else { - return XCTFail("Expected a failure") + @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)) } - let description = error.errorDescription ?? "" - XCTAssertTrue(description.contains("first")) - XCTAssertTrue(description.contains("second")) } - func testInvalidAccountIdErrorMentionsAccountId() { - let json = #"{"result":null,"success":false,"errors":[{"code":80016,"message":"Invalid account id"}]}"# - guard case .failure(let error) = R2SQLErrorClassifier.decode(response(json, status: 400)) else { - return XCTFail("Expected a failure") - } - XCTAssertTrue(error.errorDescription?.contains("Account ID") ?? false) - } + @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")]) - func testAuthenticationGuidanceNamesThePermissionGroups() { - let error = R2SQLErrorClassifier.classify( - errors: [R2SQLAPIError(code: 80_011, message: "Invalid token.")], - statusCode: 403 - ) - let description = error.errorDescription ?? "" - XCTAssertTrue(description.contains("R2 SQL")) - XCTAssertTrue(description.contains("R2 Data Catalog")) - XCTAssertTrue(description.contains("R2 Storage")) + #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 index 1227a9f755..2efc02b64f 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift @@ -1,44 +1,71 @@ -import XCTest +import Testing @testable import TableProR2SQLCore -final class R2SQLIntrospectionSQLTests: XCTestCase { - func testShowNamespaces() { - XCTAssertEqual(R2SQLIntrospectionSQL.showNamespaces(), "SHOW NAMESPACES") +@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) } - func testShowTablesQuotesNamespace() { - XCTAssertEqual(R2SQLIntrospectionSQL.showTables(namespace: "analytics"), "SHOW TABLES IN \"analytics\"") + @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""#) } - func testDescribeQualifiesNamespaceAndTable() { - XCTAssertEqual( - R2SQLIntrospectionSQL.describe(namespace: "analytics", table: "events"), - "DESCRIBE \"analytics\".\"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"]) } - func testDottedNamespaceIsQuotedPerSegmentNotReSplit() { - XCTAssertEqual( - R2SQLIntrospectionSQL.describe(namespace: "a.b", table: "t"), - "DESCRIBE \"a\".\"b\".\"t\"" - ) + @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"]) } - func testNamespaceWithEmbeddedQuoteIsEscaped() { - XCTAssertEqual( - R2SQLIntrospectionSQL.showTables(namespace: "we\"ird"), - "SHOW TABLES IN \"we\"\"ird\"" - ) + @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"]) } - func testIntrospectionStatementsNeverContainOffset() { - let statements = [ - R2SQLIntrospectionSQL.showNamespaces(), - R2SQLIntrospectionSQL.showTables(namespace: "ns"), - R2SQLIntrospectionSQL.describe(namespace: "ns", table: "t") - ] - for statement in statements { - XCTAssertFalse(statement.uppercased().contains("OFFSET")) + @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 index 7a826d3630..5c201e56b0 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift @@ -1,59 +1,39 @@ -import XCTest +import Foundation +import Testing @testable import TableProR2SQLCore -final class R2SQLJSONValueTests: XCTestCase { +@Suite("R2 SQL JSON values") +struct R2SQLJSONValueTests { private func decode(_ json: String) throws -> R2SQLJSONValue { try JSONDecoder().decode(R2SQLJSONValue.self, from: Data(json.utf8)) } - func testLargeInt64KeepsExactPrecision() throws { - let value = try decode("9223372036854775807") - XCTAssertEqual(value, .int(Int64.max)) - XCTAssertEqual(value.scalarText, "9223372036854775807") + @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) } - func testIntegerBeyondInt64MaxDecodesAsUnsigned() throws { - let value = try decode("9223372036854775808") - XCTAssertEqual(value, .uint(9_223_372_036_854_775_808)) - XCTAssertEqual(value.scalarText, "9223372036854775808") + @Test("A number no Decimal can hold is a decoding error, not NULL") + func outOfRangeNumberThrows() { + #expect(throws: DecodingError.self) { try decode("1e400") } } - func testIntegerAboveTwoToTheFiftyThreeIsNotRoundedByDouble() throws { - let value = try decode("9007199254740993") - XCTAssertEqual(value.scalarText, "9007199254740993") + @Test("Booleans stay booleans and never read as numbers") + func booleans() throws { + #expect(try decode("true") == .bool(true)) + #expect(try decode("1") == .number(1)) } - func testNegativeIntegerDecodes() throws { - XCTAssertEqual(try decode("-42"), .int(-42)) - } - - func testFractionalNumberDecodesAsDouble() throws { - XCTAssertEqual(try decode("1.5"), .double(1.5)) - } - - func testBooleansDecodeAsBool() throws { - XCTAssertEqual(try decode("true"), .bool(true)) - XCTAssertEqual(try decode("false"), .bool(false)) - } - - func testNullDecodesAsNull() throws { - XCTAssertTrue(try decode("null").isNull) - XCTAssertNil(try decode("null").scalarText) - } - - func testNestedNullInsideObjectIsPreserved() throws { - let value = try decode(#"{"count":null}"#) - XCTAssertEqual(value.jsonText(), #"{"count":null}"#) - } - - func testNestedArrayOfStructsSerializesStably() throws { - let value = try decode(#"[{"b":2,"a":1},{"a":3,"b":4}]"#) - XCTAssertEqual(value.jsonText(), #"[{"a":1,"b":2},{"a":3,"b":4}]"#) - } - - func testStringWithQuotesIsEscapedInJSONText() throws { - let value = try decode(#""he said \"hi\"""#) - XCTAssertEqual(value.scalarText, #"he said "hi""#) - XCTAssertEqual(value.jsonText(), #""he said \"hi\"""#) + @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/R2SQLQueryBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift deleted file mode 100644 index 02507a9939..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLQueryBuilderTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import XCTest -@testable import TableProR2SQLCore - -final class R2SQLQueryBuilderTests: XCTestCase { - func testBrowseQueryNeverEmitsOffset() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "analytics", table: "events", limit: 1_000) - XCTAssertFalse(sql.uppercased().contains("OFFSET")) - XCTAssertEqual(sql, "SELECT * FROM \"analytics\".\"events\" LIMIT 1000") - } - - func testFilteredQueryNeverEmitsOffset() { - let sql = R2SQLQueryBuilder.filteredQuery( - namespace: "analytics", - table: "events", - filters: [R2SQLFilter(column: "status", op: "=", value: "ok")], - matchAll: true, - limit: 100 - ) - XCTAssertFalse(sql.uppercased().contains("OFFSET")) - XCTAssertEqual(sql, "SELECT * FROM \"analytics\".\"events\" WHERE \"status\" = 'ok' LIMIT 100") - } - - func testLimitIsClampedToEngineMaximum() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "t", limit: 50_000) - XCTAssertTrue(sql.hasSuffix("LIMIT 10000")) - } - - func testLimitIsClampedToEngineMinimum() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "t", limit: 0) - XCTAssertTrue(sql.hasSuffix("LIMIT 1")) - } - - func testDottedNamespaceIsQuotedPerSegment() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "a.b", table: "t", limit: 10) - XCTAssertEqual(sql, "SELECT * FROM \"a\".\"b\".\"t\" LIMIT 10") - } - - func testEmptyNamespaceOmitsQualification() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "", table: "t", limit: 10) - XCTAssertEqual(sql, "SELECT * FROM \"t\" LIMIT 10") - } - - func testExplicitColumnsAreQuoted() { - let sql = R2SQLQueryBuilder.browseQuery( - namespace: "ns", - table: "t", - columns: ["id", "user name"], - limit: 10 - ) - XCTAssertEqual(sql, "SELECT \"id\", \"user name\" FROM \"ns\".\"t\" LIMIT 10") - } - - func testOrderByRendersDirectionPerColumn() { - let sql = R2SQLQueryBuilder.browseQuery( - namespace: "ns", - table: "t", - sortColumns: [ - R2SQLSortColumn(name: "ts", ascending: false), - R2SQLSortColumn(name: "id", ascending: true) - ], - limit: 10 - ) - XCTAssertEqual(sql, "SELECT * FROM \"ns\".\"t\" ORDER BY \"ts\" DESC, \"id\" ASC LIMIT 10") - } - - func testIdentifierWithEmbeddedQuoteIsEscaped() { - let sql = R2SQLQueryBuilder.browseQuery(namespace: "ns", table: "na\"me", limit: 10) - XCTAssertEqual(sql, "SELECT * FROM \"ns\".\"na\"\"me\" LIMIT 10") - } - - func testInjectionAttemptStaysInsideStringLiteral() { - let sql = R2SQLQueryBuilder.filteredQuery( - namespace: "ns", - table: "t", - filters: [R2SQLFilter(column: "name", op: "=", value: "'; DROP TABLE users; --")], - matchAll: true, - limit: 10 - ) - XCTAssertEqual( - sql, - "SELECT * FROM \"ns\".\"t\" WHERE \"name\" = '''; DROP TABLE users; --' LIMIT 10" - ) - } - - func testNullBytesAreStrippedFromLiterals() { - XCTAssertEqual(R2SQLLiteral.escapeStringLiteral("a\u{0}b"), "ab") - } - - func testOrFiltersJoinWithOr() { - let clause = R2SQLQueryBuilder.whereClause( - filters: [ - R2SQLFilter(column: "a", op: "=", value: "1"), - R2SQLFilter(column: "b", op: "=", value: "2") - ], - matchAll: false - ) - XCTAssertEqual(clause, "\"a\" = 1 OR \"b\" = 2") - } - - func testNullPredicatesRenderWithoutValue() { - XCTAssertEqual( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "a", op: "IS NULL", value: "")], - matchAll: true - ), - "\"a\" IS NULL" - ) - } - - func testContainsBecomesLikeWithWildcards() { - XCTAssertEqual( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "a", op: "contains", value: "abc")], - matchAll: true - ), - "\"a\" LIKE '%abc%'" - ) - } - - func testInListRendersEachItem() { - XCTAssertEqual( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "a", op: "IN", value: "1, 2, x")], - matchAll: true - ), - "\"a\" IN (1, 2, 'x')" - ) - } - - func testUnknownOperatorIsDroppedRatherThanInterpolated() { - XCTAssertNil( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "a", op: "; DROP TABLE t", value: "1")], - matchAll: true - ) - ) - } - - func testEmptyColumnFilterIsDropped() { - XCTAssertNil( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "", op: "=", value: "1")], - matchAll: true - ) - ) - } - - func testBooleanLiteralsAreNotQuoted() { - XCTAssertEqual( - R2SQLQueryBuilder.whereClause( - filters: [R2SQLFilter(column: "a", op: "=", value: "TRUE")], - matchAll: true - ), - "\"a\" = true" - ) - } - - func testCountQueryHasNoLimitAndNoOffset() { - let sql = R2SQLQueryBuilder.countQuery(namespace: "ns", table: "t") - XCTAssertEqual(sql, "SELECT COUNT(*) AS total FROM \"ns\".\"t\"") - XCTAssertFalse(sql.uppercased().contains("OFFSET")) - XCTAssertFalse(sql.uppercased().contains("LIMIT")) - } - - func testCountQueryAppliesFilters() { - let sql = R2SQLQueryBuilder.countQuery( - namespace: "ns", - table: "t", - filters: [R2SQLFilter(column: "a", op: ">", value: "5")] - ) - XCTAssertEqual(sql, "SELECT COUNT(*) AS total FROM \"ns\".\"t\" WHERE \"a\" > 5") - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift index bf58e911c3..485d923554 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift @@ -1,84 +1,32 @@ -import XCTest +import Foundation +import Testing @testable import TableProR2SQLCore -final class R2SQLRequestBuilderTests: XCTestCase { - private let config = R2SQLConnectionConfig( - accountId: "abc123", - bucket: "my-bucket", - token: "secret-token" - ) - - func testWarehouseIsAccountIdUnderscoreBucket() { - XCTAssertEqual(config.warehouse, "abc123_my-bucket") - } - - func testWarehouseRoundTripsThroughSplit() { - let parts = R2SQLWarehouse.split(config.warehouse) - XCTAssertEqual(parts?.accountId, "abc123") - XCTAssertEqual(parts?.bucket, "my-bucket") - } - - func testWarehouseSplitUsesFirstUnderscoreOnly() { - let parts = R2SQLWarehouse.split("acct_my_bucket_with_underscores") - XCTAssertEqual(parts?.accountId, "acct") - XCTAssertEqual(parts?.bucket, "my_bucket_with_underscores") - } - - func testWarehouseSplitRejectsMissingSeparator() { - XCTAssertNil(R2SQLWarehouse.split("nounderscore")) - } - - func testQueryURLMatchesDocumentedEndpoint() { - XCTAssertEqual( - config.queryURL?.absoluteString, - "https://api.sql.cloudflarestorage.com/api/v1/accounts/abc123/r2-sql/query/my-bucket" - ) - } - - func testRequestCarriesBearerTokenAndJSONContentType() throws { - let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1 FROM t") - XCTAssertEqual(request.headers["Authorization"], "Bearer secret-token") - XCTAssertEqual(request.headers["Content-Type"], "application/json") - } - - func testRequestBodyCarriesBothWarehouseAndQuery() throws { - let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT * FROM ns.t LIMIT 10") - let decoded = try XCTUnwrap( - JSONSerialization.jsonObject(with: request.body) as? [String: String] - ) - XCTAssertEqual(decoded["warehouse"], "abc123_my-bucket") - XCTAssertEqual(decoded["query"], "SELECT * FROM ns.t LIMIT 10") - XCTAssertEqual(decoded.count, 2) - } - - func testRequestUsesConfiguredTimeout() throws { - let timed = R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "t", timeoutSeconds: 15) - let request = try R2SQLRequestBuilder.queryRequest(config: timed, sql: "SELECT 1 FROM t") - XCTAssertEqual(request.timeoutSeconds, 15) - } - - func testMissingAccountIdIsRejected() { - let invalid = R2SQLConnectionConfig(accountId: "", bucket: "b", token: "t") - XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingAccountId)) - XCTAssertThrowsError(try R2SQLRequestBuilder.queryRequest(config: invalid, sql: "SELECT 1 FROM t")) - } - - func testMissingBucketIsRejected() { - let invalid = R2SQLConnectionConfig(accountId: "a", bucket: "", token: "t") - XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingBucket)) - } - - func testMissingTokenIsRejected() { - let invalid = R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "") - XCTAssertEqual(invalid.validate(), .configuration(R2SQLErrorText.missingToken)) - } - - func testValidConfigurationPassesValidation() { - XCTAssertNil(config.validate()) - } - - func testWhitespaceIsTrimmedFromIdentifiers() { - let padded = R2SQLConnectionConfig(accountId: " abc123 ", bucket: " my-bucket\n", token: "t") - XCTAssertEqual(padded.warehouse, "abc123_my-bucket") +@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 index d8c658190b..d0fa9cb908 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift @@ -1,75 +1,47 @@ -import XCTest +import Foundation +import Testing @testable import TableProR2SQLCore -final class R2SQLRowMapperTests: XCTestCase { - func testColumnOrderComesFromSchemaNotRowKeyOrder() { +@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: "zebra", type: "Utf8"), - R2SQLField(name: "alpha", type: "Int64") - ], - rows: [["alpha": .int(1), "zebra": .string("z")]] + 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) - XCTAssertEqual(mapped.columns, ["zebra", "alpha"]) - XCTAssertEqual(mapped.rows.first, [.text("z"), .text("1")]) - } - - func testMissingKeyBecomesNullRatherThanFailing() { - let result = R2SQLResult( - schema: [R2SQLField(name: "a", type: "Int64"), R2SQLField(name: "b", type: "Utf8")], - rows: [["a": .int(1)]] - ) - let mapped = R2SQLRowMapper.map(result) - XCTAssertEqual(mapped.rows.first, [.text("1"), .null]) - } - - func testExplicitJSONNullBecomesNull() { - let result = R2SQLResult( - schema: [R2SQLField(name: "a", type: "Int64")], - rows: [["a": .null]] - ) - XCTAssertEqual(R2SQLRowMapper.map(result).rows.first, [.null]) - } - func testStructColumnSerializesAsJSONText() { - let result = R2SQLResult( - schema: [R2SQLField(name: "s", type: "Struct(a Int64)")], - rows: [["s": .object(["a": .int(1)])]] - ) - let mapped = R2SQLRowMapper.map(result) - XCTAssertEqual(mapped.columnTypeNames, ["STRUCT"]) - XCTAssertEqual(mapped.rows.first, [.text("{\"a\":1}")]) + #expect(mapped.columns == ["b", "a"]) + #expect(mapped.columnTypeNames == ["TEXT", "BIGINT"]) + #expect(mapped.rows == [[.text("x"), .text("1")], [.null, .text("2")]]) } - func testListColumnSerializesAsJSONArray() { - let result = R2SQLResult( - schema: [R2SQLField(name: "l", type: "List(Int64)")], - rows: [["l": .array([.int(1), .int(2)])]] - ) - let mapped = R2SQLRowMapper.map(result) - XCTAssertEqual(mapped.columnTypeNames, ["ARRAY"]) - XCTAssertEqual(mapped.rows.first, [.text("[1,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")) } - func testEmptySchemaProducesEmptyResultSet() { - let mapped = R2SQLRowMapper.map(R2SQLResult(schema: [], rows: [])) - XCTAssertEqual(mapped, .empty) + @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")) } - func testFirstColumnStringsExtractsNames() { - let result = R2SQLResult( - schema: [R2SQLField(name: "namespace", type: "Utf8")], - rows: [["namespace": .string("analytics")], ["namespace": .string("logs")]] - ) - XCTAssertEqual(R2SQLRowMapper.firstColumnStrings(result), ["analytics", "logs"]) + @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==")) } - func testFirstColumnStringsSkipsBlanksAndNulls() { - let result = R2SQLResult( - schema: [R2SQLField(name: "n", type: "Utf8")], - rows: [["n": .string("a")], ["n": .null], ["n": .string(" ")]] - ) - XCTAssertEqual(R2SQLRowMapper.firstColumnStrings(result), ["a"]) + @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 index afcced479b..a1993e7d3a 100644 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift +++ b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift @@ -1,78 +1,32 @@ -import XCTest +import Testing @testable import TableProR2SQLCore -final class R2SQLTypeMapperTests: XCTestCase { - func testArrowTextTypesNormalizeToString() { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Utf8"), "STRING") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "LargeUtf8"), "STRING") - } - - func testArrowListTypesNormalizeToArray() { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "List(Int64)"), "ARRAY") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "LargeList(Utf8)"), "ARRAY") - } - - func testStructAndMapNormalize() { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Struct(a Int64)"), "STRUCT") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "Map(Utf8, Int64)"), "MAP") - } - - func testTypesTablePromAlreadyUnderstandsArePassedThrough() { - for name in ["Int64", "Float64", "Boolean", "Date32", "Decimal128(10, 2)", "Timestamp(Microsecond, None)"] { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: name), name) - } - } - - func testNormalizationIsCaseInsensitive() { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "utf8"), "STRING") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: "STRUCT"), "STRUCT") - } - - func testEmptyTypeStaysEmpty() { - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(rawTypeName: ""), "") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(for: R2SQLField(name: "a", rawType: nil)), "") - } - - func testCategoryClassifiesStructuredBinaryAndScalar() { - XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Struct(a Int64)"), .structured) - XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "List(Int64)"), .structured) - XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Map(Utf8, Int64)"), .structured) - XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Binary"), .binary) - XCTAssertEqual(R2SQLTypeMapper.category(rawTypeName: "Int64"), .scalar) - } - - func testBinaryValueDecodesFromBase64() { - let value = R2SQLTypeMapper.value(for: .string("AQID"), rawTypeName: "Binary") - XCTAssertEqual(value, .bytes([1, 2, 3])) - } - - func testBinaryValueFallsBackToTextWhenNotBase64() { - let value = R2SQLTypeMapper.value(for: .string("not base64!"), rawTypeName: "Binary") - XCTAssertEqual(value, .text("not base64!")) - } - - func testNilAndNullBecomeNullValue() { - XCTAssertEqual(R2SQLTypeMapper.value(for: nil, rawTypeName: "Int64"), .null) - XCTAssertEqual(R2SQLTypeMapper.value(for: .null, rawTypeName: "Int64"), .null) - } - - func testFieldTypeNameReadsObjectShapedType() { - let field = R2SQLField(name: "a", rawType: .object(["name": .string("struct")])) - XCTAssertEqual(field.typeName, "struct") - XCTAssertEqual(R2SQLTypeMapper.displayTypeName(for: field), "STRUCT") - } - - func testFieldDecodesFlatStringType() throws { - let json = #"{"name":"id","type":"Int64"}"# - let field = try JSONDecoder().decode(R2SQLField.self, from: Data(json.utf8)) - XCTAssertEqual(field.name, "id") - XCTAssertEqual(field.typeName, "Int64") - } - - func testFieldDecodesObjectShapedTypeWithoutFailing() throws { - let json = #"{"name":"s","type":{"name":"struct","fields":[]}}"# - let field = try JSONDecoder().decode(R2SQLField.self, from: Data(json.utf8)) - XCTAssertEqual(field.name, "s") - XCTAssertEqual(field.typeName, "struct") +@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..98935910fd --- /dev/null +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift @@ -0,0 +1,100 @@ +// +// 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 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 index 4afa744984..69af034e65 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPlugin.swift @@ -9,16 +9,14 @@ 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 over Apache Iceberg tables in R2" + 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 = "Cloudflare R2 SQL" - static let iconName = "cloudflare-r2-sql-icon" + static let databaseDisplayName = CloudflareR2SQLMetadata.displayName + static let iconName = CloudflareR2SQLMetadata.iconName static let defaultPort = 0 - // MARK: - UI/Capability Metadata - static let connectionMode: ConnectionMode = .apiOnly static let supportsSSH = false static let supportsSSL = false @@ -26,8 +24,6 @@ final class CloudflareR2SQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsExport = true static let supportsSchemaEditing = false - static let supportsTriggers = false - static let supportsTriggerEditing = false static let supportsForeignKeys = false static let supportsDropDatabase = false static let supportsCascadeDrop = false @@ -40,83 +36,20 @@ final class CloudflareR2SQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsModifyPrimaryKey = false static let supportsDatabaseSwitching = false static let supportsSchemaSwitching = true - static let supportsHealthMonitor = true + static let supportsHealthMonitor = false static let supportsQueryProgress = false static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema - static let schemaEntityName = "Namespace" - static let containerEntityName = "Bucket" - static let brandColorHex = "#F6821F" + 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: [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] - - static let columnTypesByCategory: [String: [String]] = [ - "Integer": ["INT32", "INT64", "INTEGER"], - "Float": ["FLOAT32", "FLOAT64", "DECIMAL128"], - "String": ["STRING", "UTF8"], - "Date": ["DATE32", "TIMESTAMP"], - "Binary": ["BINARY"], - "Boolean": ["BOOLEAN"], - "Nested": ["ARRAY", "STRUCT", "MAP"] - ] - - static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "QUALIFY", - "ORDER", "ASC", "DESC", "LIMIT", "AS", "ON", "USING", - "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", - "AND", "OR", "NOT", "IN", "EXISTS", "LIKE", "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", "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", "ARROW_CAST", "ARROW_TYPEOF", - "ARRAY_LENGTH", "ARRAY_MAX", "ARRAY_MIN", "MAP_KEYS", "MAP_VALUES" - ], - dataTypes: [ - "BOOLEAN", "INT32", "INT64", "FLOAT32", "FLOAT64", "DECIMAL128", - "STRING", "UTF8", "DATE32", "TIMESTAMP", "BINARY", - "ARRAY", "STRUCT", "MAP" - ], - regexSyntax: .regexpLike, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .limit - ) - - static let additionalConnectionFields: [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 - ) - ] + 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 index f52a8dc789..f609480f23 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift @@ -10,146 +10,17 @@ import TableProR2SQLCore extension CloudflareR2SQLPluginDriver { func execute(query: String) async throws -> PluginQueryResult { let started = Date() - let result = try await run(sql: query) - return Self.pluginResult(result, executionTime: Date().timeIntervalSince(started)) - } - - func buildBrowseQuery( - table: String, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? { - buildBrowseQuery( - table: table, - schema: currentSchema, - sortColumns: sortColumns, - columns: columns, - limit: limit, - offset: offset - ) - } - - func buildBrowseQuery( - table: String, - schema: String?, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? { - guard let namespace = resolveNamespace(schema) else { return nil } - return R2SQLQueryBuilder.browseQuery( - namespace: namespace, - table: table, - columns: columns, - sortColumns: Self.sortColumns(sortColumns, in: columns), - limit: limit - ) - } - - func buildFilteredQuery( - table: String, - filters: [(column: String, op: String, value: String)], - logicMode: String, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? { - buildFilteredQuery( - table: table, - schema: currentSchema, - filters: filters, - logicMode: logicMode, - sortColumns: sortColumns, - columns: columns, - limit: limit, - offset: offset - ) - } - - func buildFilteredQuery( - table: String, - schema: String?, - filters: [(column: String, op: String, value: String)], - logicMode: String, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? { - guard let namespace = resolveNamespace(schema) else { return nil } - return R2SQLQueryBuilder.filteredQuery( - namespace: namespace, - table: table, - filters: filters.map { R2SQLFilter(column: $0.column, op: $0.op, value: $0.value) }, - matchAll: logicMode.lowercased() != "or", - columns: columns, - sortColumns: Self.sortColumns(sortColumns, in: columns), - limit: limit - ) - } - - func fetchExactRowCount( - table: String, - schema: String?, - filters: [(column: String, op: String, value: String)], - logicMode: String - ) async throws -> Int? { - guard let namespace = resolveNamespace(schema) else { return nil } - let sql = R2SQLQueryBuilder.countQuery( - namespace: namespace, - table: table, - filters: filters.map { R2SQLFilter(column: $0.column, op: $0.op, value: $0.value) }, - matchAll: logicMode.lowercased() != "or" - ) - let result = try await run(sql: sql) - return R2SQLRowMapper.firstColumnStrings(result).first.flatMap(Int.init) - } - - func defaultExportQuery(table: String, schema: String?) -> String? { - guard let namespace = resolveNamespace(schema) else { return nil } - return R2SQLQueryBuilder.browseQuery( - namespace: namespace, - table: table, - limit: R2SQLLimits.maxLimit - ) - } - - func quoteIdentifier(_ name: String) -> String { - R2SQLLiteral.quoteIdentifier(name) - } - - func escapeStringLiteral(_ value: String) -> String { - R2SQLLiteral.escapeStringLiteral(value) - } - - static func sortColumns( - _ sortColumns: [(columnIndex: Int, ascending: Bool)], - in columns: [String] - ) -> [R2SQLSortColumn] { - sortColumns.compactMap { sort in - guard sort.columnIndex >= 0, sort.columnIndex < columns.count else { return nil } - return R2SQLSortColumn(name: columns[sort.columnIndex], ascending: sort.ascending) - } - } - - static func pluginResult(_ result: R2SQLResult, executionTime: TimeInterval) -> PluginQueryResult { - let mapped = R2SQLRowMapper.map(result) + let mapped = R2SQLRowMapper.map(try await run(sql: query)) return PluginQueryResult( columns: mapped.columns, columnTypeNames: mapped.columnTypeNames, - rows: mapped.rows.map { $0.map(cellValue) }, + rows: mapped.rows.map { $0.map(Self.cellValue) }, rowsAffected: 0, - executionTime: executionTime, - isTruncated: false, - statusMessage: nil + executionTime: Date().timeIntervalSince(started) ) } - static func cellValue(_ value: R2SQLValue) -> PluginCellValue { + private static func cellValue(_ value: R2SQLValue) -> PluginCellValue { switch value { case .null: return .null diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift index bc542a2cd2..9e4fc6da92 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift @@ -9,8 +9,7 @@ import TableProR2SQLCore extension CloudflareR2SQLPluginDriver { func fetchDatabases() async throws -> [String] { - guard let bucket = resolvedConfig?.bucket, !bucket.isEmpty else { return [] } - return [bucket] + [connectionConfig.bucket] } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -18,33 +17,25 @@ extension CloudflareR2SQLPluginDriver { } func fetchSchemas() async throws -> [String] { - let result = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) - return R2SQLRowMapper.firstColumnStrings(result).sorted() + try R2SQLIntrospectionSQL.namespaces(from: try await run(sql: R2SQLIntrospectionSQL.showNamespaces)) } func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - guard let namespace = resolveNamespace(schema) else { return [] } - let result = try await run(sql: R2SQLIntrospectionSQL.showTables(namespace: namespace)) - return R2SQLRowMapper.firstColumnStrings(result).sorted().map { name in + 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] { - guard let namespace = resolveNamespace(schema) else { return [] } - let result = try await run(sql: R2SQLIntrospectionSQL.describe(namespace: namespace, table: table)) - let mapped = R2SQLRowMapper.map(result) - - return mapped.rows.compactMap { row -> PluginColumnInfo? in - guard let name = Self.text(row.first), !name.isEmpty else { return nil } - let rawType = row.count > 1 ? Self.text(row[1]) ?? "" : "" - let nullable = Self.parseNullable(row.count > 2 ? Self.text(row[2]) : nil) - return PluginColumnInfo( - name: name, - dataType: R2SQLTypeMapper.displayTypeName(rawTypeName: rawType), - isNullable: nullable, + try await describe(table: table, schema: schema).columns.map { column in + PluginColumnInfo( + name: column.name, + dataType: column.typeName, + isNullable: column.isNullable, defaultValue: nil, - comment: nil + comment: column.comment ) } } @@ -62,36 +53,28 @@ extension CloudflareR2SQLPluginDriver { } func fetchTableDDL(table: String, schema: String?) async throws -> String { - guard let namespace = resolveNamespace(schema) else { - throw R2SQLError.configuration(R2SQLErrorText.noNamespace) - } - let columns = try await fetchColumns(table: table, schema: namespace) - guard !columns.isEmpty else { - throw R2SQLError.query(R2SQLAPIError(code: 0, message: "No columns found for \(table)")) - } - let body = columns - .map { " \(R2SQLLiteral.quoteIdentifier($0.name)) \($0.dataType)\($0.isNullable ? "" : " NOT NULL")" } + 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 = R2SQLLiteral.qualifiedName(namespace: namespace, table: table) + 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(R2SQLErrorText.noViews) + throw R2SQLError.unsupported("R2 SQL has no views.") } - static func text(_ value: R2SQLValue?) -> String? { - guard case .text(let text)? = value else { return nil } - return text - } - - static func parseNullable(_ value: String?) -> Bool { - guard let value else { return true } - switch value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { - case "NO", "FALSE", "0", "NOT NULL": - return false - default: - return true - } + 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 index 06ff3aeb3f..7609541a2a 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift @@ -12,106 +12,86 @@ final class CloudflareR2SQLPluginDriver: PluginDatabaseDriver, @unchecked Sendab static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareR2SQL") private let lock = NSLock() - private var connectionConfig: R2SQLConnectionConfig? private var namespace: String? private var isConnected = false + private let queryTimeout = HttpQueryTimeoutBox() let transport: R2SQLTransport - let config: DriverConnectionConfig - - init(config: DriverConnectionConfig, transport: R2SQLTransport? = nil) { - self.config = config - self.transport = transport ?? URLSessionR2SQLTransport() - } - - // MARK: - Capabilities - - var capabilities: PluginCapabilities { - [.cancelQuery] + 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 } - // MARK: - Connection State - - var resolvedConfig: R2SQLConnectionConfig? { - lock.withLock { connectionConfig } - } - var currentSchema: String? { lock.withLock { namespace } } func switchSchema(to schema: String) async throws { - lock.withLock { namespace = schema } + lock.withLock { namespace = schema.isEmpty ? nil : schema } } - func resolveNamespace(_ schema: String?) -> String? { + func namespace(for schema: String?) throws -> String { if let schema, !schema.isEmpty { return schema } - let current = currentSchema - if let current, !current.isEmpty { return current } - return nil + guard let current = currentSchema else { + throw R2SQLError.configuration("Choose a namespace first.") + } + return current } // MARK: - Lifecycle func connect() async throws { - let resolved = Self.buildConfig(from: config) - if let error = resolved.validate() { + _ = try connectionConfig.validated() + lock.withLock { isConnected = true } + do { + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces) + } catch { + lock.withLock { isConnected = false } throw error } - lock.withLock { - connectionConfig = resolved - if namespace == nil { - namespace = resolved.defaultNamespace.isEmpty ? nil : resolved.defaultNamespace - } - isConnected = true - } - _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) } func disconnect() { - lock.withLock { - connectionConfig = nil - isConnected = false - } + lock.withLock { isConnected = false } + transport.cancelAll() } func ping() async throws { - _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces()) + _ = try await run(sql: R2SQLIntrospectionSQL.showNamespaces) } func cancelQuery() throws { - (transport as? URLSessionR2SQLTransport)?.cancelInFlight() + transport.cancelAll() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + queryTimeout.set(serverTimeoutSeconds: seconds) } // MARK: - Transport func run(sql: String) async throws -> R2SQLResult { - guard let resolved = resolvedConfig else { - throw R2SQLError.notConnected - } - let request = try R2SQLRequestBuilder.queryRequest(config: resolved, sql: sql) - let response = try await transport.send(request) - switch R2SQLErrorClassifier.decode(response) { - case .success(let result): - return result - case .failure(let error): - throw error - } - } - - private static func buildConfig(from config: DriverConnectionConfig) -> R2SQLConnectionConfig { - R2SQLConnectionConfig( - accountId: config.additionalFields["r2AccountId"] ?? "", - bucket: config.additionalFields["r2Bucket"] ?? "", - token: config.password, - defaultNamespace: config.additionalFields["r2Namespace"] ?? "", - timeoutSeconds: 60 + 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/R2SQLURLSessionTransport.swift b/Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift deleted file mode 100644 index 9bf1462e09..0000000000 --- a/Plugins/CloudflareR2SQLDriverPlugin/R2SQLURLSessionTransport.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// R2SQLURLSessionTransport.swift -// TablePro -// - -import Foundation -import TableProR2SQLCore - -final class URLSessionR2SQLTransport: NSObject, R2SQLTransport, @unchecked Sendable { - private let session: URLSession - private let lock = NSLock() - private var inFlight: URLSessionDataTask? - - override init() { - let configuration = URLSessionConfiguration.ephemeral - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - session = URLSession(configuration: configuration) - super.init() - } - - func cancelInFlight() { - let task = lock.withLock { inFlight } - task?.cancel() - } - - func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse { - var urlRequest = URLRequest(url: request.url) - urlRequest.httpMethod = "POST" - urlRequest.httpBody = request.body - urlRequest.timeoutInterval = TimeInterval(request.timeoutSeconds) - for (name, value) in request.headers { - urlRequest.setValue(value, forHTTPHeaderField: name) - } - - let (data, response) = try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation<(Data, URLResponse), Error>) in - let task = session.dataTask(with: urlRequest) { data, response, error in - if let error { - if (error as? URLError)?.code == .cancelled { - continuation.resume(throwing: R2SQLError.cancelled) - } else { - continuation.resume(throwing: R2SQLError.transport(error.localizedDescription)) - } - return - } - guard let data, let response else { - continuation.resume(throwing: R2SQLError.transport("Empty response from R2 SQL")) - return - } - continuation.resume(returning: (data, response)) - } - lock.withLock { inFlight = task } - task.resume() - } - - lock.withLock { inFlight = nil } - - guard let httpResponse = response as? HTTPURLResponse else { - throw R2SQLError.transport("Response was not HTTP") - } - return R2SQLHTTPResponse(statusCode: httpResponse.statusCode, body: data) - } -} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift index a0b2661f13..8d7f0b7b46 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+R2SQLDefaults.swift @@ -15,7 +15,7 @@ extension PluginMetadataRegistry { isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: r2SQLExplainVariants, pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: [], + supportsHealthMonitor: false, urlSchemes: [], postConnectActions: [.selectSchemaFromLastSession], brandColorHex: "#F6821F", queryLanguageName: "SQL", editorLanguage: .sql, diff --git a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift new file mode 100644 index 0000000000..72f83f5905 --- /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: 10_000)) + } +} diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 1178f8d0fd..9a5d4e038c 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. The connection runs at Safe Mode **Read-Only** whatever level it was given, so INSERT, UPDATE, DELETE, cell editing and schema editing are all refused. Edit the ledger in a text editor; the next query picks the change up. See [Safe Mode](/features/safe-mode#connections-held-at-read-only). +- No writes. The connection runs at Safe Mode **Read-Only** whatever level it was given, so INSERT, UPDATE, DELETE, cell editing and schema editing are all refused. Edit the ledger in a text editor; the next query picks the change up. See [Safe Mode](/features/safe-mode#connections-that-are-always-read-only). - 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 index 4963b61953..ac70117632 100644 --- a/docs/databases/cloudflare-r2-sql.mdx +++ b/docs/databases/cloudflare-r2-sql.mdx @@ -1,114 +1,108 @@ --- title: Cloudflare R2 SQL -description: Run read-only SQL against Apache Iceberg tables in a Cloudflare R2 bucket +description: Query Apache Iceberg tables in an R2 bucket with Cloudflare's read-only SQL engine --- -TablePro connects to Cloudflare R2 SQL, the serverless query engine that reads Apache Iceberg tables stored in an R2 bucket. The bucket's tables are registered in R2 Data Catalog, and TablePro queries them over the R2 SQL HTTP API at `https://api.sql.cloudflarestorage.com`. There is no host, port, or tunnel involved. +import RegistryPlugin from "/snippets/registry-plugin.mdx"; -R2 SQL runs `SELECT` and nothing else, so these connections are read-only. See [Read-only connections](#read-only-connections). +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. -Cloudflare's own reference is the [R2 SQL documentation](https://developers.cloudflare.com/r2-sql/). - -## Install the plugin - -Cloudflare R2 SQL is a registry driver. Pick **Cloudflare R2 SQL** in the database type chooser and TablePro offers to download it, or install it up front from **Settings > Plugins > Browse > Cloudflare R2 SQL Driver**. The driver loads without restarting the app. See [Plugins](/features/plugins). + ## Connection settings -| Field | Description | -|-------|-------------| -| **Account ID** | Your Cloudflare account ID. | -| **Bucket** | The R2 bucket holding the Iceberg tables. It needs R2 Data Catalog enabled. | -| **API Token** | Cloudflare API token, entered in the built-in password field (labeled **API Token** for this driver). Stored in the macOS Keychain. | - -That is the whole form. There is no username, port, SSH Tunnel section, or SSL/TLS section: the API is HTTPS only. The Iceberg warehouse name is derived from the account ID and the bucket, so you never type it. - -Click **Test Connection** to verify, then **Save & Connect**. +| 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 | -## Getting your credentials - -**Account ID**: on the [Cloudflare dashboard](https://dash.cloudflare.com) right sidebar, or run `npx wrangler whoami`. +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**. -**Bucket**: the bucket name from **R2 Object Storage** in the dashboard. Enable R2 Data Catalog on it first. A bucket without the catalog has no tables to query. +## Connection URL -**API Token**: +R2 SQL has no connection URL. Fill in the form instead. -1. Go to [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) -2. Click **Create Token** and pick the **Custom token** template -3. Add three permission groups: **R2 SQL**, **R2 Data Catalog**, and **R2 Storage** -4. Save and copy the token +## Getting your credentials -All three groups are needed, one per layer the query touches: R2 SQL runs the query, R2 Data Catalog lists the namespaces and tables, R2 Storage reads the data files. + + + 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 grants access to R2 across your account, not just this bucket. Scope it to the account you need and rotate it like any other credential. +The token reaches every bucket its permissions cover, not only the one this connection names. ## Namespaces and tables -Iceberg groups tables into namespaces. TablePro maps a namespace to a schema, so the sidebar shows one **Namespace** node per namespace with its tables underneath, and the switcher in the toolbar reads **Namespace** too. TablePro reads the tree with `SHOW NAMESPACES`, `SHOW TABLES IN `, and `DESCRIBE .
`. +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. -Qualify tables with their namespace in a query tab: +Name the namespace in a query tab: ```sql SELECT user_id, event, ts -FROM default.events -WHERE ts >= TIMESTAMP '2026-01-01 00:00:00' +FROM logs.events +WHERE ts >= '2026-01-01T00:00:00Z' ORDER BY ts DESC LIMIT 1000 ``` -One connection covers one bucket. Add another connection for another bucket. +R2 SQL converts nothing implicitly: quote strings, leave numbers bare, and give timestamps a time zone. ## Read-only connections -R2 SQL has no `INSERT`, `UPDATE`, `DELETE`, or DDL. TablePro pins the connection to Safe Mode **Read-Only** and disables the Safe Mode picker in the connection form, so: - -- Cell editing, row insert, row delete, and duplicate row are off in the data grid -- The Structure tab lists columns, types, and nullability, and creates or alters nothing -- Import is unavailable. Export works. See [Import and Export](/features/import-export) - -Loading these tables happens outside TablePro, through whichever Iceberg writer feeds the catalog. See [Safe Mode](/features/safe-mode). +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 rejects `OFFSET` and caps `LIMIT` at 10,000 rows. A query with no `LIMIT` returns 500 rows. - -Without `OFFSET` there is no way to skip rows, so a table tab loads one capped page and the First / Previous / Next / Last controls are hidden. Two ways to work through a large table: +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). -- Narrow it with [filters](/features/filtering) and sorting. Both compile into the query, so the server does the work. -- Write keyset pagination in a query tab, carrying the last key of the previous page forward: +Past the first 10,000, page by key in a query tab, carrying the last value of the previous page forward: ```sql -SELECT * FROM default.events +SELECT * FROM logs.events WHERE event_id > '01HQ7Z2K3M4N5P6Q7R8S9T0V' ORDER BY event_id LIMIT 1000 ``` -## SQL support +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. -Supported: `SELECT`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, `LIMIT`, joins, subqueries, CTEs, window functions with an inline `OVER` clause, set operations, `EXPLAIN`, and `EXPLAIN FORMAT JSON`. +## Cost -Not supported: `OFFSET`, `LATERAL`, `UNNEST`, `PIVOT` and `UNPIVOT`, joins nested in parentheses, `PERCENTILE_DISC`, and the named `WINDOW` clause. An inline `OVER (...)` covers what a named window would. +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 -The Explain dropdown in the query editor offers **Explain** (`EXPLAIN`) and **Explain (JSON)** (`EXPLAIN FORMAT JSON`). Both show the plan as raw text. See [Explain Visualization](/features/explain-visualization). +- 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 failed**: check the token carries all three permission groups, the Account ID belongs to the account that owns the bucket, and the token has not expired or been revoked. +### `Authentication error` -**No namespaces after connect**: the bucket has no R2 Data Catalog enabled, or the catalog holds no tables yet. +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. -**`unsupported feature: OFFSET clause is not supported`**: a query in the editor uses `OFFSET`. Rewrite it with keyset pagination, see [Pagination](#pagination). +### No namespaces after connecting -**Only 500 rows came back**: the query had no `LIMIT`, so R2 SQL applied its default. Add an explicit `LIMIT`, up to 10,000. +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. -## Limitations +### `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` -- Read-only. No writes, no DDL, no transactions. -- No `OFFSET`, and 10,000 rows per query is the ceiling, so table tabs show a single page. -- No primary keys, foreign keys, or indexes. The ER diagram opens with every table unconnected. -- No import. Export works. -- No SSH tunnel, Cloudflare Tunnel, SOCKS proxy, or SSL/TLS section. The API is HTTPS only. -- One bucket per connection, and no bucket switcher in the toolbar. +A query tab query uses `OFFSET`. Rewrite it with keyset paging, as in [Pagination](#pagination). diff --git a/docs/databases/sqlite.mdx b/docs/databases/sqlite.mdx index 319c6022fe..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 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-held-at-read-only), so the grid and the editor refuse edits that would only change the copy. +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/features/safe-mode.mdx b/docs/features/safe-mode.mdx index ccdabbdb8c..c485e6ba70 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -20,7 +20,7 @@ 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 held at Read-Only +## 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. diff --git a/project.yml b/project.yml index fd6ab9f999..ba7448e8ea 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 From 3f8b40e64d52289615d8842f39c7bc49121a50f5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 18:43:02 +0700 Subject: [PATCH 5/7] fix(connections): keep a held connection's saved level and name results cut at the R2 SQL ceiling Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- CHANGELOG.md | 1 - .../CloudflareR2SQLMetadata.swift | 1 + .../CloudflareR2SQLPluginDriver+Query.swift | 13 +++++- .../Database/DatabaseManager+Sessions.swift | 10 +++++ ...ginMetadataRegistry+RegistryDefaults.swift | 1 - .../Core/Services/Export/ExportService.swift | 16 +++++++- .../Views/Main/MainContentCoordinator.swift | 2 +- .../Services/LeadingRowsStatementTests.swift | 9 ++++ .../SyncRecordMapperConnectionTests.swift | 2 +- .../Models/ReadOnlyEnforcementTests.swift | 41 ++++++++++++++++--- .../CloudflareR2SQLMetadataParityTests.swift | 2 +- docs/databases/beancount.mdx | 2 +- docs/features/safe-mode.mdx | 1 - 13 files changed, 86 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272552d7f5..1148126050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,6 @@ 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 diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift index 98935910fd..4875d44177 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift @@ -17,6 +17,7 @@ enum CloudflareR2SQLMetadata { static let defaultSchemaName = "" static let schemaEntityName = "Namespace" static let containerEntityName = "Bucket" + static let maximumRows = 10_000 static let accountIdFieldId = "r2AccountId" static let bucketFieldId = "r2Bucket" diff --git a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift index f609480f23..894433f25e 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift +++ b/Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift @@ -16,7 +16,18 @@ extension CloudflareR2SQLPluginDriver { columnTypeNames: mapped.columnTypeNames, rows: mapped.rows.map { $0.map(Self.cellValue) }, rowsAffected: 0, - executionTime: Date().timeIntervalSince(started) + 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 ) } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index be34133f72..a551bcefa5 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -588,6 +588,16 @@ extension DatabaseManager { setSession(updated, for: connectionId) } + /// The user picking a level from the toolbar or the Database menu. + /// + /// A connection held at Read-Only offers only the level already in force, so a pick there + /// changes nothing, and writing it would replace the level the user saved, which is the one + /// that comes back once the connection stops being held. + func chooseSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { + guard activeSessions[connectionId]?.connection.readOnlyEnforcement == nil else { return } + setSafeModeLevel(level, for: connectionId) + } + func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { guard var session = activeSessions[connectionId] else { return } guard session.connection.preferredSafeModeLevel != level diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index d8f4b855e9..49a30c01c9 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -717,7 +717,6 @@ extension PluginMetadataRegistry { supportsAddIndex: false, supportsDropIndex: false, supportsModifyPrimaryKey: false, - isEngineReadOnly: true, localFilePathField: .database ), schema: PluginMetadataSnapshot.SchemaInfo( diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 9efc950c10..1f450ba3c6 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -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/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 82adc3c13e..f7a38b934a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -124,7 +124,7 @@ final class MainContentCoordinator { } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } func setSafeModeLevel(_ level: SafeModeLevel) { - services.databaseManager.setSafeModeLevel(level, for: connectionId) + services.databaseManager.chooseSafeModeLevel(level, for: connectionId) toolbarState.safeModeLevel = services.databaseManager.session(for: connectionId)?.safeModeLevel ?? level } let selectionState = GridSelectionState() diff --git a/TableProTests/Core/Services/LeadingRowsStatementTests.swift b/TableProTests/Core/Services/LeadingRowsStatementTests.swift index 890ad8b9d8..91db5b2347 100644 --- a/TableProTests/Core/Services/LeadingRowsStatementTests.swift +++ b/TableProTests/Core/Services/LeadingRowsStatementTests.swift @@ -92,6 +92,15 @@ struct LeadingRowsStatementTests { #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/Sync/SyncRecordMapperConnectionTests.swift b/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift index c30d6960d8..3db4ec0162 100644 --- a/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift +++ b/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift @@ -92,7 +92,7 @@ struct SyncRecordMapperConnectionTests { @Test("A connection the engine holds at Read-Only syncs the user's own level") func enforcedReadOnlyIsNotSynced() { - let connection = DatabaseConnection(name: "Ledger", type: .beancount, safeModeLevel: .alert) + let connection = DatabaseConnection(name: "Iceberg", type: .cloudflareR2SQL, safeModeLevel: .alert) let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) diff --git a/TableProTests/Models/ReadOnlyEnforcementTests.swift b/TableProTests/Models/ReadOnlyEnforcementTests.swift index be408223c8..f28a6f865d 100644 --- a/TableProTests/Models/ReadOnlyEnforcementTests.swift +++ b/TableProTests/Models/ReadOnlyEnforcementTests.swift @@ -34,11 +34,9 @@ struct ReadOnlyEnforcementTests { #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .remoteDatabaseFile) == (level == .readOnly)) } - @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) + @Test("A read-only engine reads as Read-Only and keeps the user's own level") + func readOnlyEngine() { + let connection = DatabaseConnection(name: "Engine", type: .cloudflareR2SQL, safeModeLevel: .alert) #expect(connection.readOnlyEnforcement == .readOnlyEngine) #expect(connection.safeModeLevel == .readOnly) @@ -94,10 +92,17 @@ struct ReadOnlyEnforcementTests { @Test("A session starts at the enforced level") func sessionSeedsEnforcedLevel() { #expect(ConnectionSession(connection: remoteFileConnection()).safeModeLevel == .readOnly) - let engine = DatabaseConnection(name: "Ledger", type: .beancount, safeModeLevel: .silent) + let engine = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .silent) #expect(ConnectionSession(connection: engine).safeModeLevel == .readOnly) } + @Test("Beancount keeps the level it was given, because BQL queries do not classify as reads") + func beancountIsNotEnforced() { + let ledger = DatabaseConnection(name: "Ledger", type: .beancount, safeModeLevel: .silent) + #expect(ledger.readOnlyEnforcement == nil) + #expect(ledger.safeModeLevel == .silent) + } + @Test("Choosing a weaker level on an enforced session keeps it Read-Only") func setSafeModeLevelKeepsEnforcement() { let connection = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .readOnly) @@ -112,6 +117,30 @@ struct ReadOnlyEnforcementTests { #expect(session?.connection.preferredSafeModeLevel == .silent) } + @Test("Picking Read-Only 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 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) diff --git a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift index 72f83f5905..9739be1d50 100644 --- a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift +++ b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift @@ -73,6 +73,6 @@ struct CloudflareR2SQLMetadataParityTests { let capabilities = try curated().capabilities #expect(capabilities.isEngineReadOnly) - #expect(capabilities.pagination == .leadingRowsOnly(maximumRows: 10_000)) + #expect(capabilities.pagination == .leadingRowsOnly(maximumRows: CloudflareR2SQLMetadata.maximumRows)) } } diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 9a5d4e038c..224de31c9f 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. The connection runs at Safe Mode **Read-Only** whatever level it was given, so INSERT, UPDATE, DELETE, cell editing and schema editing are all refused. Edit the ledger in a text editor; the next query picks the change up. See [Safe Mode](/features/safe-mode#connections-that-are-always-read-only). +- 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 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/features/safe-mode.mdx b/docs/features/safe-mode.mdx index c485e6ba70..34d27cdd55 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -26,7 +26,6 @@ A connection that cannot take a write runs at **Read-Only** whatever level it wa | Connection | Condition | |------------|-----------| -| [Beancount](/databases/beancount) | The engine runs read queries only | | [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 | From 94b7fab2df6627dd68c92518a1c5f90d7df1dcfd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 20:20:36 +0700 Subject: [PATCH 6/7] fix(plugin-beancount): classify BQL as a read and hold Beancount connections at Read-Only Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- CHANGELOG.md | 1 + ...ginMetadataRegistry+RegistryDefaults.swift | 1 + .../Core/Utilities/SQL/QueryClassifier.swift | 15 ++++++++ .../SQL/QueryClassifierBeancountTests.swift | 38 +++++++++++++++++++ .../Models/ReadOnlyEnforcementTests.swift | 14 +++---- docs/databases/beancount.mdx | 2 +- docs/features/safe-mode.mdx | 1 + 7 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 TableProTests/Core/Utilities/SQL/QueryClassifierBeancountTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 508f31e068..e162a57cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ 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 diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 49a30c01c9..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( 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/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/ReadOnlyEnforcementTests.swift b/TableProTests/Models/ReadOnlyEnforcementTests.swift index f28a6f865d..b9bd353c17 100644 --- a/TableProTests/Models/ReadOnlyEnforcementTests.swift +++ b/TableProTests/Models/ReadOnlyEnforcementTests.swift @@ -34,9 +34,11 @@ struct ReadOnlyEnforcementTests { #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .remoteDatabaseFile) == (level == .readOnly)) } - @Test("A read-only engine reads as Read-Only and keeps the user's own level") - func readOnlyEngine() { - let connection = DatabaseConnection(name: "Engine", type: .cloudflareR2SQL, safeModeLevel: .alert) + @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.readOnlyEnforcement == .readOnlyEngine) #expect(connection.safeModeLevel == .readOnly) @@ -96,12 +98,6 @@ struct ReadOnlyEnforcementTests { #expect(ConnectionSession(connection: engine).safeModeLevel == .readOnly) } - @Test("Beancount keeps the level it was given, because BQL queries do not classify as reads") - func beancountIsNotEnforced() { - let ledger = DatabaseConnection(name: "Ledger", type: .beancount, safeModeLevel: .silent) - #expect(ledger.readOnlyEnforcement == nil) - #expect(ledger.safeModeLevel == .silent) - } @Test("Choosing a weaker level on an enforced session keeps it Read-Only") func setSafeModeLevelKeepsEnforcement() { 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/features/safe-mode.mdx b/docs/features/safe-mode.mdx index acb8ec9e4a..b940e7fb8c 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -26,6 +26,7 @@ A connection that cannot take a write runs at **Read-Only** whatever level it wa | 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 | From a793f12e2661a150a7911bdbe95310421b156a5f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 11 Sep 2026 20:25:29 +0700 Subject: [PATCH 7/7] fix(connections): show the managed Safe Mode minimum in the toolbar, menu and connection form Claude-Session: https://claude.ai/code/session_01JKFSBk6YwDemnkbQnyc2xz --- CHANGELOG.md | 1 + .../Database/DatabaseManager+Sessions.swift | 11 ++- ...inSplitViewController+MenuValidation.swift | 5 +- .../Core/Services/Policy/ManagedPolicy.swift | 18 +---- .../DatabaseConnection+SafeMode.swift | 20 ++--- .../Connection/ReadOnlyEnforcement.swift | 40 ---------- .../Models/Connection/SafeModeFloor.swift | 67 +++++++++++++++++ .../Models/Connection/SafeModeLevel.swift | 12 +++ .../ConnectionFormCoordinator+SafeMode.swift | 17 ++++- .../Panes/OptionsPaneView.swift | 13 ++-- ...ntTests.swift => SafeModeFloorTests.swift} | 74 ++++++++++++++----- docs/features/safe-mode.mdx | 2 +- 12 files changed, 181 insertions(+), 99 deletions(-) delete mode 100644 TablePro/Models/Connection/ReadOnlyEnforcement.swift create mode 100644 TablePro/Models/Connection/SafeModeFloor.swift rename TableProTests/Models/{ReadOnlyEnforcementTests.swift => SafeModeFloorTests.swift} (63%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e162a57cd8..441e792417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Safe Mode minimum from a configuration profile missing from the toolbar, the Database menu and the connection form. (#2030) - 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. - Blank welcome window list when a search matched nothing and a favorite existed. - Welcome window reading No Connections while a tag filter hid every connection. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index a551bcefa5..6cccde6be5 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -590,11 +590,14 @@ extension DatabaseManager { /// The user picking a level from the toolbar or the Database menu. /// - /// A connection held at Read-Only offers only the level already in force, so a pick there - /// changes nothing, and writing it would replace the level the user saved, which is the one - /// that comes back once the connection stops being held. + /// 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 activeSessions[connectionId]?.connection.readOnlyEnforcement == nil else { return } + guard let connection = activeSessions[connectionId]?.connection, + level != connection.safeModeLevel, + connection.safeModeFloor?.allows(level) ?? true + else { return } setSafeModeLevel(level, for: connectionId) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 2713156b89..2a11ff404c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -446,10 +446,7 @@ extension MainSplitViewController: NSMenuItemValidation { guard isConnected, let raw = menuItem.representedObject as? String, let level = SafeModeLevel(rawValue: raw) else { return false } - return ReadOnlyEnforcement.allowsChoosing( - level, - under: commandActions?.coordinator?.connection.readOnlyEnforcement - ) + return commandActions?.coordinator?.connection.safeModeFloor?.allows(level) ?? true } private func isCurrentResultView(_ menuItem: NSMenuItem) -> Bool { 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/Models/Connection/DatabaseConnection+SafeMode.swift b/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift index d4b22f7bbd..40d5efea15 100644 --- a/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift +++ b/TablePro/Models/Connection/DatabaseConnection+SafeMode.swift @@ -6,22 +6,22 @@ import Foundation extension DatabaseConnection { - /// The Safe Mode level in force: the user's own level, raised to Read-Only when the - /// connection cannot be written to. + /// 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 reads as - /// Read-Only 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. + /// 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 { readOnlyEnforcement == nil ? preferredSafeModeLevel : .readOnly } + get { safeModeFloor?.raising(preferredSafeModeLevel) ?? preferredSafeModeLevel } set { preferredSafeModeLevel = newValue } } - var readOnlyEnforcement: ReadOnlyEnforcement? { - ReadOnlyEnforcement.resolve( + var safeModeFloor: SafeModeFloor? { + SafeModeFloor.resolve( isEngineReadOnly: PluginMetadataRegistry.shared.snapshot(for: type)?.capabilities.isEngineReadOnly ?? false, - opensRemoteDatabaseFile: opensRemoteDatabaseFile + opensRemoteDatabaseFile: opensRemoteDatabaseFile, + managedMinimum: ManagedPolicyResolver.minimumSafeModeLevel(policy: ManagedPolicyReader.shared) ) } } diff --git a/TablePro/Models/Connection/ReadOnlyEnforcement.swift b/TablePro/Models/Connection/ReadOnlyEnforcement.swift deleted file mode 100644 index f5a2dc8a94..0000000000 --- a/TablePro/Models/Connection/ReadOnlyEnforcement.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// ReadOnlyEnforcement.swift -// TablePro -// - -import Foundation - -/// Why a connection runs at Read-Only whatever Safe Mode level the user picked. -/// -/// These are facts about the connection, not policy, so they are never written into the user's -/// own setting: switching the connection's type or turning the remote file off hands back the -/// level the user chose. -internal enum ReadOnlyEnforcement: 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 - - static func resolve(isEngineReadOnly: Bool, opensRemoteDatabaseFile: Bool) -> ReadOnlyEnforcement? { - if isEngineReadOnly { return .readOnlyEngine } - if opensRemoteDatabaseFile { return .remoteDatabaseFile } - return nil - } - - static func allowsChoosing(_ level: SafeModeLevel, under enforcement: ReadOnlyEnforcement?) -> Bool { - enforcement == nil || level == .readOnly - } - - var explanation: String { - switch self { - 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." - ) - } - } -} 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/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift index 80c3408ff5..eb94f20444 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+SafeMode.swift @@ -7,10 +7,21 @@ import Foundation @MainActor extension ConnectionFormCoordinator { - var readOnlyEnforcement: ReadOnlyEnforcement? { - ReadOnlyEnforcement.resolve( + var safeModeFloor: SafeModeFloor? { + SafeModeFloor.resolve( isEngineReadOnly: services.pluginManager.isEngineReadOnly(for: network.type), - opensRemoteDatabaseFile: transport == .remoteFile + 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 f5988984fc..8bd7ff34c7 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -122,11 +122,12 @@ struct OptionsPaneView: View { @ViewBuilder private var safeModeRow: some View { - if coordinator.readOnlyEnforcement != nil { - LabeledContent(String(localized: "Safe Mode"), value: SafeModeLevel.readOnly.displayName) + 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.customization.safeModeLevel) { - ForEach(SafeModeLevel.allCases) { level in + Picker(String(localized: "Safe Mode"), selection: $coordinator.effectiveSafeModeLevel) { + ForEach(levels) { level in Text(level.displayName).tag(level) } } @@ -136,8 +137,8 @@ struct OptionsPaneView: View { @ViewBuilder private var accessFooter: some View { VStack(alignment: .leading, spacing: 4) { - if let enforcement = coordinator.readOnlyEnforcement { - Text(enforcement.explanation) + if let floor = coordinator.safeModeFloor { + Text(floor.explanation) } if aiIsEnabled { // swiftlint:disable:next line_length diff --git a/TableProTests/Models/ReadOnlyEnforcementTests.swift b/TableProTests/Models/SafeModeFloorTests.swift similarity index 63% rename from TableProTests/Models/ReadOnlyEnforcementTests.swift rename to TableProTests/Models/SafeModeFloorTests.swift index b9bd353c17..2f2b09c50d 100644 --- a/TableProTests/Models/ReadOnlyEnforcementTests.swift +++ b/TableProTests/Models/SafeModeFloorTests.swift @@ -1,5 +1,5 @@ // -// ReadOnlyEnforcementTests.swift +// SafeModeFloorTests.swift // TableProTests // @@ -9,9 +9,9 @@ import Testing @testable import TablePro -@Suite("Read-only enforcement") +@Suite("Safe Mode floor") @MainActor -struct ReadOnlyEnforcementTests { +struct SafeModeFloorTests { private func remoteFileConnection(preferred: SafeModeLevel = .silent) -> DatabaseConnection { var connection = DatabaseConnection(name: "Remote", type: .sqlite, safeModeLevel: preferred) connection.sshTunnelMode = .inline( @@ -20,18 +20,48 @@ struct ReadOnlyEnforcementTests { return connection } - @Test("A read-only engine outranks a remote file, and neither leaves no enforcement") + @Test("A read-only engine outranks a remote file, and both outrank the profile") func resolveOrder() { - #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: true, opensRemoteDatabaseFile: true) == .readOnlyEngine) - #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: true) == .remoteDatabaseFile) - #expect(ReadOnlyEnforcement.resolve(isEngineReadOnly: false, opensRemoteDatabaseFile: false) == nil) + 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("Only Read-Only can be chosen while enforcement applies", arguments: SafeModeLevel.allCases) - func allowsChoosing(level: SafeModeLevel) { - #expect(ReadOnlyEnforcement.allowsChoosing(level, under: nil)) - #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .readOnlyEngine) == (level == .readOnly)) - #expect(ReadOnlyEnforcement.allowsChoosing(level, under: .remoteDatabaseFile) == (level == .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: [ @@ -40,7 +70,7 @@ struct ReadOnlyEnforcementTests { func readOnlyEngine(type: DatabaseType) { let connection = DatabaseConnection(name: "Engine", type: type, safeModeLevel: .alert) - #expect(connection.readOnlyEnforcement == .readOnlyEngine) + #expect(connection.safeModeFloor?.reason == .readOnlyEngine) #expect(connection.safeModeLevel == .readOnly) #expect(connection.preferredSafeModeLevel == .alert) } @@ -49,7 +79,7 @@ struct ReadOnlyEnforcementTests { func writableEngine() { let connection = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .alert) - #expect(connection.readOnlyEnforcement == nil) + #expect(connection.safeModeFloor == nil) #expect(connection.safeModeLevel == .alert) } @@ -57,7 +87,7 @@ struct ReadOnlyEnforcementTests { func remoteFile() { let connection = remoteFileConnection() - #expect(connection.readOnlyEnforcement == .remoteDatabaseFile) + #expect(connection.safeModeFloor?.reason == .remoteDatabaseFile) #expect(connection.safeModeLevel == .readOnly) #expect(connection.preferredSafeModeLevel == .silent) } @@ -98,7 +128,6 @@ struct ReadOnlyEnforcementTests { #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) @@ -113,7 +142,7 @@ struct ReadOnlyEnforcementTests { #expect(session?.connection.preferredSafeModeLevel == .silent) } - @Test("Picking Read-Only on a held connection leaves the saved level alone") + @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) @@ -126,6 +155,17 @@ struct ReadOnlyEnforcementTests { #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) diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index b940e7fb8c..1f7d83de40 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -88,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.