From 093935bbd936885695ac6257a8750f4c54dc3d01 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 14:53:35 +0700 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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) From d27db5187a4d6fc32406d3cdcf344b8ba9de6f47 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 16:02:24 +0700 Subject: [PATCH 07/12] fix(redis): add timeouts, keepalive, ACL auth, and thread-safety fixes --- .../RedisPluginConnection.swift | 125 +++++++++++++----- .../RedisDriverPlugin/RedisPluginDriver.swift | 1 + 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 2d77a488..92749c2e 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -23,6 +23,7 @@ struct RedisSSLConfig { var caCertificatePath: String = "" var clientCertificatePath: String = "" var clientKeyPath: String = "" + var verifyPeer: Bool = true init() {} @@ -32,6 +33,7 @@ struct RedisSSLConfig { self.caCertificatePath = additionalFields["sslCaCertPath"] ?? "" self.clientCertificatePath = additionalFields["sslClientCertPath"] ?? "" self.clientKeyPath = additionalFields["sslClientKeyPath"] ?? "" + self.verifyPeer = (additionalFields["sslVerifyPeer"] ?? "true") == "true" } } @@ -99,7 +101,10 @@ final class RedisPluginConnection: @unchecked Sendable { #if canImport(CRedis) private static let initOnce: Void = { - redisInitOpenSSL() + let result = redisInitOpenSSL() + if result != REDIS_OK { + logger.warning("redisInitOpenSSL failed with code \(result)") + } }() private var context: UnsafeMutablePointer? @@ -109,6 +114,7 @@ final class RedisPluginConnection: @unchecked Sendable { private let queue = DispatchQueue(label: "com.TablePro.redis.plugin", qos: .userInitiated) private let host: String private let port: Int + private let username: String? private let password: String? private let database: Int private let sslConfig: RedisSSLConfig @@ -144,12 +150,14 @@ final class RedisPluginConnection: @unchecked Sendable { init( host: String, port: Int, + username: String? = nil, password: String?, database: Int = 0, sslConfig: RedisSSLConfig = RedisSSLConfig() ) { self.host = host self.port = port + self.username = username self.password = password self.database = database self.sslConfig = sslConfig @@ -165,17 +173,8 @@ final class RedisPluginConnection: @unchecked Sendable { sslContext = nil stateLock.unlock() - let cleanupQueue = queue - if handle != nil || ssl != nil { - cleanupQueue.async { - if let handle = handle { - redisFree(handle) - } - if let ssl = ssl { - redisFreeSSLContext(ssl) - } - } - } + if let handle { redisFree(handle) } + if let ssl { redisFreeSSLContext(ssl) } #endif } @@ -187,7 +186,8 @@ final class RedisPluginConnection: @unchecked Sendable { try await pluginDispatchAsync(on: queue) { [self] in logger.debug("Connecting to Redis at \(self.host):\(self.port)") - guard let ctx = redisConnect(host, Int32(port)) else { + let connectTimeout = timeval(tv_sec: 10, tv_usec: 0) + guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else { logger.error("Failed to create Redis context") throw RedisPluginError.connectionFailed } @@ -202,6 +202,10 @@ final class RedisPluginConnection: @unchecked Sendable { throw RedisPluginError(code: errCode, message: errMsg) } + let commandTimeout = timeval(tv_sec: 30, tv_usec: 0) + redisSetTimeout(ctx, commandTimeout) + redisEnableKeepAliveWithInterval(ctx, 60) + self.context = ctx if sslConfig.isEnabled { @@ -216,7 +220,13 @@ final class RedisPluginConnection: @unchecked Sendable { if let password = password, !password.isEmpty { do { - let reply = try executeCommandSync(["AUTH", password]) + let authArgs: [String] + if let username = username, !username.isEmpty { + authArgs = ["AUTH", username, password] + } else { + authArgs = ["AUTH", password] + } + let reply = try executeCommandSync(authArgs) if case .error(let msg) = reply { redisFree(ctx) self.context = nil @@ -345,11 +355,17 @@ final class RedisPluginConnection: @unchecked Sendable { func executeCommand(_ args: [String]) async throws -> RedisReply { #if canImport(CRedis) - resetCancellation() return try await pluginDispatchAsync(on: queue) { [self] in - guard !isShuttingDown, context != nil else { + resetCancellation() + guard !isShuttingDown else { + throw RedisPluginError.notConnected + } + stateLock.lock() + guard context != nil else { + stateLock.unlock() throw RedisPluginError.notConnected } + stateLock.unlock() try checkCancelled() let result = try executeCommandSync(args) try checkCancelled() @@ -362,11 +378,17 @@ final class RedisPluginConnection: @unchecked Sendable { func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { #if canImport(CRedis) - resetCancellation() return try await pluginDispatchAsync(on: queue) { [self] in - guard !isShuttingDown, context != nil else { + resetCancellation() + guard !isShuttingDown else { throw RedisPluginError.notConnected } + stateLock.lock() + guard context != nil else { + stateLock.unlock() + throw RedisPluginError.notConnected + } + stateLock.unlock() try checkCancelled() let results = try executePipelineSync(commands) try checkCancelled() @@ -381,11 +403,17 @@ final class RedisPluginConnection: @unchecked Sendable { func selectDatabase(_ index: Int) async throws { #if canImport(CRedis) - resetCancellation() try await pluginDispatchAsync(on: queue) { [self] in - guard !isShuttingDown, context != nil else { + resetCancellation() + guard !isShuttingDown else { throw RedisPluginError.notConnected } + stateLock.lock() + guard context != nil else { + stateLock.unlock() + throw RedisPluginError.notConnected + } + stateLock.unlock() try checkCancelled() let reply = try executeCommandSync(["SELECT", String(index)]) if case .error(let msg) = reply { @@ -417,27 +445,44 @@ private extension RedisPluginConnection { let clientKey: UnsafePointer? = sslConfig.clientKeyPath.isEmpty ? nil : (sslConfig.clientKeyPath as NSString).utf8String + let sniHostname: UnsafePointer? = (host as NSString).utf8String + + var options = redisSSLOptions() + options.cacert_filename = caCert + options.capath = nil + options.cert_filename = clientCert + options.private_key_filename = clientKey + options.server_name = sniHostname + options.verify_mode = sslConfig.verifyPeer ? REDIS_SSL_VERIFY_PEER : REDIS_SSL_VERIFY_NONE - guard let ssl = redisCreateSSLContext(caCert, nil, clientCert, clientKey, nil, &sslError) else { + guard let ssl = redisCreateSSLContextWithOptions(&options, &sslError) else { let errCode = Int(sslError.rawValue) - throw RedisPluginError(code: errCode, message: "Failed to create SSL context (error \(errCode))") + throw RedisPluginError( + code: errCode, + message: "Failed to create SSL context (error \(errCode))" + ) } - self.sslContext = ssl - let result = redisInitiateSSLWithContext(ctx, ssl) if result != REDIS_OK { + redisFreeSSLContext(ssl) let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } } throw RedisPluginError(code: Int(result), message: "SSL handshake failed: \(errMsg)") } + self.sslContext = ssl logger.debug("SSL connection established") } func executeCommandSync(_ args: [String]) throws -> RedisReply { - guard let ctx = context else { throw RedisPluginError.notConnected } + stateLock.lock() + guard let ctx = context else { + stateLock.unlock() + throw RedisPluginError.notConnected + } + stateLock.unlock() let argc = Int32(args.count) let lengths = args.map { $0.utf8.count } @@ -461,7 +506,12 @@ private extension RedisPluginConnection { } func executePipelineSync(_ commands: [[String]]) throws -> [RedisReply] { - guard let ctx = context else { throw RedisPluginError.notConnected } + stateLock.lock() + guard let ctx = context else { + stateLock.unlock() + throw RedisPluginError.notConnected + } + stateLock.unlock() guard !commands.isEmpty else { return [] } var appendedCount = 0 @@ -517,8 +567,18 @@ private extension RedisPluginConnection { ) rethrows -> T { let count = args.count - let cStrings = args.map { strdup($0) } - defer { cStrings.forEach { free($0) } } + let cStrings: [UnsafeMutablePointer] = args.map { arg in + var utf8 = Array(arg.utf8) + let ptr = UnsafeMutablePointer.allocate(capacity: utf8.count + 1) + utf8.withUnsafeBufferPointer { buf in + buf.baseAddress!.withMemoryRebound(to: CChar.self, capacity: buf.count) { src in + ptr.initialize(from: src, count: buf.count) + } + } + ptr[utf8.count] = 0 + return ptr + } + defer { cStrings.forEach { $0.deallocate() } } let argv = UnsafeMutablePointer?>.allocate(capacity: count) let argvlen = UnsafeMutablePointer.allocate(capacity: count) @@ -651,7 +711,12 @@ private extension RedisPluginConnection { } func fetchServerVersionSync() -> String? { - guard context != nil else { return nil } + stateLock.lock() + guard context != nil else { + stateLock.unlock() + return nil + } + stateLock.unlock() do { let reply = try executeCommandSync(["INFO", "server"]) if case .string(let info) = reply { @@ -664,7 +729,7 @@ private extension RedisPluginConnection { } func parseVersionFromInfo(_ info: String) -> String? { - for line in info.components(separatedBy: "\r\n") { + for line in info.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("redis_version:") { let value = trimmed.dropFirst("redis_version:".count) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 5a2a9ab1..ce8ea13a 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -42,6 +42,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let conn = RedisPluginConnection( host: config.host, port: config.port, + username: config.username.isEmpty ? nil : config.username, password: config.password.isEmpty ? nil : config.password, database: redisDb, sslConfig: sslConfig From 9bb3fb91136f6a8b40e0eaafb1259324074f2086 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 16:01:33 +0700 Subject: [PATCH 08/12] fix(redis): fix tokenizer escape handling and type-aware statement generation --- .../RedisCommandParser.swift | 158 ++++++++++++++---- .../RedisDriverPlugin/RedisPluginDriver.swift | 12 +- .../RedisStatementGenerator.swift | 63 ++++++- 3 files changed, 191 insertions(+), 42 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 040106d8..1bbe86fa 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -44,8 +44,8 @@ enum RedisOperation { case scard(key: String) // Sorted set - case zrange(key: String, start: Int, stop: Int, withScores: Bool) - case zadd(key: String, scoreMembers: [(Double, String)]) + case zrange(key: String, start: String, stop: String, flags: [String]) + case zadd(key: String, flags: [String], scoreMembers: [(Double, String)]) case zrem(key: String, members: [String]) case zcard(key: String) @@ -149,7 +149,7 @@ struct RedisCommandParser { case "SET": guard args.count >= 2 else { throw RedisParseError.missingArgument("SET requires key and value") } - let options = parseSetOptions(Array(args.dropFirst(2))) + let options = try parseSetOptions(Array(args.dropFirst(2))) return .set(key: args[0], value: args[1], options: options) case "DEL": @@ -164,7 +164,7 @@ struct RedisCommandParser { guard args.count >= 1, let cursor = Int(args[0]) else { throw RedisParseError.missingArgument("SCAN requires a cursor (integer)") } - let (pattern, count) = parseScanOptions(Array(args.dropFirst())) + let (pattern, count) = try parseScanOptions(Array(args.dropFirst())) return .scan(cursor: cursor, pattern: pattern, count: count) case "TYPE": @@ -295,26 +295,56 @@ struct RedisCommandParser { switch command { case "ZRANGE": guard args.count >= 3 else { throw RedisParseError.missingArgument("ZRANGE requires key, start, and stop") } - guard let start = Int(args[1]), let stop = Int(args[2]) else { - throw RedisParseError.invalidArgument("ZRANGE start and stop must be integers") + let start = args[1] + let stop = args[2] + // Parse optional trailing flags: BYSCORE, BYLEX, REV, WITHSCORES, LIMIT offset count + let knownFlags: Set = ["BYSCORE", "BYLEX", "REV", "WITHSCORES", "LIMIT"] + var flags: [String] = [] + var i = 3 + while i < args.count { + let upper = args[i].uppercased() + if knownFlags.contains(upper) { + flags.append(upper) + if upper == "LIMIT" { + // LIMIT requires offset and count + guard i + 2 < args.count else { + throw RedisParseError.missingArgument("LIMIT requires offset and count") + } + flags.append(args[i + 1]) + flags.append(args[i + 2]) + i += 2 + } + } + i += 1 } - let withScores = args.count > 3 && args[3].uppercased() == "WITHSCORES" - return .zrange(key: args[0], start: start, stop: stop, withScores: withScores) + return .zrange(key: args[0], start: start, stop: stop, flags: flags) case "ZADD": - guard args.count >= 3, (args.count - 1) % 2 == 0 else { + guard args.count >= 2 else { throw RedisParseError.missingArgument("ZADD requires key followed by score member pairs") } - var scoreMembers: [(Double, String)] = [] + // Skip known flags after key: NX, XX, GT, LT, CH, INCR (case-insensitive) + let zaddFlags: Set = ["NX", "XX", "GT", "LT", "CH", "INCR"] + var collectedFlags: [String] = [] var i = 1 - while i + 1 < args.count { - guard let score = Double(args[i]) else { - throw RedisParseError.invalidArgument("ZADD score must be a number: \(args[i])") + while i < args.count, zaddFlags.contains(args[i].uppercased()) { + collectedFlags.append(args[i].uppercased()) + i += 1 + } + let remaining = Array(args[i...]) + guard !remaining.isEmpty, remaining.count % 2 == 0 else { + throw RedisParseError.missingArgument("ZADD requires score member pairs after flags") + } + var scoreMembers: [(Double, String)] = [] + var j = 0 + while j + 1 < remaining.count { + guard let score = Double(remaining[j]) else { + throw RedisParseError.invalidArgument("ZADD score must be a number: \(remaining[j])") } - scoreMembers.append((score, args[i + 1])) - i += 2 + scoreMembers.append((score, remaining[j + 1])) + j += 2 } - return .zadd(key: args[0], scoreMembers: scoreMembers) + return .zadd(key: args[0], flags: collectedFlags, scoreMembers: scoreMembers) case "ZREM": guard args.count >= 2 else { throw RedisParseError.missingArgument("ZREM requires key and at least one member") } @@ -407,23 +437,46 @@ struct RedisCommandParser { // MARK: - Tokenizer - /// Split input by whitespace, respecting quoted strings (single and double quotes) + /// Split input by whitespace, respecting quoted strings (single and double quotes). + /// Escape sequences (\n, \t, \r, \\, \", \') are only decoded inside quoted strings. + /// Outside quotes, backslash is treated as a literal character (matching Redis CLI behavior). private static func tokenize(_ input: String) -> [String] { var tokens: [String] = [] var current = "" var inQuote = false var quoteChar: Character = "\"" var escapeNext = false + var escapedInsideQuote = false + var hadQuote = false for char in input { if escapeNext { - current.append(char) escapeNext = false + if escapedInsideQuote { + // Decode known escape sequences inside quoted strings + switch char { + case "n": current.append("\n") + case "t": current.append("\t") + case "r": current.append("\r") + case "\\": current.append("\\") + case "\"": current.append("\"") + case "'": current.append("'") + default: + // Unknown escape: preserve both characters + current.append("\\") + current.append(char) + } + } else { + // Outside quotes: backslash is literal + current.append("\\") + current.append(char) + } continue } if char == "\\" { escapeNext = true + escapedInsideQuote = inQuote continue } @@ -438,14 +491,16 @@ struct RedisCommandParser { if char == "\"" || char == "'" { inQuote = true + hadQuote = true quoteChar = char continue } if char.isWhitespace { - if !current.isEmpty { + if !current.isEmpty || hadQuote { tokens.append(current) current = "" + hadQuote = false } continue } @@ -453,7 +508,12 @@ struct RedisCommandParser { current.append(char) } - if !current.isEmpty { + // Handle trailing backslash outside quotes + if escapeNext, !escapedInsideQuote { + current.append("\\") + } + + if !current.isEmpty || hadQuote { tokens.append(current) } @@ -462,8 +522,8 @@ struct RedisCommandParser { // MARK: - Option Parsers - /// Parse SET command options: EX, PX, NX, XX - private static func parseSetOptions(_ args: [String]) -> RedisSetOptions? { + /// Parse SET command options: EX, PX, EXAT, PXAT, NX, XX + private static func parseSetOptions(_ args: [String]) throws -> RedisSetOptions? { guard !args.isEmpty else { return nil } var options = RedisSetOptions() @@ -474,17 +534,43 @@ struct RedisCommandParser { let arg = args[i].uppercased() switch arg { case "EX": - if i + 1 < args.count, let seconds = Int(args[i + 1]) { - options.ex = seconds - hasOption = true - i += 1 + guard i + 1 < args.count else { + throw RedisParseError.missingArgument("EX requires a value") } + guard let seconds = Int(args[i + 1]) else { + throw RedisParseError.invalidArgument("EX value must be a positive integer") + } + options.ex = seconds + hasOption = true + i += 1 case "PX": - if i + 1 < args.count, let millis = Int(args[i + 1]) { - options.px = millis - hasOption = true - i += 1 + guard i + 1 < args.count else { + throw RedisParseError.missingArgument("PX requires a value") + } + guard let millis = Int(args[i + 1]) else { + throw RedisParseError.invalidArgument("PX value must be a positive integer") + } + options.px = millis + hasOption = true + i += 1 + case "EXAT": + guard i + 1 < args.count else { + throw RedisParseError.missingArgument("EXAT requires a value") + } + guard Int(args[i + 1]) != nil else { + throw RedisParseError.invalidArgument("EXAT value must be a positive integer") } + hasOption = true + i += 1 + case "PXAT": + guard i + 1 < args.count else { + throw RedisParseError.missingArgument("PXAT requires a value") + } + guard Int(args[i + 1]) != nil else { + throw RedisParseError.invalidArgument("PXAT value must be a positive integer") + } + hasOption = true + i += 1 case "NX": options.nx = true hasOption = true @@ -501,7 +587,7 @@ struct RedisCommandParser { } /// Parse SCAN options: MATCH pattern, COUNT count - private static func parseScanOptions(_ args: [String]) -> (pattern: String?, count: Int?) { + private static func parseScanOptions(_ args: [String]) throws -> (pattern: String?, count: Int?) { var pattern: String? var count: Int? var i = 0 @@ -515,10 +601,14 @@ struct RedisCommandParser { i += 1 } case "COUNT": - if i + 1 < args.count { - count = Int(args[i + 1]) - i += 1 + guard i + 1 < args.count else { + throw RedisParseError.missingArgument("COUNT requires a value") + } + guard let countVal = Int(args[i + 1]) else { + throw RedisParseError.invalidArgument("COUNT must be a positive integer") } + count = countVal + i += 1 default: break } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index ce8ea13a..539a8041 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -391,7 +391,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return k case .sadd(let k, _), .srem(let k, _): return k - case .zadd(let k, _), .zrem(let k, _): + case .zadd(let k, _, _), .zrem(let k, _): return k case .del(let keys) where keys.count == 1: return keys[0] @@ -832,14 +832,16 @@ private extension RedisPluginDriver { startTime: Date ) async throws -> PluginQueryResult { switch operation { - case .zrange(let key, let start, let stop, let withScores): - var args = ["ZRANGE", key, String(start), String(stop)] - if withScores { args.append("WITHSCORES") } + case .zrange(let key, let start, let stop, let flags): + var args = ["ZRANGE", key, start, stop] + args += flags + let withScores = flags.contains("WITHSCORES") let result = try await conn.executeCommand(args) return buildSortedSetResult(result, withScores: withScores, startTime: startTime) - case .zadd(let key, let scoreMembers): + case .zadd(let key, let flags, let scoreMembers): var args = ["ZADD", key] + args += flags for (score, member) in scoreMembers { args += [String(score), member] } diff --git a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift index e3619193..616cfe31 100644 --- a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift +++ b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift @@ -26,6 +26,11 @@ struct RedisStatementGenerator { columns.firstIndex(of: "Value") } + /// Index of the "Type" column + private var typeColumnIndex: Int? { + columns.firstIndex(of: "Type") + } + /// Index of the "TTL" column private var ttlColumnIndex: Int? { columns.firstIndex(of: "TTL") @@ -80,22 +85,27 @@ struct RedisStatementGenerator { var key: String? var value: String? + var type: String? var ttl: Int? if let values = insertedRowData[change.rowIndex] { if let ki = keyColumnIndex, ki < values.count { key = values[ki] } + if let ti = typeColumnIndex, ti < values.count { + type = values[ti] + } if let vi = valueColumnIndex, vi < values.count { value = values[vi] } - if let ti = ttlColumnIndex, ti < values.count, let ttlStr = values[ti] { + if let ttli = ttlColumnIndex, ttli < values.count, let ttlStr = values[ttli] { ttl = Int(ttlStr) } } else { for cellChange in change.cellChanges { switch cellChange.columnName { case "Key": key = cellChange.newValue + case "Type": type = cellChange.newValue case "Value": value = cellChange.newValue case "TTL": if let ttlStr = cellChange.newValue { ttl = Int(ttlStr) } @@ -110,7 +120,7 @@ struct RedisStatementGenerator { } let v = value ?? "" - let cmd = "SET \(escapeArgument(k)) \(escapeArgument(v))" + let cmd = generateInsertCommand(key: k, value: v, type: type?.lowercased()) statements.append((statement: cmd, parameters: [])) if let ttlSeconds = ttl, ttlSeconds > 0 { @@ -121,6 +131,31 @@ struct RedisStatementGenerator { return statements } + /// Generate the appropriate Redis command based on the data type + private func generateInsertCommand(key: String, value: String, type: String?) -> String { + switch type { + case "hash": + // Try to parse value as JSON object for HSET key field1 val1 ... + if let data = value.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + var args = "HSET \(escapeArgument(key))" + for (field, val) in json { + args += " \(escapeArgument(field)) \(escapeArgument(String(describing: val)))" + } + return args + } + return "HSET \(escapeArgument(key)) field \(escapeArgument(value))" + case "list": + return "RPUSH \(escapeArgument(key)) \(escapeArgument(value))" + case "set": + return "SADD \(escapeArgument(key)) \(escapeArgument(value))" + case "zset": + return "ZADD \(escapeArgument(key)) 0 \(escapeArgument(value))" + default: + return "SET \(escapeArgument(key)) \(escapeArgument(value))" + } + } + // MARK: - UPDATE private func generateUpdate(for change: PluginRowChange) -> [(statement: String, parameters: [String?])] { @@ -148,12 +183,30 @@ struct RedisStatementGenerator { return key }() + // Determine the Redis type from the original row data + let redisType: String? = { + guard let ti = typeColumnIndex, + let originalRow = change.originalRow, + ti < originalRow.count else { + return nil + } + return originalRow[ti] + }() + for cellChange in change.cellChanges { switch cellChange.columnName { case "Key": continue // Already handled above case "Value": if let newValue = cellChange.newValue { + let typeLower = redisType?.lowercased() ?? "string" + if typeLower != "string" { + // Non-string types show a preview; blindly SET would destroy the data structure + Self.logger.warning( + "Skipping Value update for \(typeLower) key '\(effectiveKey)' - use query editor" + ) + continue + } let cmd = "SET \(escapeArgument(effectiveKey)) \(escapeArgument(newValue))" statements.append((statement: cmd, parameters: [])) } @@ -187,14 +240,18 @@ struct RedisStatementGenerator { /// Escape a Redis argument for safe embedding in a command string. /// Wraps in double quotes if the value contains whitespace or special characters. + /// Ensures special characters round-trip correctly through the tokenizer. private func escapeArgument(_ value: String) -> String { - let needsQuoting = value.isEmpty || value.contains(where: { $0.isWhitespace || $0 == "\"" || $0 == "'" }) + let needsQuoting = value.isEmpty || value.contains(where: { + $0.isWhitespace || $0 == "\"" || $0 == "'" || $0 == "\\" || $0 == "\n" || $0 == "\r" || $0 == "\t" + }) if needsQuoting { let escaped = value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") return "\"\(escaped)\"" } return value From 67114e120f7e2c827e5fdcf76f49c50ccae163be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 15:59:59 +0700 Subject: [PATCH 09/12] fix(redis): pipeline value previews to eliminate serial round-trips --- .../RedisDriverPlugin/RedisPluginDriver.swift | 136 +++++++++++++----- 1 file changed, 103 insertions(+), 33 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 539a8041..ccab890d 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -19,6 +19,9 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static let maxScanKeys = PluginRowLimits.defaultMax + private var cachedScanPattern: String? + private var cachedScanKeys: [String]? + var serverVersion: String? { redisConnection?.serverVersion() } @@ -55,6 +58,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func disconnect() { redisConnection?.disconnect() redisConnection = nil + cachedScanPattern = nil + cachedScanKeys = nil } func ping() async throws { @@ -71,6 +76,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func execute(query: String) async throws -> PluginQueryResult { let startTime = Date() + cachedScanPattern = nil + cachedScanKeys = nil guard let conn = redisConnection else { throw RedisPluginError.notConnected @@ -140,7 +147,17 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { switch operation { case .scan(_, let pattern, _): - let allKeys = try await scanAllKeys(connection: conn, pattern: pattern, maxKeys: Self.maxScanKeys) + let cacheKey = pattern ?? "*" + let allKeys: [String] + if cachedScanPattern == cacheKey, let cached = cachedScanKeys { + allKeys = cached + } else { + allKeys = try await scanAllKeys( + connection: conn, pattern: pattern, maxKeys: Self.maxScanKeys + ) + cachedScanPattern = cacheKey + cachedScanKeys = allKeys + } let pageEnd = min(offset + limit, allKeys.count) guard offset < allKeys.count else { return buildEmptyKeyResult(startTime: startTime) @@ -1088,22 +1105,59 @@ private extension RedisPluginDriver { return buildEmptyKeyResult(startTime: startTime) } - var commands: [[String]] = [] - commands.reserveCapacity(keys.count * 2) + var typeAndTtlCommands: [[String]] = [] + typeAndTtlCommands.reserveCapacity(keys.count * 2) for key in keys { - commands.append(["TYPE", key]) - commands.append(["TTL", key]) + typeAndTtlCommands.append(["TYPE", key]) + typeAndTtlCommands.append(["TTL", key]) + } + let typeAndTtlReplies = try await conn.executePipeline(typeAndTtlCommands) + + var typeNames: [String] = [] + typeNames.reserveCapacity(keys.count) + var ttlValues: [Int] = [] + ttlValues.reserveCapacity(keys.count) + for i in 0 ..< keys.count { + let typeName = (typeAndTtlReplies[i * 2].stringValue ?? "unknown").uppercased() + let ttl = typeAndTtlReplies[i * 2 + 1].intValue ?? -1 + typeNames.append(typeName) + ttlValues.append(ttl) } - let replies = try await conn.executePipeline(commands) - var rows: [[String?]] = [] + var previewCommands: [[String]] = [] + previewCommands.reserveCapacity(keys.count) + var previewCommandIndices: [Int] = [] + previewCommandIndices.reserveCapacity(keys.count) + for (i, key) in keys.enumerated() { - let typeName = (replies[i * 2].stringValue ?? "unknown").uppercased() - let ttl = replies[i * 2 + 1].intValue ?? -1 - let ttlStr = String(ttl) + let command: [String]? = previewCommandForType(typeNames[i], key: key) + if let command { + previewCommandIndices.append(previewCommands.count) + previewCommands.append(command) + } else { + previewCommandIndices.append(-1) + } + } + + var previewReplies: [RedisReply] = [] + if !previewCommands.isEmpty { + previewReplies = try await conn.executePipeline(previewCommands) + } - let value = try await fetchValuePreview(key: key, type: typeName, connection: conn) - rows.append([key, typeName, ttlStr, value]) + var rows: [[String?]] = [] + rows.reserveCapacity(keys.count) + for (i, key) in keys.enumerated() { + let ttlStr = String(ttlValues[i]) + let pipelineIndex = previewCommandIndices[i] + let preview: String? + if pipelineIndex >= 0, pipelineIndex < previewReplies.count { + preview = formatPreviewReply( + previewReplies[pipelineIndex], type: typeNames[i] + ) + } else { + preview = nil + } + rows.append([key, typeNames[i], ttlStr, preview]) } return PluginQueryResult( @@ -1116,47 +1170,64 @@ private extension RedisPluginDriver { ) } - func fetchValuePreview(key: String, type: String, connection conn: RedisPluginConnection) async throws -> String? { + func previewCommandForType(_ type: String, key: String) -> [String]? { switch type.lowercased() { case "string": - let result = try await conn.executeCommand(["GET", key]) - return truncatePreview(result.stringValue) + return ["GET", key] + case "hash": + return ["HSCAN", key, "0", "COUNT", String(Self.previewLimit)] + case "list": + return ["LRANGE", key, "0", String(Self.previewLimit - 1)] + case "set": + return ["SSCAN", key, "0", "COUNT", String(Self.previewLimit)] + case "zset": + return ["ZRANGE", key, "0", String(Self.previewLimit - 1)] + case "stream": + return ["XLEN", key] + default: + return nil + } + } + + func formatPreviewReply(_ reply: RedisReply, type: String) -> String? { + switch type.lowercased() { + case "string": + return truncatePreview(reply.stringValue) case "hash": - let result = try await conn.executeCommand(["HSCAN", key, "0", "COUNT", String(Self.previewLimit)]) let array: [String] - if case .array(let scanResult) = result, + if case .array(let scanResult) = reply, scanResult.count == 2, let items = scanResult[1].stringArrayValue { array = items - } else if let items = result.stringArrayValue, !items.isEmpty { + } else if let items = reply.stringArrayValue, !items.isEmpty { array = items } else { return "{}" } guard !array.isEmpty else { return "{}" } var pairs: [String] = [] - var i = 0 - while i + 1 < array.count { - pairs.append("\"\(escapeJsonString(array[i]))\":\"\(escapeJsonString(array[i + 1]))\"") - i += 2 + var idx = 0 + while idx + 1 < array.count { + pairs.append( + "\"\(escapeJsonString(array[idx]))\":\"\(escapeJsonString(array[idx + 1]))\"" + ) + idx += 2 } return truncatePreview("{\(pairs.joined(separator: ","))}") case "list": - let result = try await conn.executeCommand(["LRANGE", key, "0", String(Self.previewLimit - 1)]) - guard let items = result.stringArrayValue else { return "[]" } + guard let items = reply.stringArrayValue else { return "[]" } let quoted = items.map { "\"\(escapeJsonString($0))\"" } return truncatePreview("[\(quoted.joined(separator: ", "))]") case "set": - let result = try await conn.executeCommand(["SSCAN", key, "0", "COUNT", String(Self.previewLimit)]) let members: [String] - if case .array(let scanResult) = result, + if case .array(let scanResult) = reply, scanResult.count == 2, let items = scanResult[1].stringArrayValue { members = items - } else if let items = result.stringArrayValue { + } else if let items = reply.stringArrayValue { members = items } else { return "[]" @@ -1165,14 +1236,12 @@ private extension RedisPluginDriver { return truncatePreview("[\(quoted.joined(separator: ", "))]") case "zset": - let result = try await conn.executeCommand(["ZRANGE", key, "0", String(Self.previewLimit - 1)]) - guard let members = result.stringArrayValue else { return "[]" } + guard let members = reply.stringArrayValue else { return "[]" } let quoted = members.map { "\"\(escapeJsonString($0))\"" } return truncatePreview("[\(quoted.joined(separator: ", "))]") case "stream": - let lenResult = try await conn.executeCommand(["XLEN", key]) - let len = lenResult.intValue ?? 0 + let len = reply.intValue ?? 0 return "(\(len) entries)" default: @@ -1182,8 +1251,9 @@ private extension RedisPluginDriver { func truncatePreview(_ value: String?) -> String? { guard let value else { return nil } - if value.count > Self.previewMaxChars { - return String(value.prefix(Self.previewMaxChars)) + "..." + let nsValue = value as NSString + if nsValue.length > Self.previewMaxChars { + return nsValue.substring(to: Self.previewMaxChars) + "..." } return value } From 11ff0bd547f9f8f434d11ab2d890f0df0f316e1a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 16:08:42 +0700 Subject: [PATCH 10/12] fix(redis): fix coordinator integration for non-SQL database behavior --- .../RedisDriverPlugin/RedisPluginDriver.swift | 92 +++++++++++++------ .../RedisDriverPlugin/RedisQueryBuilder.swift | 7 +- .../MainContentCoordinator+Navigation.swift | 12 ++- ...MainContentCoordinator+QueryAnalysis.swift | 44 ++++++++- .../MainContentCoordinator+QueryHelpers.swift | 63 +++++++++---- .../MainContentCoordinator+Redis.swift | 6 ++ .../MainContentCoordinator+SaveChanges.swift | 16 +++- .../Views/Main/MainContentCoordinator.swift | 1 + 8 files changed, 189 insertions(+), 52 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index ccab890d..d883f12f 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -187,33 +187,41 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw RedisPluginError.notConnected } + // Parse key counts from INFO keyspace let result = try await conn.executeCommand(["INFO", "keyspace"]) - guard let info = result.stringValue else { return [] } + var keyCounts: [String: Int] = [:] + if let info = result.stringValue { + for line in info.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("db"), + let colonIndex = trimmed.firstIndex(of: ":") else { continue } - var databases: [PluginTableInfo] = [] - for line in info.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("db"), - let colonIndex = trimmed.firstIndex(of: ":") else { continue } - - let dbName = String(trimmed[trimmed.startIndex ..< colonIndex]) - let statsStr = String(trimmed[trimmed.index(after: colonIndex)...]) - - var keyCount = 0 - for stat in statsStr.components(separatedBy: ",") { - let parts = stat.components(separatedBy: "=") - if parts.count == 2, parts[0] == "keys", let count = Int(parts[1]) { - keyCount = count - break + let dbName = String(trimmed[trimmed.startIndex ..< colonIndex]) + let statsStr = String(trimmed[trimmed.index(after: colonIndex)...]) + + for stat in statsStr.components(separatedBy: ",") { + let parts = stat.components(separatedBy: "=") + if parts.count == 2, parts[0] == "keys", let count = Int(parts[1]) { + keyCounts[dbName] = count + break + } } } + } - if keyCount > 0 { - databases.append(PluginTableInfo(name: dbName, type: "TABLE", rowCount: keyCount)) - } + // Get total database count from CONFIG GET databases + let configResult = try await conn.executeCommand(["CONFIG", "GET", "databases"]) + var maxDatabases = 16 + if let array = configResult.stringArrayValue, array.count >= 2, let count = Int(array[1]) { + maxDatabases = count } - return databases + // Return all databases (including empty ones) so users can navigate to them + return (0 ..< maxDatabases).map { index in + let dbName = "db\(index)" + let keyCount = keyCounts[dbName] ?? 0 + return PluginTableInfo(name: dbName, type: "TABLE", rowCount: keyCount) + } } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { @@ -309,7 +317,15 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchDatabases() async throws -> [String] { - [] + guard let conn = redisConnection else { + throw RedisPluginError.notConnected + } + let result = try await conn.executeCommand(["CONFIG", "GET", "databases"]) + var maxDatabases = 16 + if let array = result.stringArrayValue, array.count >= 2, let count = Int(array[1]) { + maxDatabases = count + } + return (0 ..< maxDatabases).map { "db\($0)" } } func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { @@ -377,12 +393,28 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func switchDatabase(to database: String) async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - guard let dbIndex = Int(database) ?? Int(database.dropFirst(2)) else { + let dbIndex: Int + if let idx = Int(database) { + dbIndex = idx + } else if database.lowercased().hasPrefix("db"), let idx = Int(database.dropFirst(2)) { + dbIndex = idx + } else { throw RedisPluginError(code: 0, message: "Invalid database index: \(database)") } try await conn.selectDatabase(dbIndex) } + // MARK: - Table Operations + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + ["FLUSHDB"] + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + // Redis databases are pre-allocated and cannot be dropped + nil + } + // MARK: - EXPLAIN func buildExplainQuery(_ sql: String) -> String? { @@ -662,7 +694,10 @@ private extension RedisPluginDriver { return buildStatusResult(success ? "OK" : "Key not found or no TTL", startTime: startTime) case .rename(let key, let newKey): - _ = try await conn.executeCommand(["RENAME", key, newKey]) + let reply = try await conn.executeCommand(["RENAME", key, newKey]) + if case .error(let msg) = reply { + throw RedisPluginError(code: 0, message: "RENAME failed: \(msg)") + } return buildStatusResult("OK", startTime: startTime) case .exists(let keys): @@ -1260,14 +1295,19 @@ private extension RedisPluginDriver { func escapeJsonString(_ str: String) -> String { var result = "" - for char in str { - switch char { + for scalar in str.unicodeScalars { + switch scalar { case "\\": result += "\\\\" case "\"": result += "\\\"" case "\n": result += "\\n" case "\r": result += "\\r" case "\t": result += "\\t" - default: result.append(char) + default: + if scalar.value < 0x20 { + result += String(format: "\\u%04X", scalar.value) + } else { + result += String(scalar) + } } } return result diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index d336a955..78371993 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -59,12 +59,15 @@ struct RedisQueryBuilder { return "SCAN 0 MATCH \"\(pattern)\" COUNT \(limit)" } - /// Build a count command for a namespace + /// Build a count command for a namespace. + /// When a namespace filter is active, DBSIZE would overcount because it + /// returns the total key count for the entire database. We use a SCAN-based + /// approach instead; note the returned count is approximate since SCAN may + /// return duplicates across iterations and new keys may appear mid-scan. func buildCountQuery(namespace: String) -> String { if namespace.isEmpty { return "DBSIZE" } - // For a specific namespace, we use SCAN to count matching keys return "SCAN 0 MATCH \"\(namespace)*\" COUNT 10000" } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index adf5c18b..f89ce488 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -431,18 +431,26 @@ extension MainContentCoordinator { /// Select a Redis database index and then run the query. /// Redis sidebar clicks go through openTableTab (sync), so we need a Task /// to call the async selectDatabase before executing the query. + /// Cancels any previous in-flight switch to prevent race conditions + /// from rapid sidebar clicks. private func selectRedisDatabaseAndQuery(_ dbIndex: Int) { + cancelRedisDatabaseSwitchTask() + let connId = connectionId let database = String(dbIndex) - Task { @MainActor in + redisDatabaseSwitchTask = Task { @MainActor [weak self] in + guard let self else { return } do { if let adapter = DatabaseManager.shared.driver(for: connId) as? PluginDriverAdapter { try await adapter.switchDatabase(to: String(dbIndex)) } } catch { - navigationLogger.error("Failed to SELECT Redis db\(dbIndex): \(error.localizedDescription, privacy: .public)") + if !Task.isCancelled { + navigationLogger.error("Failed to SELECT Redis db\(dbIndex): \(error.localizedDescription, privacy: .public)") + } return } + guard !Task.isCancelled else { return } DatabaseManager.shared.updateSession(connId) { session in session.currentDatabase = database } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryAnalysis.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryAnalysis.swift index 9b230528..ab934f95 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryAnalysis.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryAnalysis.swift @@ -17,9 +17,36 @@ extension MainContentCoordinator { "RENAME ", "GRANT ", "REVOKE ", ] + /// Redis commands that modify data + private static let redisWriteCommands: Set = [ + "SET", "DEL", "HSET", "HDEL", "HMSET", "LPUSH", "RPUSH", "LPOP", "RPOP", + "SADD", "SREM", "ZADD", "ZREM", "EXPIRE", "PERSIST", "RENAME", + "FLUSHDB", "FLUSHALL", "MSET", "APPEND", "INCR", "DECR", "INCRBY", + "DECRBY", "SETEX", "PSETEX", "SETNX", "GETSET", "GETDEL", + "XADD", "XTRIM", "XDEL", + ] + + /// Redis commands that are destructive + private static let redisDangerousCommands: Set = [ + "FLUSHDB", "FLUSHALL", "DEBUG", "SHUTDOWN", + ] + /// Check if a SQL statement is a write operation (modifies data or schema) func isWriteQuery(_ sql: String) -> Bool { - let uppercased = sql.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + + // Redis: check the first token against known write commands + if connection.type == .redis { + let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() + // CONFIG SET is a write; plain CONFIG GET is not + if firstToken == "CONFIG" { + let rest = trimmed.dropFirst(firstToken.count).trimmingCharacters(in: .whitespaces) + return rest.uppercased().hasPrefix("SET") + } + return Self.redisWriteCommands.contains(firstToken) + } + + let uppercased = trimmed.uppercased() return Self.writeQueryPrefixes.contains { uppercased.hasPrefix($0) } } @@ -30,7 +57,20 @@ extension MainContentCoordinator { /// Check if a query is potentially dangerous (DROP, TRUNCATE, DELETE without WHERE) func isDangerousQuery(_ sql: String) -> Bool { - let uppercased = sql.uppercased().trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + + // Redis: check for destructive commands + if connection.type == .redis { + let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() + // CONFIG SET is dangerous + if firstToken == "CONFIG" { + let rest = trimmed.dropFirst(firstToken.count).trimmingCharacters(in: .whitespaces) + return rest.uppercased().hasPrefix("SET") + } + return Self.redisDangerousCommands.contains(firstToken) + } + + let uppercased = trimmed.uppercased() // Check for DROP if uppercased.hasPrefix("DROP ") { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 7c7b91d0..271652d6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -196,30 +196,46 @@ extension MainContentCoordinator { connectionType: DatabaseType, schemaResult: SchemaResult? ) { + let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql + + // Phase 2a: Exact row count + // Redis/non-SQL drivers don't support SELECT COUNT(*); use approximate count instead. Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: 200_000_000) guard !self.isTearingDown else { return } guard let mainDriver = DatabaseManager.shared.driver(for: connectionId) else { return } - let quotedTable = mainDriver.quoteIdentifier(tableName) - let countResult = try? await mainDriver.execute( - query: "SELECT COUNT(*) FROM \(quotedTable)" - ) - if let firstRow = countResult?.rows.first, - let countStr = firstRow.first ?? nil, - let count = Int(countStr) { + + let count: Int? + if isNonSQL { + count = try? await mainDriver.fetchApproximateRowCount(table: tableName) + } else { + let quotedTable = mainDriver.quoteIdentifier(tableName) + let countResult = try? await mainDriver.execute( + query: "SELECT COUNT(*) FROM \(quotedTable)" + ) + if let firstRow = countResult?.rows.first, + let countStr = firstRow.first ?? nil { + count = Int(countStr) + } else { + count = nil + } + } + + if let count { await MainActor.run { [weak self] in guard let self else { return } guard capturedGeneration == queryGeneration else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].pagination.totalRowCount = count - tabManager.tabs[idx].pagination.isApproximateRowCount = false + tabManager.tabs[idx].pagination.isApproximateRowCount = isNonSQL } } } } - // Phase 2b: Fetch enum/set values + // Phase 2b: Fetch enum/set values (not applicable for non-SQL databases) + guard !isNonSQL else { return } guard let enumDriver = DatabaseManager.shared.driver(for: connectionId) else { return } Task { [weak self] in guard let self else { return } @@ -270,21 +286,34 @@ extension MainContentCoordinator { capturedGeneration: Int, connectionType: DatabaseType ) { + let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql + Task { [weak self] in guard let self else { return } guard let mainDriver = DatabaseManager.shared.driver(for: connectionId) else { return } - let quotedTable = mainDriver.quoteIdentifier(tableName) - let countResult = try? await mainDriver.execute( - query: "SELECT COUNT(*) FROM \(quotedTable)" - ) - if let firstRow = countResult?.rows.first, - let countStr = firstRow.first ?? nil, - let count = Int(countStr) { + + let count: Int? + if isNonSQL { + count = try? await mainDriver.fetchApproximateRowCount(table: tableName) + } else { + let quotedTable = mainDriver.quoteIdentifier(tableName) + let countResult = try? await mainDriver.execute( + query: "SELECT COUNT(*) FROM \(quotedTable)" + ) + if let firstRow = countResult?.rows.first, + let countStr = firstRow.first ?? nil { + count = Int(countStr) + } else { + count = nil + } + } + + if let count { await MainActor.run { [weak self] in guard let self else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].pagination.totalRowCount = count - tabManager.tabs[idx].pagination.isApproximateRowCount = false + tabManager.tabs[idx].pagination.isApproximateRowCount = isNonSQL } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift index 82a820ff..782b5186 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Redis.swift @@ -8,4 +8,10 @@ import Foundation extension MainContentCoordinator { + /// Cancel any in-flight Redis database switch task to prevent race conditions + /// from rapid sidebar clicks. + func cancelRedisDatabaseSwitchTask() { + redisDatabaseSwitchTask?.cancel() + redisDatabaseSwitchTask = nil + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 0d983571..d194aa90 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -187,7 +187,13 @@ extension MainContentCoordinator { throw DatabaseError.notConnected } - try await driver.beginTransaction() + // Redis MULTI/EXEC is not a true transaction (no rollback on failure), + // so execute statements individually without wrapping. + let useTransaction = dbType != .redis + + if useTransaction { + try await driver.beginTransaction() + } do { for statement in validStatements { @@ -212,9 +218,13 @@ extension MainContentCoordinator { ) } - try await driver.commitTransaction() + if useTransaction { + try await driver.commitTransaction() + } } catch { - try? await driver.rollbackTransaction() + if useTransaction { + try? await driver.rollbackTransaction() + } throw error } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index af45d32f..34408a42 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -98,6 +98,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var queryGeneration: Int = 0 @ObservationIgnored internal var currentQueryTask: Task? + @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? From 1d9e8a77d45070f3d09414fafe5df4cb650e2567 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 16:09:24 +0700 Subject: [PATCH 11/12] feat(redis): add missing commands and improve data type previews --- .../RedisCommandParser.swift | 523 ++++++++++++++++-- Plugins/RedisDriverPlugin/RedisPlugin.swift | 92 ++- .../RedisPluginConnection.swift | 9 + .../RedisDriverPlugin/RedisPluginDriver.swift | 50 +- .../RedisDriverPlugin/RedisQueryBuilder.swift | 2 +- 5 files changed, 607 insertions(+), 69 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 1bbe86fa..5c53dcb3 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -112,26 +112,41 @@ struct RedisCommandParser { switch command { case "GET", "SET", "DEL", "KEYS", "SCAN", "TYPE", "TTL", "PTTL", - "EXPIRE", "PERSIST", "RENAME", "EXISTS": - return try parseKeyCommand(command, args: args) - - case "HGET", "HSET", "HGETALL", "HDEL": - return try parseHashCommand(command, args: args) - - case "LRANGE", "LPUSH", "RPUSH", "LLEN": - return try parseListCommand(command, args: args) - - case "SMEMBERS", "SADD", "SREM", "SCARD": - return try parseSetCommand(command, args: args) - - case "ZRANGE", "ZADD", "ZREM", "ZCARD": - return try parseSortedSetCommand(command, args: args) - - case "XRANGE", "XLEN": - return try parseStreamCommand(command, args: args) - - case "PING", "INFO", "DBSIZE", "FLUSHDB", "SELECT", "CONFIG", - "MULTI", "EXEC", "DISCARD": + "EXPIRE", "PEXPIRE", "EXPIREAT", "PEXPIREAT", + "PERSIST", "RENAME", "EXISTS", + "GETSET", "GETDEL", "GETEX", + "MGET", "MSET", + "INCR", "DECR", "INCRBY", "DECRBY", "INCRBYFLOAT", + "APPEND": + return try parseKeyCommand(command, args: args, tokens: tokens) + + case "HGET", "HSET", "HGETALL", "HDEL", "HSCAN": + return try parseHashCommand(command, args: args, tokens: tokens) + + case "LRANGE", "LPUSH", "RPUSH", "LLEN", + "LPOP", "RPOP", "LSET", "LINSERT", "LREM", "LPOS", "LMOVE": + return try parseListCommand(command, args: args, tokens: tokens) + + case "SMEMBERS", "SADD", "SREM", "SCARD", + "SPOP", "SRANDMEMBER", "SMOVE", + "SUNION", "SINTER", "SDIFF", + "SUNIONSTORE", "SINTERSTORE", "SDIFFSTORE", + "SSCAN": + return try parseSetCommand(command, args: args, tokens: tokens) + + case "ZRANGE", "ZADD", "ZREM", "ZCARD", + "ZSCORE", "ZRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYSCORE", + "ZINCRBY", "ZCOUNT", "ZRANK", "ZREVRANK", + "ZPOPMIN", "ZPOPMAX", + "ZSCAN": + return try parseSortedSetCommand(command, args: args, tokens: tokens) + + case "XRANGE", "XLEN", "XADD", "XREAD", "XREVRANGE", "XDEL", + "XTRIM", "XINFO", "XGROUP", "XACK": + return try parseStreamCommand(command, args: args, tokens: tokens) + + case "PING", "INFO", "DBSIZE", "FLUSHDB", "FLUSHALL", "SELECT", "CONFIG", + "MULTI", "EXEC", "DISCARD", "AUTH", "OBJECT": return try parseServerCommand(command, args: args, tokens: tokens) default: @@ -141,7 +156,9 @@ struct RedisCommandParser { // MARK: - Key Commands - private static func parseKeyCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseKeyCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "GET": guard args.count >= 1 else { throw RedisParseError.missingArgument("GET requires a key") } @@ -184,8 +201,39 @@ struct RedisCommandParser { guard let seconds = Int(args[1]) else { throw RedisParseError.invalidArgument("EXPIRE seconds must be an integer") } + // Redis 7.0+ supports optional NX|XX|GT|LT flags; pass through as raw command + if args.count > 2 { + return .command(args: tokens) + } return .expire(key: args[0], seconds: seconds) + case "PEXPIRE": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("PEXPIRE requires key and milliseconds") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("PEXPIRE milliseconds must be an integer") + } + return .command(args: tokens) + + case "EXPIREAT": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("EXPIREAT requires key and timestamp") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("EXPIREAT timestamp must be an integer") + } + return .command(args: tokens) + + case "PEXPIREAT": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("PEXPIREAT requires key and milliseconds-timestamp") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("PEXPIREAT milliseconds-timestamp must be an integer") + } + return .command(args: tokens) + case "PERSIST": guard args.count >= 1 else { throw RedisParseError.missingArgument("PERSIST requires a key") } return .persist(key: args[0]) @@ -198,14 +246,73 @@ struct RedisCommandParser { guard !args.isEmpty else { throw RedisParseError.missingArgument("EXISTS requires at least one key") } return .exists(keys: args) + case "GETSET": + guard args.count >= 2 else { throw RedisParseError.missingArgument("GETSET requires key and value") } + return .command(args: tokens) + + case "GETDEL": + guard args.count >= 1 else { throw RedisParseError.missingArgument("GETDEL requires a key") } + return .command(args: tokens) + + case "GETEX": + guard args.count >= 1 else { throw RedisParseError.missingArgument("GETEX requires a key") } + return .command(args: tokens) + + case "MGET": + guard !args.isEmpty else { throw RedisParseError.missingArgument("MGET requires at least one key") } + return .command(args: tokens) + + case "MSET": + guard args.count >= 2, args.count % 2 == 0 else { + throw RedisParseError.missingArgument("MSET requires key value pairs") + } + return .command(args: tokens) + + case "INCR": + guard args.count >= 1 else { throw RedisParseError.missingArgument("INCR requires a key") } + return .command(args: tokens) + + case "DECR": + guard args.count >= 1 else { throw RedisParseError.missingArgument("DECR requires a key") } + return .command(args: tokens) + + case "INCRBY": + guard args.count >= 2 else { throw RedisParseError.missingArgument("INCRBY requires key and increment") } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("INCRBY increment must be an integer") + } + return .command(args: tokens) + + case "DECRBY": + guard args.count >= 2 else { throw RedisParseError.missingArgument("DECRBY requires key and decrement") } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("DECRBY decrement must be an integer") + } + return .command(args: tokens) + + case "INCRBYFLOAT": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("INCRBYFLOAT requires key and increment") + } + guard Double(args[1]) != nil else { + throw RedisParseError.invalidArgument("INCRBYFLOAT increment must be a number") + } + return .command(args: tokens) + + case "APPEND": + guard args.count >= 2 else { throw RedisParseError.missingArgument("APPEND requires key and value") } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown key command: \(command)") + return .command(args: tokens) } } // MARK: - Hash Commands - private static func parseHashCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseHashCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "HGET": guard args.count >= 2 else { throw RedisParseError.missingArgument("HGET requires key and field") } @@ -228,70 +335,223 @@ struct RedisCommandParser { return .hgetall(key: args[0]) case "HDEL": - guard args.count >= 2 else { throw RedisParseError.missingArgument("HDEL requires key and at least one field") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("HDEL requires key and at least one field") + } return .hdel(key: args[0], fields: Array(args.dropFirst())) + case "HSCAN": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("HSCAN requires key and cursor") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("HSCAN cursor must be an integer") + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown hash command: \(command)") + return .command(args: tokens) } } // MARK: - List Commands - private static func parseListCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseListCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "LRANGE": - guard args.count >= 3 else { throw RedisParseError.missingArgument("LRANGE requires key, start, and stop") } + guard args.count >= 3 else { + throw RedisParseError.missingArgument("LRANGE requires key, start, and stop") + } guard let start = Int(args[1]), let stop = Int(args[2]) else { throw RedisParseError.invalidArgument("LRANGE start and stop must be integers") } return .lrange(key: args[0], start: start, stop: stop) case "LPUSH": - guard args.count >= 2 else { throw RedisParseError.missingArgument("LPUSH requires key and at least one value") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("LPUSH requires key and at least one value") + } return .lpush(key: args[0], values: Array(args.dropFirst())) case "RPUSH": - guard args.count >= 2 else { throw RedisParseError.missingArgument("RPUSH requires key and at least one value") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("RPUSH requires key and at least one value") + } return .rpush(key: args[0], values: Array(args.dropFirst())) case "LLEN": guard args.count >= 1 else { throw RedisParseError.missingArgument("LLEN requires a key") } return .llen(key: args[0]) + case "LPOP": + guard args.count >= 1 else { throw RedisParseError.missingArgument("LPOP requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("LPOP count must be an integer") + } + } + return .command(args: tokens) + + case "RPOP": + guard args.count >= 1 else { throw RedisParseError.missingArgument("RPOP requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("RPOP count must be an integer") + } + } + return .command(args: tokens) + + case "LSET": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("LSET requires key, index, and element") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("LSET index must be an integer") + } + return .command(args: tokens) + + case "LINSERT": + guard args.count >= 4 else { + throw RedisParseError.missingArgument("LINSERT requires key, BEFORE|AFTER, pivot, and element") + } + let position = args[1].uppercased() + guard position == "BEFORE" || position == "AFTER" else { + throw RedisParseError.invalidArgument("LINSERT position must be BEFORE or AFTER") + } + return .command(args: tokens) + + case "LREM": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("LREM requires key, count, and element") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("LREM count must be an integer") + } + return .command(args: tokens) + + case "LPOS": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("LPOS requires key and element") + } + return .command(args: tokens) + + case "LMOVE": + guard args.count >= 4 else { + throw RedisParseError.missingArgument("LMOVE requires source, destination, LEFT|RIGHT, LEFT|RIGHT") + } + let dir1 = args[2].uppercased() + let dir2 = args[3].uppercased() + guard (dir1 == "LEFT" || dir1 == "RIGHT") && (dir2 == "LEFT" || dir2 == "RIGHT") else { + throw RedisParseError.invalidArgument("LMOVE directions must be LEFT or RIGHT") + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown list command: \(command)") + return .command(args: tokens) } } // MARK: - Set Commands - private static func parseSetCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseSetCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "SMEMBERS": guard args.count >= 1 else { throw RedisParseError.missingArgument("SMEMBERS requires a key") } return .smembers(key: args[0]) case "SADD": - guard args.count >= 2 else { throw RedisParseError.missingArgument("SADD requires key and at least one member") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SADD requires key and at least one member") + } return .sadd(key: args[0], members: Array(args.dropFirst())) case "SREM": - guard args.count >= 2 else { throw RedisParseError.missingArgument("SREM requires key and at least one member") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SREM requires key and at least one member") + } return .srem(key: args[0], members: Array(args.dropFirst())) case "SCARD": guard args.count >= 1 else { throw RedisParseError.missingArgument("SCARD requires a key") } return .scard(key: args[0]) + case "SPOP": + guard args.count >= 1 else { throw RedisParseError.missingArgument("SPOP requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("SPOP count must be an integer") + } + } + return .command(args: tokens) + + case "SRANDMEMBER": + guard args.count >= 1 else { throw RedisParseError.missingArgument("SRANDMEMBER requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("SRANDMEMBER count must be an integer") + } + } + return .command(args: tokens) + + case "SMOVE": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("SMOVE requires source, destination, and member") + } + return .command(args: tokens) + + case "SUNION": + guard !args.isEmpty else { throw RedisParseError.missingArgument("SUNION requires at least one key") } + return .command(args: tokens) + + case "SINTER": + guard !args.isEmpty else { throw RedisParseError.missingArgument("SINTER requires at least one key") } + return .command(args: tokens) + + case "SDIFF": + guard !args.isEmpty else { throw RedisParseError.missingArgument("SDIFF requires at least one key") } + return .command(args: tokens) + + case "SUNIONSTORE": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SUNIONSTORE requires destination and at least one key") + } + return .command(args: tokens) + + case "SINTERSTORE": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SINTERSTORE requires destination and at least one key") + } + return .command(args: tokens) + + case "SDIFFSTORE": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SDIFFSTORE requires destination and at least one key") + } + return .command(args: tokens) + + case "SSCAN": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("SSCAN requires key and cursor") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("SSCAN cursor must be an integer") + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown set command: \(command)") + return .command(args: tokens) } } // MARK: - Sorted Set Commands - private static func parseSortedSetCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseSortedSetCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "ZRANGE": guard args.count >= 3 else { throw RedisParseError.missingArgument("ZRANGE requires key, start, and stop") } @@ -347,24 +607,111 @@ struct RedisCommandParser { return .zadd(key: args[0], flags: collectedFlags, scoreMembers: scoreMembers) case "ZREM": - guard args.count >= 2 else { throw RedisParseError.missingArgument("ZREM requires key and at least one member") } + guard args.count >= 2 else { + throw RedisParseError.missingArgument("ZREM requires key and at least one member") + } return .zrem(key: args[0], members: Array(args.dropFirst())) case "ZCARD": guard args.count >= 1 else { throw RedisParseError.missingArgument("ZCARD requires a key") } return .zcard(key: args[0]) + case "ZSCORE": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("ZSCORE requires key and member") + } + return .command(args: tokens) + + case "ZRANGEBYSCORE": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("ZRANGEBYSCORE requires key, min, and max") + } + return .command(args: tokens) + + case "ZREVRANGE": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("ZREVRANGE requires key, start, and stop") + } + guard Int(args[1]) != nil, Int(args[2]) != nil else { + throw RedisParseError.invalidArgument("ZREVRANGE start and stop must be integers") + } + return .command(args: tokens) + + case "ZREVRANGEBYSCORE": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("ZREVRANGEBYSCORE requires key, max, and min") + } + return .command(args: tokens) + + case "ZINCRBY": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("ZINCRBY requires key, increment, and member") + } + guard Double(args[1]) != nil else { + throw RedisParseError.invalidArgument("ZINCRBY increment must be a number") + } + return .command(args: tokens) + + case "ZCOUNT": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("ZCOUNT requires key, min, and max") + } + return .command(args: tokens) + + case "ZRANK": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("ZRANK requires key and member") + } + return .command(args: tokens) + + case "ZREVRANK": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("ZREVRANK requires key and member") + } + return .command(args: tokens) + + case "ZPOPMIN": + guard args.count >= 1 else { throw RedisParseError.missingArgument("ZPOPMIN requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("ZPOPMIN count must be an integer") + } + } + return .command(args: tokens) + + case "ZPOPMAX": + guard args.count >= 1 else { throw RedisParseError.missingArgument("ZPOPMAX requires a key") } + if args.count >= 2 { + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("ZPOPMAX count must be an integer") + } + } + return .command(args: tokens) + + case "ZSCAN": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("ZSCAN requires key and cursor") + } + guard Int(args[1]) != nil else { + throw RedisParseError.invalidArgument("ZSCAN cursor must be an integer") + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown sorted set command: \(command)") + return .command(args: tokens) } } // MARK: - Stream Commands - private static func parseStreamCommand(_ command: String, args: [String]) throws -> RedisOperation { + private static func parseStreamCommand( + _ command: String, args: [String], tokens: [String] + ) throws -> RedisOperation { switch command { case "XRANGE": - guard args.count >= 3 else { throw RedisParseError.missingArgument("XRANGE requires key, start, and end") } + guard args.count >= 3 else { + throw RedisParseError.missingArgument("XRANGE requires key, start, and end") + } var count: Int? if args.count >= 5, args[3].uppercased() == "COUNT" { count = Int(args[4]) @@ -375,8 +722,74 @@ struct RedisCommandParser { guard args.count >= 1 else { throw RedisParseError.missingArgument("XLEN requires a key") } return .xlen(key: args[0]) + case "XADD": + // XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold] *|ID field value [field value ...] + guard args.count >= 4 else { + throw RedisParseError.missingArgument("XADD requires key, ID, and at least one field-value pair") + } + return .command(args: tokens) + + case "XREAD": + // XREAD [COUNT count] [BLOCK ms] STREAMS key [key ...] ID [ID ...] + guard args.count >= 3 else { + throw RedisParseError.missingArgument("XREAD requires STREAMS keyword, at least one key, and an ID") + } + let hasStreams = args.contains { $0.uppercased() == "STREAMS" } + guard hasStreams else { + throw RedisParseError.missingArgument("XREAD requires the STREAMS keyword") + } + return .command(args: tokens) + + case "XREVRANGE": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("XREVRANGE requires key, end, and start") + } + return .command(args: tokens) + + case "XDEL": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("XDEL requires key and at least one ID") + } + return .command(args: tokens) + + case "XTRIM": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("XTRIM requires key, MAXLEN|MINID, and threshold") + } + return .command(args: tokens) + + case "XINFO": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("XINFO requires a subcommand and key") + } + let sub = args[0].uppercased() + guard sub == "STREAM" || sub == "GROUPS" || sub == "CONSUMERS" || sub == "HELP" else { + throw RedisParseError.invalidArgument( + "XINFO subcommand must be STREAM, GROUPS, CONSUMERS, or HELP" + ) + } + return .command(args: tokens) + + case "XGROUP": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("XGROUP requires a subcommand and key") + } + let sub = args[0].uppercased() + guard sub == "CREATE" || sub == "SETID" || sub == "DELCONSUMER" || sub == "DESTROY" else { + throw RedisParseError.invalidArgument( + "XGROUP subcommand must be CREATE, SETID, DELCONSUMER, or DESTROY" + ) + } + return .command(args: tokens) + + case "XACK": + guard args.count >= 3 else { + throw RedisParseError.missingArgument("XACK requires key, group, and at least one ID") + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown stream command: \(command)") + return .command(args: tokens) } } @@ -398,6 +811,15 @@ struct RedisCommandParser { case "FLUSHDB": return .flushdb + case "FLUSHALL": + // Optional ASYNC|SYNC flag + if let flag = args.first?.uppercased() { + guard flag == "ASYNC" || flag == "SYNC" else { + throw RedisParseError.invalidArgument("FLUSHALL flag must be ASYNC or SYNC") + } + } + return .command(args: tokens) + case "SELECT": guard args.count >= 1, let db = Int(args[0]) else { throw RedisParseError.missingArgument("SELECT requires a database index (integer)") @@ -430,8 +852,27 @@ struct RedisCommandParser { case "DISCARD": return .discard + case "AUTH": + guard !args.isEmpty else { + throw RedisParseError.missingArgument("AUTH requires a password (and optionally a username)") + } + return .command(args: tokens) + + case "OBJECT": + guard args.count >= 2 else { + throw RedisParseError.missingArgument("OBJECT requires a subcommand and key") + } + let sub = args[0].uppercased() + guard sub == "ENCODING" || sub == "REFCOUNT" || sub == "IDLETIME" + || sub == "HELP" || sub == "FREQ" else { + throw RedisParseError.invalidArgument( + "OBJECT subcommand must be ENCODING, REFCOUNT, IDLETIME, FREQ, or HELP" + ) + } + return .command(args: tokens) + default: - throw RedisParseError.invalidArgument("Unknown server command: \(command)") + return .command(args: tokens) } } diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 007dea01..b7cafa33 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -69,44 +69,112 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static var statementCompletions: [CompletionEntry] { [ + // Key commands CompletionEntry(label: "GET", insertText: "GET"), CompletionEntry(label: "SET", insertText: "SET"), CompletionEntry(label: "DEL", insertText: "DEL"), CompletionEntry(label: "EXISTS", insertText: "EXISTS"), CompletionEntry(label: "KEYS", insertText: "KEYS"), + CompletionEntry(label: "GETSET", insertText: "GETSET"), + CompletionEntry(label: "GETDEL", insertText: "GETDEL"), + CompletionEntry(label: "GETEX", insertText: "GETEX"), + CompletionEntry(label: "MGET", insertText: "MGET"), + CompletionEntry(label: "MSET", insertText: "MSET"), + CompletionEntry(label: "INCR", insertText: "INCR"), + CompletionEntry(label: "DECR", insertText: "DECR"), + CompletionEntry(label: "INCRBY", insertText: "INCRBY"), + CompletionEntry(label: "DECRBY", insertText: "DECRBY"), + CompletionEntry(label: "INCRBYFLOAT", insertText: "INCRBYFLOAT"), + CompletionEntry(label: "APPEND", insertText: "APPEND"), + CompletionEntry(label: "EXPIRE", insertText: "EXPIRE"), + CompletionEntry(label: "PEXPIRE", insertText: "PEXPIRE"), + CompletionEntry(label: "EXPIREAT", insertText: "EXPIREAT"), + CompletionEntry(label: "PEXPIREAT", insertText: "PEXPIREAT"), + CompletionEntry(label: "TTL", insertText: "TTL"), + CompletionEntry(label: "PTTL", insertText: "PTTL"), + CompletionEntry(label: "PERSIST", insertText: "PERSIST"), + CompletionEntry(label: "TYPE", insertText: "TYPE"), + CompletionEntry(label: "RENAME", insertText: "RENAME"), + CompletionEntry(label: "SCAN", insertText: "SCAN"), + + // Hash commands CompletionEntry(label: "HGET", insertText: "HGET"), CompletionEntry(label: "HSET", insertText: "HSET"), CompletionEntry(label: "HGETALL", insertText: "HGETALL"), CompletionEntry(label: "HDEL", insertText: "HDEL"), + CompletionEntry(label: "HSCAN", insertText: "HSCAN"), + + // List commands CompletionEntry(label: "LPUSH", insertText: "LPUSH"), CompletionEntry(label: "RPUSH", insertText: "RPUSH"), CompletionEntry(label: "LRANGE", insertText: "LRANGE"), CompletionEntry(label: "LLEN", insertText: "LLEN"), + CompletionEntry(label: "LPOP", insertText: "LPOP"), + CompletionEntry(label: "RPOP", insertText: "RPOP"), + CompletionEntry(label: "LSET", insertText: "LSET"), + CompletionEntry(label: "LINSERT", insertText: "LINSERT"), + CompletionEntry(label: "LREM", insertText: "LREM"), + CompletionEntry(label: "LPOS", insertText: "LPOS"), + CompletionEntry(label: "LMOVE", insertText: "LMOVE"), + + // Set commands CompletionEntry(label: "SADD", insertText: "SADD"), CompletionEntry(label: "SMEMBERS", insertText: "SMEMBERS"), CompletionEntry(label: "SREM", insertText: "SREM"), CompletionEntry(label: "SCARD", insertText: "SCARD"), + CompletionEntry(label: "SPOP", insertText: "SPOP"), + CompletionEntry(label: "SRANDMEMBER", insertText: "SRANDMEMBER"), + CompletionEntry(label: "SMOVE", insertText: "SMOVE"), + CompletionEntry(label: "SUNION", insertText: "SUNION"), + CompletionEntry(label: "SINTER", insertText: "SINTER"), + CompletionEntry(label: "SDIFF", insertText: "SDIFF"), + CompletionEntry(label: "SUNIONSTORE", insertText: "SUNIONSTORE"), + CompletionEntry(label: "SINTERSTORE", insertText: "SINTERSTORE"), + CompletionEntry(label: "SDIFFSTORE", insertText: "SDIFFSTORE"), + CompletionEntry(label: "SSCAN", insertText: "SSCAN"), + + // Sorted set commands CompletionEntry(label: "ZADD", insertText: "ZADD"), CompletionEntry(label: "ZRANGE", insertText: "ZRANGE"), CompletionEntry(label: "ZREM", insertText: "ZREM"), + CompletionEntry(label: "ZCARD", insertText: "ZCARD"), CompletionEntry(label: "ZSCORE", insertText: "ZSCORE"), - CompletionEntry(label: "EXPIRE", insertText: "EXPIRE"), - CompletionEntry(label: "TTL", insertText: "TTL"), - CompletionEntry(label: "PERSIST", insertText: "PERSIST"), - CompletionEntry(label: "TYPE", insertText: "TYPE"), - CompletionEntry(label: "SCAN", insertText: "SCAN"), - CompletionEntry(label: "HSCAN", insertText: "HSCAN"), - CompletionEntry(label: "SSCAN", insertText: "SSCAN"), + CompletionEntry(label: "ZRANGEBYSCORE", insertText: "ZRANGEBYSCORE"), + CompletionEntry(label: "ZREVRANGE", insertText: "ZREVRANGE"), + CompletionEntry(label: "ZREVRANGEBYSCORE", insertText: "ZREVRANGEBYSCORE"), + CompletionEntry(label: "ZINCRBY", insertText: "ZINCRBY"), + CompletionEntry(label: "ZCOUNT", insertText: "ZCOUNT"), + CompletionEntry(label: "ZRANK", insertText: "ZRANK"), + CompletionEntry(label: "ZREVRANK", insertText: "ZREVRANK"), + CompletionEntry(label: "ZPOPMIN", insertText: "ZPOPMIN"), + CompletionEntry(label: "ZPOPMAX", insertText: "ZPOPMAX"), CompletionEntry(label: "ZSCAN", insertText: "ZSCAN"), + + // Stream commands + CompletionEntry(label: "XRANGE", insertText: "XRANGE"), + CompletionEntry(label: "XREVRANGE", insertText: "XREVRANGE"), + CompletionEntry(label: "XLEN", insertText: "XLEN"), + CompletionEntry(label: "XADD", insertText: "XADD"), + CompletionEntry(label: "XREAD", insertText: "XREAD"), + CompletionEntry(label: "XDEL", insertText: "XDEL"), + CompletionEntry(label: "XTRIM", insertText: "XTRIM"), + CompletionEntry(label: "XINFO", insertText: "XINFO"), + CompletionEntry(label: "XGROUP", insertText: "XGROUP"), + CompletionEntry(label: "XACK", insertText: "XACK"), + + // Server commands + CompletionEntry(label: "PING", insertText: "PING"), CompletionEntry(label: "INFO", insertText: "INFO"), CompletionEntry(label: "DBSIZE", insertText: "DBSIZE"), CompletionEntry(label: "FLUSHDB", insertText: "FLUSHDB"), + CompletionEntry(label: "FLUSHALL", insertText: "FLUSHALL"), CompletionEntry(label: "SELECT", insertText: "SELECT"), - CompletionEntry(label: "INCR", insertText: "INCR"), - CompletionEntry(label: "DECR", insertText: "DECR"), - CompletionEntry(label: "APPEND", insertText: "APPEND"), - CompletionEntry(label: "MGET", insertText: "MGET"), - CompletionEntry(label: "MSET", insertText: "MSET") + CompletionEntry(label: "CONFIG", insertText: "CONFIG"), + CompletionEntry(label: "AUTH", insertText: "AUTH"), + CompletionEntry(label: "OBJECT", insertText: "OBJECT"), + CompletionEntry(label: "MULTI", insertText: "MULTI"), + CompletionEntry(label: "EXEC", insertText: "EXEC"), + CompletionEntry(label: "DISCARD", insertText: "DISCARD"), ] } diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 92749c2e..4119b4b9 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -529,6 +529,7 @@ private extension RedisPluginConnection { let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } } + markDisconnected() throw RedisPluginError(code: Int(ctx.pointee.err), message: errMsg) } } @@ -550,6 +551,7 @@ private extension RedisPluginConnection { freeReplyObject(d) } } + markDisconnected() throw RedisPluginError(code: Int(ctx.pointee.err), message: errMsg) } let replyPtr = reply.assumingMemoryBound(to: redisReply.self) @@ -560,6 +562,13 @@ private extension RedisPluginConnection { return replies } + func markDisconnected() { + stateLock.lock() + context = nil + _isConnected = false + stateLock.unlock() + } + func withArgvPointers( args: [String], lengths: [Int], diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index d883f12f..c9246710 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -83,11 +83,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw RedisPluginError.notConnected } - var trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.caseInsensitiveCompare("SELECT") == .orderedSame { - trimmed = "SELECT 0" - } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) // Health monitor sends "SELECT 1" as a ping — intercept and remap to PING. if trimmed.lowercased() == "select 1" { @@ -782,7 +778,7 @@ private extension RedisPluginDriver { switch operation { case .lrange(let key, let start, let stop): let result = try await conn.executeCommand(["LRANGE", key, String(start), String(stop)]) - return buildListResult(result, startTime: startTime) + return buildListResult(result, startOffset: start, startTime: startTime) case .lpush(let key, let values): let args = ["LPUSH", key] + values @@ -1216,9 +1212,9 @@ private extension RedisPluginDriver { case "set": return ["SSCAN", key, "0", "COUNT", String(Self.previewLimit)] case "zset": - return ["ZRANGE", key, "0", String(Self.previewLimit - 1)] + return ["ZRANGE", key, "0", String(Self.previewLimit - 1), "WITHSCORES"] case "stream": - return ["XLEN", key] + return ["XREVRANGE", key, "+", "-", "COUNT", "5"] default: return nil } @@ -1271,13 +1267,37 @@ private extension RedisPluginDriver { return truncatePreview("[\(quoted.joined(separator: ", "))]") case "zset": - guard let members = reply.stringArrayValue else { return "[]" } - let quoted = members.map { "\"\(escapeJsonString($0))\"" } - return truncatePreview("[\(quoted.joined(separator: ", "))]") + // Parse WITHSCORES result: alternating member, score pairs + guard let items = reply.stringArrayValue, !items.isEmpty else { return "[]" } + var pairs: [String] = [] + var i = 0 + while i + 1 < items.count { + pairs.append("\(items[i]):\(items[i + 1])") + i += 2 + } + return truncatePreview(pairs.joined(separator: ", ")) case "stream": - let len = reply.intValue ?? 0 - return "(\(len) entries)" + // Parse XREVRANGE result: array of [id, [field, value, ...]] entries + guard let entries = reply.arrayValue, !entries.isEmpty else { + return "(0 entries)" + } + var entryStrings: [String] = [] + for entry in entries { + guard let parts = entry.arrayValue, parts.count >= 2, + let entryId = parts[0].stringValue, + let fields = parts[1].stringArrayValue else { + continue + } + var fieldPairs: [String] = [] + var j = 0 + while j + 1 < fields.count { + fieldPairs.append("\(fields[j])=\(fields[j + 1])") + j += 2 + } + entryStrings.append("\(entryId): \(fieldPairs.joined(separator: ", "))") + } + return truncatePreview(entryStrings.joined(separator: "; ")) default: return nil @@ -1430,7 +1450,7 @@ private extension RedisPluginDriver { ) } - func buildListResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { + func buildListResult(_ result: RedisReply, startOffset: Int = 0, startTime: Date) -> PluginQueryResult { guard let array = result.stringArrayValue else { return PluginQueryResult( columns: ["Index", "Value"], @@ -1442,7 +1462,7 @@ private extension RedisPluginDriver { } let rows = array.enumerated().map { index, value -> [String?] in - [String(index), value] + [String(startOffset + index), value] } return PluginQueryResult( diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index 78371993..f4b282cb 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -104,7 +104,7 @@ struct RedisQueryBuilder { var result = "" for char in str { switch char { - case "*", "?", "[", "]": + case "*", "?", "[": result.append("\\") result.append(char) case "\\": From 44c188b5213b931af36c8f1449d3420ddb4687f6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 19 Mar 2026 16:14:08 +0700 Subject: [PATCH 12/12] fix(redis): store EXAT/PXAT options and fix cancellation race across pipelines --- Plugins/RedisDriverPlugin/RedisCommandParser.swift | 8 ++++++-- Plugins/RedisDriverPlugin/RedisPluginConnection.swift | 5 +---- Plugins/RedisDriverPlugin/RedisPluginDriver.swift | 3 +++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 5c53dcb3..1dca43b4 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -73,6 +73,8 @@ enum RedisOperation { struct RedisSetOptions { var ex: Int? var px: Int? + var exat: Int? + var pxat: Int? var nx: Bool = false var xx: Bool = false } @@ -998,18 +1000,20 @@ struct RedisCommandParser { guard i + 1 < args.count else { throw RedisParseError.missingArgument("EXAT requires a value") } - guard Int(args[i + 1]) != nil else { + guard let timestamp = Int(args[i + 1]) else { throw RedisParseError.invalidArgument("EXAT value must be a positive integer") } + options.exat = timestamp hasOption = true i += 1 case "PXAT": guard i + 1 < args.count else { throw RedisParseError.missingArgument("PXAT requires a value") } - guard Int(args[i + 1]) != nil else { + guard let timestamp = Int(args[i + 1]) else { throw RedisParseError.invalidArgument("PXAT value must be a positive integer") } + options.pxat = timestamp hasOption = true i += 1 case "NX": diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 4119b4b9..dbca8439 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -331,7 +331,7 @@ final class RedisPluginConnection: @unchecked Sendable { } } - private func resetCancellation() { + func resetCancellation() { stateLock.lock() _isCancelled = false stateLock.unlock() @@ -356,7 +356,6 @@ final class RedisPluginConnection: @unchecked Sendable { func executeCommand(_ args: [String]) async throws -> RedisReply { #if canImport(CRedis) return try await pluginDispatchAsync(on: queue) { [self] in - resetCancellation() guard !isShuttingDown else { throw RedisPluginError.notConnected } @@ -379,7 +378,6 @@ final class RedisPluginConnection: @unchecked Sendable { func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { #if canImport(CRedis) return try await pluginDispatchAsync(on: queue) { [self] in - resetCancellation() guard !isShuttingDown else { throw RedisPluginError.notConnected } @@ -404,7 +402,6 @@ final class RedisPluginConnection: @unchecked Sendable { func selectDatabase(_ index: Int) async throws { #if canImport(CRedis) try await pluginDispatchAsync(on: queue) { [self] in - resetCancellation() guard !isShuttingDown else { throw RedisPluginError.notConnected } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index c9246710..a3b79ab2 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -78,6 +78,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let startTime = Date() cachedScanPattern = nil cachedScanKeys = nil + redisConnection?.resetCancellation() guard let conn = redisConnection else { throw RedisPluginError.notConnected @@ -610,6 +611,8 @@ private extension RedisPluginDriver { if let opts = options { if let ex = opts.ex { args += ["EX", String(ex)] } if let px = opts.px { args += ["PX", String(px)] } + if let exat = opts.exat { args += ["EXAT", String(exat)] } + if let pxat = opts.pxat { args += ["PXAT", String(pxat)] } if opts.nx { args.append("NX") } if opts.xx { args.append("XX") } }