From 093935bbd936885695ac6257a8750f4c54dc3d01 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 14:53:35 +0700 Subject: [PATCH 1/6] feat: add Cloudflare D1 database driver plugin (#206) --- CHANGELOG.md | 1 + .../CloudflareD1Plugin.swift | 105 +++ .../CloudflareD1PluginDriver.swift | 697 ++++++++++++++++++ .../D1HttpClient.swift | 399 ++++++++++ Plugins/CloudflareD1DriverPlugin/Info.plist | 8 + .../TableProPluginKit/ConnectionMode.swift | 1 + TablePro.xcodeproj/project.pbxproj | 136 ++++ .../cloudflare-d1-icon.imageset/Contents.json | 16 + .../cloudflare-d1.svg | 3 + ...ginMetadataRegistry+RegistryDefaults.swift | 104 +++ .../Connection/DatabaseConnection.swift | 1 + TablePro/Resources/Localizable.xcstrings | 6 + .../Views/Connection/ConnectionFormView.swift | 22 +- .../CloudflareD1DriverHelperTests.swift | 314 ++++++++ .../CloudflareD1PluginMetadataTests.swift | 173 +++++ .../CloudflareD1/D1ResponseParsingTests.swift | 326 ++++++++ .../CloudflareD1/D1ValueDecodingTests.swift | 237 ++++++ 17 files changed, 2545 insertions(+), 4 deletions(-) create mode 100644 Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift create mode 100644 Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift create mode 100644 Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift create mode 100644 Plugins/CloudflareD1DriverPlugin/Info.plist create mode 100644 TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/cloudflare-d1.svg create mode 100644 TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift create mode 100644 TableProTests/Core/CloudflareD1/CloudflareD1PluginMetadataTests.swift create mode 100644 TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift create mode 100644 TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae0c1b2..38aab8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cloudflare D1 database support - Match highlighting in autocomplete suggestions (matched characters shown in bold) - Loading spinner in autocomplete popup while fetching column metadata diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift new file mode 100644 index 00000000..a9c9da68 --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -0,0 +1,105 @@ +// +// CloudflareD1Plugin.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Cloudflare D1 Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Cloudflare D1 serverless SQLite-compatible database support via REST API" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Cloudflare D1" + static let databaseDisplayName = "Cloudflare D1" + static let iconName = "cloudflare-d1-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 supportsSchemaEditing = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let brandColorHex = "#F6821F" + static let urlSchemes: [String] = ["d1"] + + static let explainVariants: [ExplainVariant] = [ + ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ] + + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue] + + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], + "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"] + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ], + tableOptions: [ + "WITHOUT ROWID", "STRICT" + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "cfAccountId", + label: String(localized: "Account ID"), + placeholder: "Cloudflare Account ID", + required: true, + section: .authentication + ) + ] + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + CloudflareD1PluginDriver(config: config) + } +} diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift new file mode 100644 index 00000000..1c1dd7b8 --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -0,0 +1,697 @@ +// +// CloudflareD1PluginDriver.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +// MARK: - Error + +private struct CloudflareD1Error: Error, PluginDriverError { + let message: String + + var pluginErrorMessage: String { message } + + static let notConnected = CloudflareD1Error(message: String(localized: "Not connected to database")) +} + +// MARK: - Plugin Driver + +final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private var httpClient: D1HttpClient? + private var _serverVersion: String? + private var databaseNameToUuid: [String: String] = [:] + private let lock = NSLock() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudflareD1PluginDriver") + + var serverVersion: String? { + lock.lock() + defer { lock.unlock() } + return _serverVersion + } + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var parameterStyle: ParameterStyle { .questionMark } + + init(config: DriverConnectionConfig) { + self.config = config + } + + // MARK: - Connection + + func connect() async throws { + guard let accountId = config.additionalFields["cfAccountId"], !accountId.isEmpty else { + throw CloudflareD1Error(message: String(localized: "Account ID is required")) + } + + let apiToken = config.password + guard !apiToken.isEmpty else { + throw CloudflareD1Error(message: String(localized: "API Token is required")) + } + + let databaseName = config.database + guard !databaseName.isEmpty else { + throw CloudflareD1Error(message: String(localized: "Database name or UUID is required")) + } + + let databaseId: String + if isUuid(databaseName) { + databaseId = databaseName + } else { + let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: "") + client.createSession() + defer { client.invalidateSession() } + let databases = try await client.listDatabases() + + guard let match = databases.first(where: { $0.name == databaseName }) else { + throw CloudflareD1Error( + message: String(localized: "Database '\(databaseName)' not found in account") + ) + } + databaseId = match.uuid + + lock.lock() + for db in databases { + databaseNameToUuid[db.name] = db.uuid + } + lock.unlock() + } + + let client = D1HttpClient(accountId: accountId, apiToken: apiToken, databaseId: databaseId) + client.createSession() + + do { + let details = try await client.getDatabaseDetails() + lock.lock() + _serverVersion = details.version ?? "D1" + lock.unlock() + } catch { + client.invalidateSession() + Self.logger.error("Connection test failed: \(error.localizedDescription)") + throw CloudflareD1Error(message: String(localized: "Failed to connect to Cloudflare D1")) + } + + lock.lock() + httpClient = client + lock.unlock() + + Self.logger.debug("Connected to Cloudflare D1 database: \(databaseName)") + } + + func disconnect() { + lock.lock() + httpClient?.invalidateSession() + httpClient = nil + lock.unlock() + } + + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> PluginQueryResult { + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + let startTime = Date() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let payload = try await client.executeRaw(sql: trimmed) + let executionTime = Date().timeIntervalSince(startTime) + return mapRawResult(payload, executionTime: executionTime) + } + + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + guard !parameters.isEmpty else { + return try await execute(query: query) + } + + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + let startTime = Date() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let anyParams: [Any?] = parameters.map { param -> Any? in + guard let value = param else { return nil } + return value + } + + let payload = try await client.executeRaw(sql: trimmed, params: anyParams) + let executionTime = Date().timeIntervalSince(startTime) + return mapRawResult(payload, executionTime: executionTime) + } + + func cancelQuery() throws { + lock.lock() + httpClient?.cancelCurrentTask() + lock.unlock() + } + + // MARK: - Pagination + + func fetchRowCount(query: String) async throws -> Int { + let baseQuery = stripLimitOffset(from: query) + let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) _t" + let result = try await execute(query: countQuery) + guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 } + return Int(countStr ?? "0") ?? 0 + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + let baseQuery = stripLimitOffset(from: query) + let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)" + return try await execute(query: paginatedQuery) + } + + // MARK: - Schema Operations + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let query = """ + SELECT name, type FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + AND name NOT GLOB '_cf_*' + ORDER BY name + """ + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let name = row[safe: 0] ?? nil else { return nil } + let typeString = (row[safe: 1] ?? nil) ?? "table" + let tableType = typeString.lowercased() == "view" ? "VIEW" : "TABLE" + return PluginTableInfo(name: name, type: tableType) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let safeTable = escapeStringLiteral(table) + let query = "PRAGMA table_info('\(safeTable)')" + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard row.count >= 6, + let name = row[1], + let dataType = row[2] else { + return nil + } + + let isNullable = row[3] == "0" + let isPrimaryKey = row[5] == "1" + let defaultValue = row[4] + + return PluginColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue + ) + } + } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + let query = """ + SELECT m.name AS tbl, p.cid, p.name, p.type, p."notnull", p.dflt_value, p.pk + FROM sqlite_master m, pragma_table_info(m.name) p + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT LIKE '_cf_%' + ORDER BY m.name, p.cid + """ + let result = try await execute(query: query) + + var allColumns: [String: [PluginColumnInfo]] = [:] + + for row in result.rows { + guard row.count >= 7, + let tableName = row[0], + let columnName = row[2], + let dataType = row[3] else { + continue + } + + let isNullable = row[4] == "0" + let defaultValue = row[5] + let isPrimaryKey = row[6] == "1" + + let column = PluginColumnInfo( + name: columnName, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue + ) + + allColumns[tableName, default: []].append(column) + } + + return allColumns + } + + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { + let query = """ + SELECT m.name AS table_name, p.id, p."table" AS referenced_table, + p."from" AS column_name, p."to" AS referenced_column, + p.on_update, p.on_delete + FROM sqlite_master m, pragma_foreign_key_list(m.name) p + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT LIKE '_cf_%' + ORDER BY m.name, p.id, p.seq + """ + let result = try await execute(query: query) + + var allForeignKeys: [String: [PluginForeignKeyInfo]] = [:] + + for row in result.rows { + guard row.count >= 7, + let tableName = row[0], + let id = row[1], + let refTable = row[2], + let fromCol = row[3], + let toCol = row[4] else { + continue + } + + let onUpdate = row[5] ?? "NO ACTION" + let onDelete = row[6] ?? "NO ACTION" + + let fk = PluginForeignKeyInfo( + name: "fk_\(tableName)_\(id)", + column: fromCol, + referencedTable: refTable, + referencedColumn: toCol, + onDelete: onDelete, + onUpdate: onUpdate + ) + + allForeignKeys[tableName, default: []].append(fk) + } + + return allForeignKeys + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let safeTable = escapeStringLiteral(table) + let query = """ + SELECT il.name, il."unique", il.origin, ii.name AS col_name + FROM pragma_index_list('\(safeTable)') il + LEFT JOIN pragma_index_info(il.name) ii ON 1=1 + ORDER BY il.seq, ii.seqno + """ + let result = try await execute(query: query) + + var indexMap: [(name: String, isUnique: Bool, isPrimary: Bool, columns: [String])] = [] + var indexLookup: [String: Int] = [:] + + for row in result.rows { + guard row.count >= 4, + let indexName = row[0] else { continue } + + let isUnique = row[1] == "1" + let origin = row[2] ?? "c" + + if let idx = indexLookup[indexName] { + if let colName = row[3] { + indexMap[idx].columns.append(colName) + } + } else { + let columns: [String] = row[3].map { [$0] } ?? [] + indexLookup[indexName] = indexMap.count + indexMap.append(( + name: indexName, + isUnique: isUnique, + isPrimary: origin == "pk", + columns: columns + )) + } + } + + return indexMap.map { entry in + PluginIndexInfo( + name: entry.name, + columns: entry.columns, + isUnique: entry.isUnique, + isPrimary: entry.isPrimary, + type: "BTREE" + ) + }.sorted { $0.isPrimary && !$1.isPrimary } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + let safeTable = escapeStringLiteral(table) + let query = "PRAGMA foreign_key_list('\(safeTable)')" + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard row.count >= 5, + let refTable = row[2], + let fromCol = row[3], + let toCol = row[4] else { + return nil + } + + let id = row[0] ?? "0" + let onUpdate = row.count >= 6 ? (row[5] ?? "NO ACTION") : "NO ACTION" + let onDelete = row.count >= 7 ? (row[6] ?? "NO ACTION") : "NO ACTION" + + return PluginForeignKeyInfo( + name: "fk_\(table)_\(id)", + column: fromCol, + referencedTable: refTable, + referencedColumn: toCol, + onDelete: onDelete, + onUpdate: onUpdate + ) + } + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let safeTable = escapeStringLiteral(table) + let query = """ + SELECT sql FROM sqlite_master + WHERE type = 'table' AND name = '\(safeTable)' + """ + let result = try await execute(query: query) + + guard let firstRow = result.rows.first, + let ddl = firstRow[0] else { + throw CloudflareD1Error(message: "Failed to fetch DDL for table '\(table)'") + } + + let formatted = formatDDL(ddl) + return formatted.hasSuffix(";") ? formatted : formatted + ";" + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let safeView = escapeStringLiteral(view) + let query = """ + SELECT sql FROM sqlite_master + WHERE type = 'view' AND name = '\(safeView)' + """ + let result = try await execute(query: query) + + guard let firstRow = result.rows.first, + let ddl = firstRow[0] else { + throw CloudflareD1Error(message: "Failed to fetch definition for view '\(view)'") + } + + return ddl + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let safeTableName = table.replacingOccurrences(of: "\"", with: "\"\"") + let countQuery = "SELECT COUNT(*) FROM (SELECT 1 FROM \"\(safeTableName)\" LIMIT 100001)" + let countResult = try await execute(query: countQuery) + let rowCount: Int64? = { + guard let row = countResult.rows.first, let countStr = row.first else { return nil } + return Int64(countStr ?? "0") + }() + + return PluginTableMetadata( + tableName: table, + rowCount: rowCount, + engine: "Cloudflare D1" + ) + } + + // MARK: - Database Operations + + func fetchDatabases() async throws -> [String] { + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + let databases = try await client.listDatabases() + + lock.lock() + databaseNameToUuid.removeAll() + for db in databases { + databaseNameToUuid[db.name] = db.uuid + } + lock.unlock() + + return databases.map(\.name) + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + let newDb = try await client.createDatabase(name: name) + + lock.lock() + databaseNameToUuid[newDb.name] = newDb.uuid + lock.unlock() + } + + func switchDatabase(to database: String) async throws { + lock.lock() + var uuid = databaseNameToUuid[database] + lock.unlock() + + if uuid == nil && isUuid(database) { + uuid = database + } + + if uuid == nil { + guard let client = getClient() else { + throw CloudflareD1Error.notConnected + } + + let databases = try await client.listDatabases() + + lock.lock() + databaseNameToUuid.removeAll() + for db in databases { + databaseNameToUuid[db.name] = db.uuid + } + uuid = databaseNameToUuid[database] + lock.unlock() + } + + guard let resolvedUuid = uuid else { + throw CloudflareD1Error( + message: String(localized: "Database '\(database)' not found") + ) + } + + lock.lock() + httpClient?.databaseId = resolvedUuid + lock.unlock() + } + + // MARK: - Identifier Quoting + + func quoteIdentifier(_ name: String) -> String { + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + + func escapeStringLiteral(_ value: String) -> String { + var result = value + result = result.replacingOccurrences(of: "'", with: "''") + result = result.replacingOccurrences(of: "\0", with: "") + return result + } + + func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS TEXT)" + } + + // MARK: - EXPLAIN + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN QUERY PLAN \(sql)" + } + + // MARK: - View Templates + + func createViewTemplate() -> String? { + "CREATE VIEW IF NOT EXISTS view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + let quoted = quoteIdentifier(viewName) + return "DROP VIEW IF EXISTS \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;" + } + + // MARK: - Foreign Key Checks + + func foreignKeyDisableStatements() -> [String]? { + ["PRAGMA foreign_keys = OFF"] + } + + func foreignKeyEnableStatements() -> [String]? { + ["PRAGMA foreign_keys = ON"] + } + + // MARK: - Table Operations + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + ["DELETE FROM \(quoteIdentifier(table))"] + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + "DROP \(objectType) IF EXISTS \(quoteIdentifier(name))" + } + + // MARK: - All Tables Metadata + + func allTablesMetadataSQL(schema: String?) -> String? { + """ + SELECT + '' as schema, + name, + type as kind, + '' as charset, + '' as collation, + '' as estimated_rows, + '' as total_size, + '' as data_size, + '' as index_size, + '' as comment + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + AND name NOT GLOB '_cf_*' + ORDER BY name + """ + } + + // MARK: - Transactions + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + // MARK: - Private Helpers + + private func getClient() -> D1HttpClient? { + lock.lock() + defer { lock.unlock() } + return httpClient + } + + private func isUuid(_ string: String) -> Bool { + let uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + return string.range(of: uuidPattern, options: .regularExpression) != nil + } + + private func mapRawResult(_ payload: D1RawResultPayload, executionTime: TimeInterval) -> PluginQueryResult { + let columns = payload.results.columns ?? [] + let rawRows = payload.results.rows ?? [] + + var rows: [[String?]] = [] + var truncated = false + + for rawRow in rawRows { + if rows.count >= PluginRowLimits.defaultMax { + truncated = true + break + } + let row = rawRow.map(\.stringValue) + rows.append(row) + } + + return PluginQueryResult( + columns: columns, + columnTypeNames: columns.map { _ in "" }, + rows: rows, + rowsAffected: payload.meta?.changes ?? 0, + executionTime: executionTime, + isTruncated: truncated + ) + } + + private func stripLimitOffset(from query: String) -> String { + let ns = query as NSString + let len = ns.length + guard len > 0 else { return query } + + let upper = query.uppercased() as NSString + var depth = 0 + var i = len - 1 + + while i >= 4 { + let ch = upper.character(at: i) + if ch == 0x29 { depth += 1 } + else if ch == 0x28 { depth -= 1 } + else if depth == 0 && ch == 0x54 { + let start = i - 4 + if start >= 0 { + let candidate = upper.substring(with: NSRange(location: start, length: 5)) + if candidate == "LIMIT" { + if start == 0 || CharacterSet.whitespacesAndNewlines + .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) { + return ns.substring(to: start) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + } + i -= 1 + } + return query + } + + private func formatDDL(_ ddl: String) -> String { + guard ddl.uppercased().hasPrefix("CREATE TABLE") else { + return ddl + } + + var formatted = ddl + + if let range = formatted.range(of: "(") { + let before = String(formatted[..: Decodable { + let result: T? + let success: Bool + let errors: [D1ApiErrorDetail]? + + private enum CodingKeys: String, CodingKey { + case result, success, errors + } +} + +struct D1ApiErrorDetail: Decodable { + let code: Int? + let message: String + + private enum CodingKeys: String, CodingKey { + case code, message + } +} + +struct D1RawResultPayload: Decodable { + let results: D1RawResults + let meta: D1QueryMeta? + let success: Bool + + private enum CodingKeys: String, CodingKey { + case results, meta, success + } +} + +struct D1RawResults: Decodable { + let columns: [String]? + let rows: [[D1Value]]? + + private enum CodingKeys: String, CodingKey { + case columns, rows + } +} + +struct D1QueryResultPayload: Decodable { + let results: [[String: D1Value]]? + let meta: D1QueryMeta? + let success: Bool + + private enum CodingKeys: String, CodingKey { + case results, meta, success + } +} + +struct D1QueryMeta: Decodable { + let duration: Double? + let changes: Int? + let rowsRead: Int? + let rowsWritten: Int? + + private enum CodingKeys: String, CodingKey { + case duration, changes + case rowsRead = "rows_read" + case rowsWritten = "rows_written" + } +} + +struct D1DatabaseInfo: Decodable { + let uuid: String + let name: String + let createdAt: String? + let version: String? + + private enum CodingKeys: String, CodingKey { + case uuid, name, version + case createdAt = "created_at" + } +} + +struct D1ListResponse: Decodable { + let result: [D1DatabaseInfo] + let success: Bool + + private enum CodingKeys: String, CodingKey { + case result, success + } +} + +enum D1Value: Decodable { + case string(String) + case int(Int) + case double(Double) + case null + + var stringValue: String? { + switch self { + case .string(let val): return val + case .int(let val): return String(val) + case .double(let val): return String(val) + case .null: return nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + if let intVal = try? container.decode(Int.self) { + self = .int(intVal) + return + } + + if let doubleVal = try? container.decode(Double.self) { + self = .double(doubleVal) + return + } + + if let stringVal = try? container.decode(String.self) { + self = .string(stringVal) + return + } + + self = .null + } +} + +// MARK: - HTTP Client + +final class D1HttpClient: @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "D1HttpClient") + + private let accountId: String + private let apiToken: String + private let lock = NSLock() + private var _databaseId: String + private var session: URLSession? + private var currentTask: URLSessionDataTask? + + var databaseId: String { + get { + lock.lock() + defer { lock.unlock() } + return _databaseId + } + set { + lock.lock() + _databaseId = newValue + lock.unlock() + } + } + + init(accountId: String, apiToken: String, databaseId: String) { + self.accountId = accountId + self.apiToken = apiToken + self._databaseId = databaseId + } + + func createSession() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 300 + + lock.lock() + session = URLSession(configuration: config) + lock.unlock() + } + + func invalidateSession() { + lock.lock() + currentTask?.cancel() + currentTask = nil + session?.invalidateAndCancel() + session = nil + lock.unlock() + } + + func cancelCurrentTask() { + lock.lock() + currentTask?.cancel() + currentTask = nil + lock.unlock() + } + + // MARK: - API Methods + + func executeRaw(sql: String, params: [Any?]? = nil) async throws -> D1RawResultPayload { + let dbId = databaseId + let url = try baseURL(databaseId: dbId).appendingPathComponent("raw") + let body = try buildQueryBody(sql: sql, params: params) + let data = try await performRequest(url: url, method: "POST", body: body) + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: data) + try checkApiSuccess(envelope) + + guard let results = envelope.result, let first = results.first else { + throw D1HttpError(message: String(localized: "Empty response from Cloudflare D1")) + } + + return first + } + + func executeQuery(sql: String, params: [Any?]? = nil) async throws -> D1QueryResultPayload { + let dbId = databaseId + let url = try baseURL(databaseId: dbId).appendingPathComponent("query") + let body = try buildQueryBody(sql: sql, params: params) + let data = try await performRequest(url: url, method: "POST", body: body) + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1QueryResultPayload]>.self, from: data) + try checkApiSuccess(envelope) + + guard let results = envelope.result, let first = results.first else { + throw D1HttpError(message: String(localized: "Empty response from Cloudflare D1")) + } + + return first + } + + func getDatabaseDetails() async throws -> D1DatabaseInfo { + let dbId = databaseId + let url = try baseURL(databaseId: dbId) + let data = try await performRequest(url: url, method: "GET", body: nil) + + let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: data) + try checkApiSuccess(envelope) + + guard let result = envelope.result else { + throw D1HttpError(message: String(localized: "Failed to fetch database details")) + } + + return result + } + + func listDatabases() async throws -> [D1DatabaseInfo] { + let url = try baseURL(databaseId: nil) + let data = try await performRequest(url: url, method: "GET", body: nil) + + let response = try JSONDecoder().decode(D1ListResponse.self, from: data) + guard response.success else { + throw D1HttpError(message: String(localized: "Failed to list databases")) + } + + return response.result + } + + func createDatabase(name: String) async throws -> D1DatabaseInfo { + let url = try baseURL(databaseId: nil) + let body = try JSONSerialization.data(withJSONObject: ["name": name]) + let data = try await performRequest(url: url, method: "POST", body: body) + + let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: data) + try checkApiSuccess(envelope) + + guard let result = envelope.result else { + throw D1HttpError(message: String(localized: "Failed to create database")) + } + + return result + } + + // MARK: - Private Helpers + + private func baseURL(databaseId: String?) throws -> URL { + guard let encodedAccount = accountId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + throw D1HttpError(message: String(localized: "Invalid Account ID")) + } + var components = URLComponents() + components.scheme = "https" + components.host = "api.cloudflare.com" + var path = "/client/v4/accounts/\(encodedAccount)/d1/database" + if let dbId = databaseId, + let encodedDb = dbId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + path += "/\(encodedDb)" + } + components.path = path + guard let url = components.url else { + throw D1HttpError(message: String(localized: "Invalid Account ID or database identifier")) + } + return url + } + + private func buildQueryBody(sql: String, params: [Any?]?) throws -> Data { + var dict: [String: Any] = ["sql": sql] + if let params, !params.isEmpty { + dict["params"] = params.map { $0 ?? NSNull() } + } + return try JSONSerialization.data(withJSONObject: dict) + } + + private func performRequest(url: URL, method: String, body: Data?) async throws -> Data { + lock.lock() + guard let session else { + lock.unlock() + throw D1HttpError(message: String(localized: "Not connected to database")) + } + lock.unlock() + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.dataTask(with: request) { data, response, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let data, let response else { + continuation.resume( + throwing: D1HttpError(message: "Empty response from server") + ) + return + } + continuation.resume(returning: (data, response)) + } + + self.lock.lock() + self.currentTask = task + self.lock.unlock() + + task.resume() + } + } onCancel: { + self.lock.lock() + self.currentTask?.cancel() + self.currentTask = nil + self.lock.unlock() + } + + lock.lock() + currentTask = nil + lock.unlock() + + guard let httpResponse = response as? HTTPURLResponse else { + throw D1HttpError(message: "Invalid response from server") + } + + if httpResponse.statusCode >= 400 { + try handleHttpError(statusCode: httpResponse.statusCode, data: data, response: httpResponse) + } + + return data + } + + private func handleHttpError(statusCode: Int, data: Data, response: HTTPURLResponse) throws { + let bodyText = String(data: data, encoding: .utf8) ?? "Unknown error" + + switch statusCode { + case 401, 403: + Self.logger.error("D1 auth error (\(statusCode)): \(bodyText)") + throw D1HttpError( + message: String(localized: "Authentication failed. Check your API token and Account ID.") + ) + case 429: + let retryAfter = response.value(forHTTPHeaderField: "Retry-After") ?? "unknown" + Self.logger.warning("D1 rate limited. Retry-After: \(retryAfter)") + throw D1HttpError( + message: String(localized: "Rate limited by Cloudflare. Retry after \(retryAfter) seconds.") + ) + default: + if let errorResponse = try? JSONDecoder().decode( + D1ApiResponse.self, from: data + ), let errors = errorResponse.errors, let first = errors.first { + Self.logger.error("D1 API error (\(statusCode)): \(first.message)") + throw D1HttpError(message: first.message) + } + Self.logger.error("D1 HTTP error (\(statusCode)): \(bodyText)") + throw D1HttpError(message: bodyText.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + private func checkApiSuccess(_ envelope: D1ApiResponse) throws { + guard envelope.success else { + if let errors = envelope.errors, let first = errors.first { + throw D1HttpError(message: first.message) + } + throw D1HttpError(message: String(localized: "API request failed")) + } + } +} + +// MARK: - Error + +struct D1HttpError: Error, LocalizedError { + let message: String + + var errorDescription: String? { message } +} diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist new file mode 100644 index 00000000..68929d2c --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 2 + + diff --git a/Plugins/TableProPluginKit/ConnectionMode.swift b/Plugins/TableProPluginKit/ConnectionMode.swift index 9fd3d1be..5011c113 100644 --- a/Plugins/TableProPluginKit/ConnectionMode.swift +++ b/Plugins/TableProPluginKit/ConnectionMode.swift @@ -6,4 +6,5 @@ public enum ConnectionMode: String, Codable, Sendable { case network case fileBased + case apiOnly } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 4596c473..b56dd180 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5AE4F4912F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; @@ -142,6 +144,17 @@ name = "Copy Plug-Ins"; runOnlyForDeploymentPostprocessing = 0; }; + 5AE4F4922F6BC0640097AC5B /* Copy D1 Plugin */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 5AE4F4912F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin in Copy Plug-Ins */, + ); + name = "Copy D1 Plugin"; + runOnlyForDeploymentPostprocessing = 0; + }; 5A86FF0100000000 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -175,6 +188,7 @@ 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudflareD1DriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EtcdDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B3B2F6808CA0040461A /* EtcdCommandParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdCommandParser.swift; sourceTree = ""; }; 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdHttpClient.swift; sourceTree = ""; }; @@ -207,6 +221,13 @@ ); target = 5A862000000000000 /* SQLiteDriver */; }; + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */; + }; 5A863000900000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "ClickHouseDriver" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -347,6 +368,14 @@ path = Plugins/SQLiteDriverPlugin; sourceTree = ""; }; + 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */, + ); + path = Plugins/CloudflareD1DriverPlugin; + sourceTree = ""; + }; 5A863000500000000 /* Plugins/ClickHouseDriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -622,6 +651,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE4F4712F6BC0640097AC5B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AEA8B272F6808270040461A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -645,6 +682,7 @@ isa = PBXGroup; children = ( 5AEA8B412F6808CA0040461A /* Plugins/EtcdDriverPlugin */, + 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, 5A1091C92EF17EDC0055EA7C /* TablePro */, 5A860000500000000 /* Plugins/TableProPluginKit */, 5A861000500000000 /* Plugins/OracleDriverPlugin */, @@ -693,6 +731,7 @@ 5A86F000100000000 /* SQLImport.tableplugin */, 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */, + 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -739,6 +778,7 @@ 5A1091C52EF17EDC0055EA7C /* Resources */, 5A86FF0100000000 /* Embed Frameworks */, 5A86FF0000000000 /* Copy Plug-Ins */, + 5AE4F4922F6BC0640097AC5B /* Copy D1 Plugin */, ); buildRules = ( ); @@ -1134,6 +1174,28 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */; + buildPhases = ( + 5AE4F4702F6BC0640097AC5B /* Sources */, + 5AE4F4712F6BC0640097AC5B /* Frameworks */, + 5AE4F4722F6BC0640097AC5B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */, + ); + name = CloudflareD1DriverPlugin; + packageProductDependencies = ( + ); + productName = CloudflareD1DriverPlugin; + productReference = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5AEA8B292F6808270040461A /* EtcdDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5AEA8B2D2F6808270040461A /* Build configuration list for PBXNativeTarget "EtcdDriverPlugin" */; @@ -1215,6 +1277,10 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; + 5AE4F4732F6BC0640097AC5B = { + CreatedOnToolsVersion = 26.3; + LastSwiftMigration = 2630; + }; 5AEA8B292F6808270040461A = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -1265,6 +1331,7 @@ 5A86F000000000000 /* SQLImport */, 5ABCC5A62F43856700EAF3FC /* TableProTests */, 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, + 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */, ); }; /* End PBXProject section */ @@ -1403,6 +1470,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE4F4722F6BC0640097AC5B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AEA8B282F6808270040461A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1546,6 +1620,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AE4F4702F6BC0640097AC5B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AEA8B262F6808270040461A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2884,6 +2965,52 @@ }; name = Release; }; + 5AE4F4762F6BC0640097AC5B /* 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/CloudflareD1DriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CloudflareD1Plugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CloudflareD1DriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5AE4F4772F6BC0640097AC5B /* 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/CloudflareD1DriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CloudflareD1Plugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CloudflareD1DriverPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5AEA8B2B2F6808270040461A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3116,6 +3243,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AE4F4752F6BC0640097AC5B /* Build configuration list for PBXNativeTarget "CloudflareD1DriverPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AE4F4762F6BC0640097AC5B /* Debug */, + 5AE4F4772F6BC0640097AC5B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5AEA8B2D2F6808270040461A /* Build configuration list for PBXNativeTarget "EtcdDriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/Contents.json b/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/Contents.json new file mode 100644 index 00000000..c3076882 --- /dev/null +++ b/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cloudflare-d1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/cloudflare-d1.svg b/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/cloudflare-d1.svg new file mode 100644 index 00000000..e5c91f52 --- /dev/null +++ b/TablePro/Assets.xcassets/cloudflare-d1-icon.imageset/cloudflare-d1.svg @@ -0,0 +1,3 @@ + + + diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index a77276f8..10c21027 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -455,6 +455,57 @@ extension PluginMetadataRegistry { "Geospatial": ["geo"] ] + let d1Dialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ], + tableOptions: ["WITHOUT ROWID", "STRICT"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + let d1ColumnTypes: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], + "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"] + ] + return [ ("MongoDB", PluginMetadataSnapshot( displayName: "MongoDB", iconName: "mongodb-icon", defaultPort: 27_017, @@ -918,6 +969,59 @@ extension PluginMetadataRegistry { ), ] ) + )), + ("Cloudflare D1", PluginMetadataSnapshot( + displayName: "Cloudflare D1", iconName: "cloudflare-d1-icon", defaultPort: 0, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "d1", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["d1"], postConnectActions: [], + brandColorHex: "#F6821F", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "main", + defaultGroupName: "main", + tableEntityName: "Tables", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .defaultValue] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: d1Dialect, + statementCompletions: [], + columnTypesByCategory: d1ColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [ + ConnectionField( + id: "cfAccountId", + label: String(localized: "Account ID"), + placeholder: "Cloudflare Account ID", + required: true, + section: .authentication + ) + ] + ) )) ] } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index cb871b9a..e353970e 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -236,6 +236,7 @@ extension DatabaseType { static let cassandra = DatabaseType(rawValue: "Cassandra") static let scylladb = DatabaseType(rawValue: "ScyllaDB") static let etcd = DatabaseType(rawValue: "etcd") + static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") } extension DatabaseType: Codable { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ef32debc..ec0040e2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2307,6 +2307,9 @@ } } } + }, + "Account ID" : { + }, "Account:" : { "localizations" : { @@ -3339,6 +3342,9 @@ } } } + }, + "API Token" : { + }, "Appearance" : { "localizations" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 45c41260..367f8d32 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -265,6 +265,14 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length .controlSize(.small) } } + } else if PluginManager.shared.connectionMode(for: type) == .apiOnly { + Section(String(localized: "Connection")) { + TextField( + String(localized: "Database"), + text: $database, + prompt: Text("database_name") + ) + } } else { Section(String(localized: "Connection")) { TextField( @@ -285,8 +293,12 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length ) } } + } + + if PluginManager.shared.connectionMode(for: type) != .fileBased { Section(String(localized: "Authentication")) { - if PluginManager.shared.requiresAuthentication(for: type) { + if PluginManager.shared.requiresAuthentication(for: type) + && PluginManager.shared.connectionMode(for: type) != .apiOnly { TextField( String(localized: "Username"), text: $username, @@ -306,8 +318,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length ) } if !hidePasswordField { + let isApiOnly = PluginManager.shared.connectionMode(for: type) == .apiOnly SecureField( - String(localized: "Password"), + isApiOnly ? String(localized: "API Token") : String(localized: "Password"), text: $password ) } @@ -859,8 +872,9 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length private var isValid: Bool { // Host and port can be empty (will use defaults: localhost and default port) - let isFileBased = PluginManager.shared.connectionMode(for: type) == .fileBased - let basicValid = !name.isEmpty && (isFileBased ? !database.isEmpty : true) + let mode = PluginManager.shared.connectionMode(for: type) + let requiresDatabase = mode == .fileBased || mode == .apiOnly + let basicValid = !name.isEmpty && (requiresDatabase ? !database.isEmpty : true) if sshEnabled { let sshPortValid = sshPort.isEmpty || (Int(sshPort).map { (1...65_535).contains($0) } ?? false) let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty && sshPortValid diff --git a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift new file mode 100644 index 00000000..5049ea3d --- /dev/null +++ b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift @@ -0,0 +1,314 @@ +// +// CloudflareD1DriverHelperTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("Cloudflare D1 Driver Helpers") +struct CloudflareD1DriverHelperTests { + + // MARK: - Local copies of helper functions for testing + + private static func quoteIdentifier(_ name: String) -> String { + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + + private static func escapeStringLiteral(_ value: String) -> String { + var result = value + result = result.replacingOccurrences(of: "'", with: "''") + result = result.replacingOccurrences(of: "\0", with: "") + return result + } + + private static func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS TEXT)" + } + + private static func isUuid(_ string: String) -> Bool { + let uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + return string.range(of: uuidPattern, options: .regularExpression) != nil + } + + private static func stripLimitOffset(from query: String) -> String { + let ns = query as NSString + let len = ns.length + guard len > 0 else { return query } + + let upper = query.uppercased() as NSString + var depth = 0 + var i = len - 1 + + while i >= 4 { + let ch = upper.character(at: i) + if ch == 0x29 { depth += 1 } + else if ch == 0x28 { depth -= 1 } + else if depth == 0 && ch == 0x54 { + let start = i - 4 + if start >= 0 { + let candidate = upper.substring(with: NSRange(location: start, length: 5)) + if candidate == "LIMIT" { + if start == 0 || CharacterSet.whitespacesAndNewlines + .contains(UnicodeScalar(upper.character(at: start - 1)) ?? UnicodeScalar(0)) { + return ns.substring(to: start) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + } + i -= 1 + } + return query + } + + private static func formatDDL(_ ddl: String) -> String { + guard ddl.uppercased().hasPrefix("CREATE TABLE") else { + return ddl + } + + var formatted = ddl + + if let range = formatted.range(of: "(") { + let before = String(formatted[..: Decodable { + let result: T? + let success: Bool + let errors: [D1ApiErrorDetail]? + } + + private struct D1ApiErrorDetail: Decodable { + let code: Int? + let message: String + } + + private struct D1RawResultPayload: Decodable { + let results: D1RawResults + let meta: D1QueryMeta? + let success: Bool + } + + private struct D1RawResults: Decodable { + let columns: [String]? + let rows: [[D1Value]]? + } + + private struct D1QueryMeta: Decodable { + let duration: Double? + let changes: Int? + let rowsRead: Int? + let rowsWritten: Int? + + enum CodingKeys: String, CodingKey { + case duration, changes + case rowsRead = "rows_read" + case rowsWritten = "rows_written" + } + } + + private struct D1DatabaseInfo: Decodable { + let uuid: String + let name: String + let createdAt: String? + let version: String? + + enum CodingKeys: String, CodingKey { + case uuid, name, version + case createdAt = "created_at" + } + } + + private struct D1ListResponse: Decodable { + let result: [D1DatabaseInfo] + let success: Bool + } + + private enum D1Value: Decodable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + + var stringValue: String? { + switch self { + case .string(let val): return val + case .int(let val): return String(val) + case .double(let val): return String(val) + case .bool(let val): return val ? "1" : "0" + case .null: return nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null; return } + if let v = try? container.decode(Int.self) { self = .int(v); return } + if let v = try? container.decode(Double.self) { self = .double(v); return } + if let v = try? container.decode(Bool.self) { self = .bool(v); return } + if let v = try? container.decode(String.self) { self = .string(v); return } + self = .null + } + } + + // MARK: - /raw Endpoint Response + + @Test("Parses raw query response with columns and rows") + func parsesRawResponse() throws { + let json = """ + { + "result": [{ + "results": { + "columns": ["id", "name", "age"], + "rows": [[1, "Alice", 30], [2, "Bob", null]] + }, + "meta": { + "duration": 0.5, + "changes": 0, + "rows_read": 2, + "rows_written": 0 + }, + "success": true + }], + "success": true, + "errors": [] + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) + #expect(envelope.success) + + guard let results = envelope.result, let first = results.first else { + Issue.record("Expected non-nil result") + return + } + + #expect(first.success) + #expect(first.results.columns == ["id", "name", "age"]) + + guard let rows = first.results.rows else { + Issue.record("Expected non-nil rows") + return + } + + #expect(rows.count == 2) + #expect(rows[0][0].stringValue == "1") + #expect(rows[0][1].stringValue == "Alice") + #expect(rows[0][2].stringValue == "30") + #expect(rows[1][0].stringValue == "2") + #expect(rows[1][1].stringValue == "Bob") + #expect(rows[1][2].stringValue == nil) + + #expect(first.meta?.duration == 0.5) + #expect(first.meta?.changes == 0) + #expect(first.meta?.rowsRead == 2) + #expect(first.meta?.rowsWritten == 0) + } + + @Test("Parses raw response with empty results") + func parsesEmptyRawResponse() throws { + let json = """ + { + "result": [{ + "results": { + "columns": [], + "rows": [] + }, + "meta": {"duration": 0.1, "changes": 0}, + "success": true + }], + "success": true + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) + guard let first = envelope.result?.first else { + Issue.record("Expected result") + return + } + + #expect(first.results.columns?.isEmpty == true) + #expect(first.results.rows?.isEmpty == true) + } + + @Test("Parses mutation response with changes count") + func parsesMutationResponse() throws { + let json = """ + { + "result": [{ + "results": { + "columns": [], + "rows": [] + }, + "meta": { + "duration": 0.3, + "changes": 5, + "rows_read": 0, + "rows_written": 5 + }, + "success": true + }], + "success": true + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) + guard let first = envelope.result?.first else { + Issue.record("Expected result") + return + } + + #expect(first.meta?.changes == 5) + #expect(first.meta?.rowsWritten == 5) + } + + // MARK: - Error Response + + @Test("Parses error response with error details") + func parsesErrorResponse() throws { + let json = """ + { + "result": null, + "success": false, + "errors": [ + {"code": 7500, "message": "no such table: nonexistent"} + ] + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) + #expect(!envelope.success) + #expect(envelope.result == nil) + #expect(envelope.errors?.count == 1) + #expect(envelope.errors?.first?.code == 7500) + #expect(envelope.errors?.first?.message == "no such table: nonexistent") + } + + @Test("Parses error response without error code") + func parsesErrorWithoutCode() throws { + let json = """ + { + "success": false, + "errors": [{"message": "Something went wrong"}] + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) + #expect(!envelope.success) + #expect(envelope.errors?.first?.code == nil) + #expect(envelope.errors?.first?.message == "Something went wrong") + } + + // MARK: - Database List Response + + @Test("Parses list databases response") + func parsesListDatabases() throws { + let json = """ + { + "result": [ + {"uuid": "abc-123", "name": "my-db", "created_at": "2025-01-01T00:00:00Z", "version": "production"}, + {"uuid": "def-456", "name": "staging-db", "created_at": "2025-06-15T12:00:00Z", "version": "production"} + ], + "success": true + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(D1ListResponse.self, from: json) + #expect(response.success) + #expect(response.result.count == 2) + #expect(response.result[0].uuid == "abc-123") + #expect(response.result[0].name == "my-db") + #expect(response.result[0].createdAt == "2025-01-01T00:00:00Z") + #expect(response.result[0].version == "production") + #expect(response.result[1].uuid == "def-456") + #expect(response.result[1].name == "staging-db") + } + + @Test("Parses database details response (single object)") + func parsesDatabaseDetails() throws { + let json = """ + { + "result": {"uuid": "abc-123", "name": "my-db", "version": "production"}, + "success": true + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: json) + #expect(envelope.success) + guard let db = envelope.result else { + Issue.record("Expected result") + return + } + #expect(db.uuid == "abc-123") + #expect(db.name == "my-db") + #expect(db.version == "production") + } + + @Test("Parses database info with missing optional fields") + func parsesDatabaseInfoMissingOptionals() throws { + let json = """ + { + "result": [{"uuid": "abc-123", "name": "my-db"}], + "success": true + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(D1ListResponse.self, from: json) + #expect(response.result[0].createdAt == nil) + #expect(response.result[0].version == nil) + } + + // MARK: - QueryMeta snake_case Decoding + + @Test("QueryMeta decodes snake_case fields correctly") + func queryMetaSnakeCase() throws { + let json = """ + {"duration": 1.5, "changes": 3, "rows_read": 100, "rows_written": 3} + """.data(using: .utf8)! + + let meta = try JSONDecoder().decode(D1QueryMeta.self, from: json) + #expect(meta.duration == 1.5) + #expect(meta.changes == 3) + #expect(meta.rowsRead == 100) + #expect(meta.rowsWritten == 3) + } + + @Test("QueryMeta handles missing optional fields") + func queryMetaMissingFields() throws { + let json = "{}".data(using: .utf8)! + + let meta = try JSONDecoder().decode(D1QueryMeta.self, from: json) + #expect(meta.duration == nil) + #expect(meta.changes == nil) + #expect(meta.rowsRead == nil) + #expect(meta.rowsWritten == nil) + } +} diff --git a/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift b/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift new file mode 100644 index 00000000..412e91e7 --- /dev/null +++ b/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift @@ -0,0 +1,237 @@ +// +// D1ValueDecodingTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("D1Value JSON Decoding") +struct D1ValueDecodingTests { + + // MARK: - Local copy of D1Value for testing + + private enum D1Value: Decodable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + + var stringValue: String? { + switch self { + case .string(let val): return val + case .int(let val): return String(val) + case .double(let val): return String(val) + case .bool(let val): return val ? "1" : "0" + case .null: return nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + if let intVal = try? container.decode(Int.self) { + self = .int(intVal) + return + } + + if let doubleVal = try? container.decode(Double.self) { + self = .double(doubleVal) + return + } + + if let boolVal = try? container.decode(Bool.self) { + self = .bool(boolVal) + return + } + + if let stringVal = try? container.decode(String.self) { + self = .string(stringVal) + return + } + + self = .null + } + } + + // MARK: - Null + + @Test("Decodes JSON null as .null") + func decodesNull() throws { + let json = "[null]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + #expect(values.count == 1) + if case .null = values[0] { + // correct + } else { + Issue.record("Expected .null, got \(values[0])") + } + } + + @Test("Null stringValue returns nil") + func nullStringValue() throws { + let json = "[null]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + #expect(values[0].stringValue == nil) + } + + // MARK: - Integers + + @Test("Decodes integer as .int") + func decodesInteger() throws { + let json = "[42]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .int(let val) = values[0] { + #expect(val == 42) + } else { + Issue.record("Expected .int, got \(values[0])") + } + } + + @Test("Decodes zero as .int not .bool") + func decodesZeroAsInt() throws { + let json = "[0]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .int(let val) = values[0] { + #expect(val == 0) + } else { + Issue.record("Expected .int(0), got \(values[0])") + } + } + + @Test("Decodes one as .int not .bool") + func decodesOneAsInt() throws { + let json = "[1]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .int(let val) = values[0] { + #expect(val == 1) + } else { + Issue.record("Expected .int(1), got \(values[0])") + } + } + + @Test("Decodes negative integer") + func decodesNegativeInt() throws { + let json = "[-100]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .int(let val) = values[0] { + #expect(val == -100) + } else { + Issue.record("Expected .int(-100), got \(values[0])") + } + } + + @Test("Integer stringValue returns string representation") + func intStringValue() throws { + let json = "[42]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + #expect(values[0].stringValue == "42") + } + + // MARK: - Doubles + + @Test("Decodes float as .double") + func decodesFloat() throws { + let json = "[3.14]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .double(let val) = values[0] { + #expect(abs(val - 3.14) < 0.001) + } else { + Issue.record("Expected .double, got \(values[0])") + } + } + + @Test("Double stringValue returns string representation") + func doubleStringValue() throws { + let json = "[3.14]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + guard let str = values[0].stringValue else { + Issue.record("Expected non-nil stringValue") + return + } + #expect(str.hasPrefix("3.14")) + } + + // MARK: - Booleans + + @Test("Decodes JSON true as .bool (not when it could be int)") + func decodesTrue() throws { + let json = "[true]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + // JSON true is distinct from integer 1 in JSON spec + // Foundation's JSONDecoder may decode true as Int(1) since Int is tried first + // This is acceptable — the stringValue is "1" either way + let str = values[0].stringValue + #expect(str == "1") + } + + @Test("Decodes JSON false") + func decodesFalse() throws { + let json = "[false]".data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + let str = values[0].stringValue + #expect(str == "0") + } + + // MARK: - Strings + + @Test("Decodes string as .string") + func decodesString() throws { + let json = #"["hello"]"#.data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .string(let val) = values[0] { + #expect(val == "hello") + } else { + Issue.record("Expected .string, got \(values[0])") + } + } + + @Test("String stringValue returns the string") + func stringStringValue() throws { + let json = #"["hello"]"#.data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + #expect(values[0].stringValue == "hello") + } + + @Test("Decodes empty string") + func decodesEmptyString() throws { + let json = #"[""]"#.data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .string(let val) = values[0] { + #expect(val == "") + } else { + Issue.record("Expected .string, got \(values[0])") + } + } + + @Test("Decodes numeric string as .string not .int") + func decodesNumericString() throws { + let json = #"["42"]"#.data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + if case .string(let val) = values[0] { + #expect(val == "42") + } else { + Issue.record("Expected .string(\"42\"), got \(values[0])") + } + } + + // MARK: - Mixed Array + + @Test("Decodes mixed-type array from D1 row response") + func decodesMixedRow() throws { + let json = #"[1, "Alice", 30, null, 3.14]"#.data(using: .utf8)! + let values = try JSONDecoder().decode([D1Value].self, from: json) + #expect(values.count == 5) + #expect(values[0].stringValue == "1") + #expect(values[1].stringValue == "Alice") + #expect(values[2].stringValue == "30") + #expect(values[3].stringValue == nil) + #expect(values[4].stringValue?.hasPrefix("3.14") == true) + } +} From 4b749ed5821d8db39801f66c96becc8460a53688 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 14:57:15 +0700 Subject: [PATCH 2/6] fix: make D1 plugin registry-distributed, not built-in --- TablePro.xcodeproj/project.pbxproj | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index b56dd180..8ea09b48 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5AE4F4912F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; @@ -144,17 +143,6 @@ name = "Copy Plug-Ins"; runOnlyForDeploymentPostprocessing = 0; }; - 5AE4F4922F6BC0640097AC5B /* Copy D1 Plugin */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 5AE4F4912F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin in Copy Plug-Ins */, - ); - name = "Copy D1 Plugin"; - runOnlyForDeploymentPostprocessing = 0; - }; 5A86FF0100000000 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -778,7 +766,6 @@ 5A1091C52EF17EDC0055EA7C /* Resources */, 5A86FF0100000000 /* Embed Frameworks */, 5A86FF0000000000 /* Copy Plug-Ins */, - 5AE4F4922F6BC0640097AC5B /* Copy D1 Plugin */, ); buildRules = ( ); From 444ef2e7dfbf55b0dd834f8aa56ec2ec45d0ddfb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 14:58:58 +0700 Subject: [PATCH 3/6] ci: add Cloudflare D1 to plugin build workflow --- .github/workflows/build-plugin.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index c8b41aea..1026cbc7 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -155,6 +155,11 @@ jobs: DISPLAY_NAME="Redis Driver"; SUMMARY="Redis in-memory data store driver via hiredis" DB_TYPE_IDS='["Redis"]'; ICON="redis-icon"; BUNDLE_NAME="RedisDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/redis" ;; + cloudflare-d1) + TARGET="CloudflareD1DriverPlugin"; BUNDLE_ID="com.TablePro.CloudflareD1DriverPlugin" + 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" ;; xlsx) TARGET="XLSXExport"; BUNDLE_ID="com.TablePro.XLSXExportPlugin" DISPLAY_NAME="XLSX Export"; SUMMARY="Export data to Microsoft Excel XLSX format" @@ -174,8 +179,8 @@ jobs: esac } - PLUGIN_NAME=$(echo "$TAG" | sed -E 's/^plugin-([a-z]+)-v.*$/\1/') - VERSION=$(echo "$TAG" | sed -E 's/^plugin-[a-z]+-v(.*)$/\1/') + PLUGIN_NAME=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\1/') + VERSION=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\2/') resolve_plugin_info "$PLUGIN_NAME" From 535185a647ce4aff6c13cd881478b3e843e6e5c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 15:04:35 +0700 Subject: [PATCH 4/6] docs: add Cloudflare D1 documentation (en, vi, zh) --- docs/databases/cloudflare-d1.mdx | 239 ++++++++++++++++++++++++++++ docs/vi/databases/cloudflare-d1.mdx | 210 ++++++++++++++++++++++++ docs/zh/databases/cloudflare-d1.mdx | 210 ++++++++++++++++++++++++ 3 files changed, 659 insertions(+) create mode 100644 docs/databases/cloudflare-d1.mdx create mode 100644 docs/vi/databases/cloudflare-d1.mdx create mode 100644 docs/zh/databases/cloudflare-d1.mdx diff --git a/docs/databases/cloudflare-d1.mdx b/docs/databases/cloudflare-d1.mdx new file mode 100644 index 00000000..e5e122e1 --- /dev/null +++ b/docs/databases/cloudflare-d1.mdx @@ -0,0 +1,239 @@ +--- +title: Cloudflare D1 +description: Connect to Cloudflare D1 databases with TablePro +--- + +# Cloudflare D1 Connections + +TablePro supports Cloudflare D1, a serverless SQLite-compatible database. TablePro connects via the Cloudflare REST API using your API token - no direct database connections or SSH tunnels needed. + +## Install Plugin + +The Cloudflare D1 driver is available as a downloadable plugin. When you select Cloudflare D1 in the connection form, TablePro will prompt you to install it automatically. You can also install it manually: + +1. Open **Settings** > **Plugins** > **Browse** +2. Find **Cloudflare D1 Driver** and click **Install** +3. The plugin downloads and loads immediately - no restart needed + +## Quick Setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **Cloudflare D1** from the database type selector + + + Fill in your database name (or UUID), Cloudflare Account ID, and API token + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | +|-------|-------------| +| **Name** | Connection identifier | +| **Database** | D1 database name or UUID | +| **Account ID** | Your Cloudflare account ID | +| **API Token** | Cloudflare API token with D1 permissions | + + +You can use either the database name (e.g., `my-app-db`) or the database UUID. If you use a name, TablePro resolves it to the UUID automatically via the Cloudflare API. + + +## Getting Your Credentials + +### Account ID + +Find your Account ID on the [Cloudflare dashboard](https://dash.cloudflare.com) right sidebar, or run: + +```bash +npx wrangler whoami +``` + +### API Token + +1. Go to [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) +2. Click **Create Token** +3. Select **Custom token** template +4. Add permission: **Account** > **D1** > **Edit** +5. Save and copy the token + + +Store your API token securely. TablePro saves it in the macOS Keychain, but the token grants access to all D1 databases in your account. + + +### Create a D1 Database + +If you don't have a D1 database yet: + +```bash +# Install Wrangler CLI +npm install -g wrangler + +# Login to Cloudflare +wrangler login + +# Create a database +wrangler d1 create my-app-db + +# Seed with test data +wrangler d1 execute my-app-db --remote \ + --command "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)" +wrangler d1 execute my-app-db --remote \ + --command "INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')" +``` + +You can also create databases directly from TablePro using the **Create Database** button in the database switcher. + +## Example Configuration + +``` +Name: My D1 Database +Database: my-app-db +Account ID: abc123def456 +API Token: (your Cloudflare API token) +``` + +## Features + +### Database Browsing + +After connecting, use the database switcher in the toolbar to list and switch between all D1 databases in your Cloudflare account. The sidebar shows tables and views in the selected database. + +### Table Browsing + +For each table, TablePro shows: + +- **Structure**: Columns with SQLite data types, nullability, default values, and primary key info +- **Indexes**: B-tree indexes with column details +- **Foreign Keys**: Foreign key constraints with referenced tables +- **DDL**: The full CREATE TABLE statement + +### Query Editor + +Write and execute SQL queries using SQLite syntax: + +```sql +-- Query data +SELECT name, email FROM users WHERE id > 10 ORDER BY name; + +-- Create tables +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + title TEXT NOT NULL, + content TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Aggregations +SELECT user_id, COUNT(*) as post_count +FROM posts +GROUP BY user_id +HAVING post_count > 5; +``` + +### EXPLAIN Query Plan + +Analyze query execution plans using the Explain button in the query editor. TablePro uses `EXPLAIN QUERY PLAN` to show how SQLite processes your queries. + +### Data Editing + +Edit cell values, insert rows, and delete rows directly in the data grid. Changes are submitted as standard INSERT, UPDATE, and DELETE statements via the D1 API. + +### Database Management + +- **List Databases**: View all D1 databases in your Cloudflare account +- **Switch Database**: Switch between databases without reconnecting +- **Create Database**: Create new D1 databases directly from TablePro + +### Export + +Export query results or table data to CSV, JSON, SQL, and other formats. + +## SQL Dialect + +D1 uses SQLite's SQL syntax. Key points: + +- **Identifier quoting**: Double quotes (`"column_name"`) +- **String literals**: Single quotes (`'value'`) +- **Auto-increment**: `INTEGER PRIMARY KEY AUTOINCREMENT` +- **Type affinity**: SQLite's flexible type system applies +- **PRAGMA support**: Most SQLite PRAGMAs work (`PRAGMA table_info`, `PRAGMA foreign_key_list`, etc.) + +## Troubleshooting + +### Authentication Failed + +**Symptoms**: "Authentication failed. Check your API token and Account ID." + +**Solutions**: + +1. Verify your API token has **D1 Edit** permissions +2. Check that the Account ID matches your Cloudflare account +3. Ensure the token has not expired or been revoked +4. Try creating a new API token + +### Database Not Found + +**Symptoms**: "Database 'name' not found in account" + +**Solutions**: + +1. Verify the database name or UUID is correct +2. Check that the database exists: `wrangler d1 list` +3. Ensure your API token has access to the account containing the database + +### Rate Limited + +**Symptoms**: "Rate limited by Cloudflare" + +**Solutions**: + +1. Wait for the retry period indicated in the error message +2. Reduce query frequency +3. Use pagination for large result sets instead of fetching all rows + +### Connection Timeout + +**Symptoms**: Queries take too long or time out + +**Solutions**: + +1. Check your internet connection +2. Verify the Cloudflare API is operational at [Cloudflare Status](https://www.cloudflarestatus.com) +3. Simplify complex queries that may exceed D1's execution limits + +## Known Limitations + +- **No persistent connections.** Each query is an independent HTTP request to the Cloudflare API. There is no connection pooling or session state between requests. +- **No transactions.** D1 does not support multi-statement transactions across API calls. Each SQL statement executes independently and auto-commits. +- **No SSH or SSL configuration.** D1 is accessed exclusively via HTTPS through the Cloudflare API. No custom SSL certificates or SSH tunnels are needed or supported. +- **No schema editing UI.** Table structure changes must be done via SQL (ALTER TABLE, etc.) in the query editor. +- **10 GB database limit.** Each D1 database is capped at 10 GB. For larger datasets, consider sharding across multiple databases. +- **API rate limits.** The Cloudflare API has rate limits that may affect heavy usage. TablePro surfaces rate limit errors with retry timing. +- **No import.** Bulk data import is not supported through the plugin. Use `wrangler d1 execute` with SQL files for bulk loading. + +## Next Steps + + + + Master the query editor features + + + Browse and edit data in the data grid + + + Export D1 data to various formats + + + Speed up your workflow + + diff --git a/docs/vi/databases/cloudflare-d1.mdx b/docs/vi/databases/cloudflare-d1.mdx new file mode 100644 index 00000000..73c58a13 --- /dev/null +++ b/docs/vi/databases/cloudflare-d1.mdx @@ -0,0 +1,210 @@ +--- +title: Cloudflare D1 +description: Kết nối cơ sở dữ liệu Cloudflare D1 với TablePro +--- + +# Kết nối Cloudflare D1 + +TablePro hỗ trợ Cloudflare D1, cơ sở dữ liệu serverless tương thích SQLite. TablePro kết nối qua REST API của Cloudflare bằng API token, không cần kết nối trực tiếp hay SSH tunnel. + +## Cài đặt Plugin + +Driver Cloudflare D1 có sẵn dưới dạng plugin tải về. Khi bạn chọn Cloudflare D1 trong form kết nối, TablePro sẽ tự động nhắc bạn cài đặt. Bạn cũng có thể cài đặt thủ công: + +1. Mở **Cài đặt** > **Plugin** > **Duyệt** +2. Tìm **Cloudflare D1 Driver** và nhấn **Cài đặt** +3. Plugin được tải và kích hoạt ngay lập tức, không cần khởi động lại + +## Thiết lập Nhanh + + + + Nhấp **New Connection** từ màn hình Chào mừng hoặc **File** > **New Connection** + + + Chọn **Cloudflare D1** từ bộ chọn loại cơ sở dữ liệu + + + Điền tên database (hoặc UUID), Account ID và API token của Cloudflare + + + Nhấp **Test Connection**, sau đó nhấp **Create** + + + +## Cài đặt Kết nối + +### Trường Bắt buộc + +| Trường | Mô tả | +|--------|-------| +| **Name** | Tên định danh kết nối | +| **Database** | Tên hoặc UUID của database D1 | +| **Account ID** | Account ID của Cloudflare | +| **API Token** | API token có quyền D1 | + + +Bạn có thể dùng tên database (ví dụ: `my-app-db`) hoặc UUID. Nếu dùng tên, TablePro sẽ tự động chuyển đổi sang UUID qua API Cloudflare. + + +## Lấy Thông tin Xác thực + +### Account ID + +Tìm Account ID trên [bảng điều khiển Cloudflare](https://dash.cloudflare.com) ở thanh bên phải, hoặc chạy: + +```bash +npx wrangler whoami +``` + +### API Token + +1. Truy cập [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) +2. Nhấp **Create Token** +3. Chọn mẫu **Custom token** +4. Thêm quyền: **Account** > **D1** > **Edit** +5. Lưu và sao chép token + + +Bảo quản API token cẩn thận. TablePro lưu token trong macOS Keychain, nhưng token cho phép truy cập tất cả database D1 trong tài khoản. + + +### Tạo Database D1 + +Nếu chưa có database D1: + +```bash +# Cài đặt Wrangler CLI +npm install -g wrangler + +# Đăng nhập Cloudflare +wrangler login + +# Tạo database +wrangler d1 create my-app-db + +# Thêm dữ liệu test +wrangler d1 execute my-app-db --remote \ + --command "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)" +wrangler d1 execute my-app-db --remote \ + --command "INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')" +``` + +Bạn cũng có thể tạo database trực tiếp từ TablePro bằng nút **Create Database** trong bộ chuyển database. + +## Ví dụ Cấu hình + +``` +Name: My D1 Database +Database: my-app-db +Account ID: abc123def456 +API Token: (API token Cloudflare của bạn) +``` + +## Tính năng + +### Duyệt Database + +Sau khi kết nối, sử dụng bộ chuyển database trên thanh công cụ để liệt kê và chuyển đổi giữa các database D1 trong tài khoản Cloudflare. Thanh bên hiển thị các bảng và view trong database đã chọn. + +### Duyệt Bảng + +Với mỗi bảng, TablePro hiển thị: + +- **Cấu trúc**: Cột với kiểu dữ liệu SQLite, khả năng null, giá trị mặc định và thông tin khóa chính +- **Chỉ mục**: Chỉ mục B-tree với chi tiết cột +- **Khóa ngoại**: Ràng buộc khóa ngoại với bảng tham chiếu +- **DDL**: Câu lệnh CREATE TABLE đầy đủ + +### Trình Soạn thảo SQL + +Viết và thực thi truy vấn SQL với cú pháp SQLite: + +```sql +-- Truy vấn dữ liệu +SELECT name, email FROM users WHERE id > 10 ORDER BY name; + +-- Tạo bảng +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + title TEXT NOT NULL, + content TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### EXPLAIN Query Plan + +Phân tích kế hoạch thực thi truy vấn bằng nút Explain trong trình soạn thảo. TablePro sử dụng `EXPLAIN QUERY PLAN` để hiển thị cách SQLite xử lý truy vấn. + +### Chỉnh sửa Dữ liệu + +Chỉnh sửa giá trị ô, thêm hàng và xóa hàng trực tiếp trong lưới dữ liệu. Các thay đổi được gửi dưới dạng câu lệnh INSERT, UPDATE và DELETE qua D1 API. + +### Quản lý Database + +- **Liệt kê Database**: Xem tất cả database D1 trong tài khoản Cloudflare +- **Chuyển Database**: Chuyển đổi giữa các database mà không cần kết nối lại +- **Tạo Database**: Tạo database D1 mới trực tiếp từ TablePro + +### Xuất dữ liệu + +Xuất kết quả truy vấn hoặc dữ liệu bảng sang CSV, JSON, SQL và các định dạng khác. + +## Xử lý Sự cố + +### Xác thực Thất bại + +**Triệu chứng**: "Authentication failed. Check your API token and Account ID." + +**Giải pháp**: + +1. Kiểm tra API token có quyền **D1 Edit** +2. Xác nhận Account ID đúng với tài khoản Cloudflare +3. Đảm bảo token chưa hết hạn hoặc bị thu hồi + +### Không Tìm thấy Database + +**Triệu chứng**: "Database 'name' not found in account" + +**Giải pháp**: + +1. Kiểm tra tên hoặc UUID database chính xác +2. Xác nhận database tồn tại: `wrangler d1 list` +3. Đảm bảo API token có quyền truy cập tài khoản chứa database + +### Bị Giới hạn Tần suất + +**Triệu chứng**: "Rate limited by Cloudflare" + +**Giải pháp**: + +1. Đợi theo thời gian chỉ định trong thông báo lỗi +2. Giảm tần suất truy vấn +3. Sử dụng phân trang cho tập kết quả lớn + +## Hạn chế + +- **Không có kết nối liên tục.** Mỗi truy vấn là một yêu cầu HTTP độc lập đến API Cloudflare. +- **Không hỗ trợ transaction.** D1 không hỗ trợ transaction đa câu lệnh. Mỗi câu lệnh SQL thực thi độc lập và tự động commit. +- **Không cần SSH hay SSL.** D1 truy cập qua HTTPS thông qua API Cloudflare. Không cần cấu hình SSL hay SSH tunnel. +- **Giới hạn 10 GB.** Mỗi database D1 giới hạn 10 GB. +- **Giới hạn tần suất API.** API Cloudflare có giới hạn tần suất có thể ảnh hưởng đến sử dụng nặng. + +## Bước Tiếp theo + + + + Tìm hiểu các tính năng trình soạn thảo + + + Duyệt và chỉnh sửa dữ liệu + + + Xuất dữ liệu D1 sang các định dạng khác + + + Tăng tốc quy trình làm việc + + diff --git a/docs/zh/databases/cloudflare-d1.mdx b/docs/zh/databases/cloudflare-d1.mdx new file mode 100644 index 00000000..b67a12bd --- /dev/null +++ b/docs/zh/databases/cloudflare-d1.mdx @@ -0,0 +1,210 @@ +--- +title: Cloudflare D1 +description: 使用 TablePro 连接 Cloudflare D1 数据库 +--- + +# Cloudflare D1 连接 + +TablePro 支持 Cloudflare D1,一个兼容 SQLite 的无服务器数据库。TablePro 通过 Cloudflare REST API 使用 API 令牌进行连接,无需直接数据库连接或 SSH 隧道。 + +## 安装插件 + +Cloudflare D1 驱动以可下载插件的形式提供。当您在连接表单中选择 Cloudflare D1 时,TablePro 会自动提示您安装。您也可以手动安装: + +1. 打开 **设置** > **插件** > **浏览** +2. 找到 **Cloudflare D1 Driver** 并点击 **安装** +3. 插件立即下载并加载,无需重启 + +## 快速设置 + + + + 在欢迎界面点击 **New Connection**,或通过 **File** > **New Connection** + + + 从数据库类型选择器中选择 **Cloudflare D1** + + + 填写数据库名称(或 UUID)、Cloudflare Account ID 和 API 令牌 + + + 点击 **Test Connection**,然后点击 **Create** + + + +## 连接设置 + +### 必填字段 + +| 字段 | 说明 | +|------|------| +| **Name** | 连接标识名称 | +| **Database** | D1 数据库名称或 UUID | +| **Account ID** | Cloudflare 账户 ID | +| **API Token** | 具有 D1 权限的 Cloudflare API 令牌 | + + +您可以使用数据库名称(例如 `my-app-db`)或 UUID。如果使用名称,TablePro 会通过 Cloudflare API 自动解析为 UUID。 + + +## 获取凭据 + +### Account ID + +在 [Cloudflare 控制台](https://dash.cloudflare.com) 右侧边栏找到 Account ID,或运行: + +```bash +npx wrangler whoami +``` + +### API 令牌 + +1. 访问 [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) +2. 点击 **Create Token** +3. 选择 **Custom token** 模板 +4. 添加权限:**Account** > **D1** > **Edit** +5. 保存并复制令牌 + + +请妥善保管 API 令牌。TablePro 将其保存在 macOS 钥匙串中,但该令牌允许访问账户中的所有 D1 数据库。 + + +### 创建 D1 数据库 + +如果还没有 D1 数据库: + +```bash +# 安装 Wrangler CLI +npm install -g wrangler + +# 登录 Cloudflare +wrangler login + +# 创建数据库 +wrangler d1 create my-app-db + +# 添加测试数据 +wrangler d1 execute my-app-db --remote \ + --command "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)" +wrangler d1 execute my-app-db --remote \ + --command "INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')" +``` + +您也可以在 TablePro 中使用数据库切换器的 **Create Database** 按钮直接创建数据库。 + +## 配置示例 + +``` +Name: My D1 Database +Database: my-app-db +Account ID: abc123def456 +API Token: (您的 Cloudflare API 令牌) +``` + +## 功能 + +### 数据库浏览 + +连接后,使用工具栏中的数据库切换器列出和切换 Cloudflare 账户中的所有 D1 数据库。侧边栏显示所选数据库中的表和视图。 + +### 表浏览 + +对于每个表,TablePro 显示: + +- **结构**:列及其 SQLite 数据类型、可空性、默认值和主键信息 +- **索引**:B-tree 索引及列详情 +- **外键**:外键约束及引用表 +- **DDL**:完整的 CREATE TABLE 语句 + +### SQL 编辑器 + +使用 SQLite 语法编写和执行 SQL 查询: + +```sql +-- 查询数据 +SELECT name, email FROM users WHERE id > 10 ORDER BY name; + +-- 创建表 +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + title TEXT NOT NULL, + content TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### EXPLAIN 查询计划 + +使用查询编辑器中的 Explain 按钮分析查询执行计划。TablePro 使用 `EXPLAIN QUERY PLAN` 显示 SQLite 如何处理您的查询。 + +### 数据编辑 + +在数据网格中直接编辑单元格值、插入行和删除行。更改通过 D1 API 以标准 INSERT、UPDATE 和 DELETE 语句提交。 + +### 数据库管理 + +- **列出数据库**:查看 Cloudflare 账户中的所有 D1 数据库 +- **切换数据库**:无需重新连接即可切换数据库 +- **创建数据库**:直接从 TablePro 创建新的 D1 数据库 + +### 导出 + +将查询结果或表数据导出为 CSV、JSON、SQL 等格式。 + +## 故障排除 + +### 认证失败 + +**症状**:"Authentication failed. Check your API token and Account ID." + +**解决方案**: + +1. 确认 API 令牌具有 **D1 Edit** 权限 +2. 检查 Account ID 是否与 Cloudflare 账户匹配 +3. 确保令牌未过期或被撤销 + +### 未找到数据库 + +**症状**:"Database 'name' not found in account" + +**解决方案**: + +1. 确认数据库名称或 UUID 正确 +2. 验证数据库存在:`wrangler d1 list` +3. 确保 API 令牌有权访问包含该数据库的账户 + +### 频率限制 + +**症状**:"Rate limited by Cloudflare" + +**解决方案**: + +1. 按错误消息中指示的时间等待 +2. 降低查询频率 +3. 对大型结果集使用分页 + +## 已知限制 + +- **无持久连接。** 每个查询都是对 Cloudflare API 的独立 HTTP 请求。 +- **不支持事务。** D1 不支持跨 API 调用的多语句事务。每条 SQL 语句独立执行并自动提交。 +- **无需 SSH 或 SSL。** D1 通过 Cloudflare API 以 HTTPS 方式访问,无需配置 SSL 证书或 SSH 隧道。 +- **10 GB 数据库限制。** 每个 D1 数据库上限为 10 GB。 +- **API 频率限制。** Cloudflare API 有频率限制,可能影响高频使用。 + +## 后续步骤 + + + + 掌握查询编辑器功能 + + + 浏览和编辑数据 + + + 将 D1 数据导出为各种格式 + + + 加速工作流程 + + From cf3e62a6942bf077924287f65054939ad55a7ca4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 15:11:14 +0700 Subject: [PATCH 5/6] fix: address PR review findings for Cloudflare D1 plugin --- .../CloudflareD1PluginDriver.swift | 25 +++++++++++-------- .../D1HttpClient.swift | 16 ++++++++---- .../Views/Connection/ConnectionFormView.swift | 8 +++++- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 1c1dd7b8..3586cda6 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -139,12 +139,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let anyParams: [Any?] = parameters.map { param -> Any? in - guard let value = param else { return nil } - return value - } - - let payload = try await client.executeRaw(sql: trimmed, params: anyParams) + let payload = try await client.executeRaw(sql: trimmed, params: parameters) let executionTime = Date().timeIntervalSince(startTime) return mapRawResult(payload, executionTime: executionTime) } @@ -220,7 +215,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let query = """ SELECT m.name AS tbl, p.cid, p.name, p.type, p."notnull", p.dflt_value, p.pk FROM sqlite_master m, pragma_table_info(m.name) p - WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT LIKE '_cf_%' + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT GLOB '_cf_*' ORDER BY m.name, p.cid """ let result = try await execute(query: query) @@ -259,7 +254,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable p."from" AS column_name, p."to" AS referenced_column, p.on_update, p.on_delete FROM sqlite_master m, pragma_foreign_key_list(m.name) p - WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT LIKE '_cf_%' + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.name NOT GLOB '_cf_*' ORDER BY m.name, p.id, p.seq """ let result = try await execute(query: query) @@ -569,9 +564,17 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable // MARK: - Transactions - func beginTransaction() async throws {} - func commitTransaction() async throws {} - func rollbackTransaction() async throws {} + func beginTransaction() async throws { + throw CloudflareD1Error(message: String(localized: "Transactions are not supported by Cloudflare D1")) + } + + func commitTransaction() async throws { + throw CloudflareD1Error(message: String(localized: "Transactions are not supported by Cloudflare D1")) + } + + func rollbackTransaction() async throws { + throw CloudflareD1Error(message: String(localized: "Transactions are not supported by Cloudflare D1")) + } // MARK: - Private Helpers diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index 66fbc3cd..786e9f11 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -363,11 +363,17 @@ final class D1HttpClient: @unchecked Sendable { message: String(localized: "Authentication failed. Check your API token and Account ID.") ) case 429: - let retryAfter = response.value(forHTTPHeaderField: "Retry-After") ?? "unknown" - Self.logger.warning("D1 rate limited. Retry-After: \(retryAfter)") - throw D1HttpError( - message: String(localized: "Rate limited by Cloudflare. Retry after \(retryAfter) seconds.") - ) + let retryAfter = response.value(forHTTPHeaderField: "Retry-After") + Self.logger.warning("D1 rate limited. Retry-After: \(retryAfter ?? "not specified")") + if let seconds = retryAfter { + throw D1HttpError( + message: String(localized: "Rate limited by Cloudflare. Retry after \(seconds) seconds.") + ) + } else { + throw D1HttpError( + message: String(localized: "Rate limited by Cloudflare. Please try again later.") + ) + } default: if let errorResponse = try? JSONDecoder().decode( D1ApiResponse.self, from: data diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 367f8d32..3375fb5e 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -874,7 +874,13 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length // Host and port can be empty (will use defaults: localhost and default port) let mode = PluginManager.shared.connectionMode(for: type) let requiresDatabase = mode == .fileBased || mode == .apiOnly - let basicValid = !name.isEmpty && (requiresDatabase ? !database.isEmpty : true) + var basicValid = !name.isEmpty && (requiresDatabase ? !database.isEmpty : true) + if mode == .apiOnly { + let hasRequiredFields = authSectionFields + .filter(\.isRequired) + .allSatisfy { !(additionalFieldValues[$0.id] ?? "").isEmpty } + basicValid = basicValid && hasRequiredFields && !password.isEmpty + } if sshEnabled { let sshPortValid = sshPort.isEmpty || (Int(sshPort).map { (1...65_535).contains($0) } ?? false) let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty && sshPortValid From ab1f7e51ff5708ea6e367c5e7c117b756e76482a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 15:15:02 +0700 Subject: [PATCH 6/6] fix: restore nil param handling, remove unused executeQuery --- .../CloudflareD1PluginDriver.swift | 3 ++- .../D1HttpClient.swift | 27 ++----------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 3586cda6..0bbd4a03 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -139,7 +139,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let payload = try await client.executeRaw(sql: trimmed, params: parameters) + let anyParams: [Any?] = parameters.map { $0 as Any? } + let payload = try await client.executeRaw(sql: trimmed, params: anyParams) let executionTime = Date().timeIntervalSince(startTime) return mapRawResult(payload, executionTime: executionTime) } diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index 786e9f11..6f7f8826 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -46,15 +46,6 @@ struct D1RawResults: Decodable { } } -struct D1QueryResultPayload: Decodable { - let results: [[String: D1Value]]? - let meta: D1QueryMeta? - let success: Bool - - private enum CodingKeys: String, CodingKey { - case results, meta, success - } -} struct D1QueryMeta: Decodable { let duration: Double? @@ -90,6 +81,8 @@ struct D1ListResponse: Decodable { } } +// No .bool case: D1/SQLite stores booleans as integers (0/1), +// and Foundation's JSONDecoder decodes JSON true/false as Int when Int is tried first. enum D1Value: Decodable { case string(String) case int(Int) @@ -207,22 +200,6 @@ final class D1HttpClient: @unchecked Sendable { return first } - func executeQuery(sql: String, params: [Any?]? = nil) async throws -> D1QueryResultPayload { - let dbId = databaseId - let url = try baseURL(databaseId: dbId).appendingPathComponent("query") - let body = try buildQueryBody(sql: sql, params: params) - let data = try await performRequest(url: url, method: "POST", body: body) - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1QueryResultPayload]>.self, from: data) - try checkApiSuccess(envelope) - - guard let results = envelope.result, let first = results.first else { - throw D1HttpError(message: String(localized: "Empty response from Cloudflare D1")) - } - - return first - } - func getDatabaseDetails() async throws -> D1DatabaseInfo { let dbId = databaseId let url = try baseURL(databaseId: dbId)