From 377a17ef2f83b06496e29d51db8593b45ea54abe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Apr 2026 00:01:11 +0700 Subject: [PATCH 1/4] feat: add libSQL / Turso database driver plugin --- .github/workflows/build-plugin.yml | 5 + CHANGELOG.md | 1 + .../LibSQLDriverPlugin/HranaHttpClient.swift | 320 +++++++ Plugins/LibSQLDriverPlugin/Info.plist | 8 + Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift | 109 +++ .../LibSQLPluginDriver.swift | 786 ++++++++++++++++++ TablePro.xcodeproj/project.pbxproj | 130 +++ .../libsql-icon.imageset/Contents.json | 16 + .../libsql-icon.imageset/libsql.svg | 1 + ...ginMetadataRegistry+RegistryDefaults.swift | 114 +++ .../Core/Plugins/PluginMetadataRegistry.swift | 1 + .../ConnectionFormView+Helpers.swift | 4 +- .../libsql-icon.imageset/Contents.json | 16 + .../libsql-icon.imageset/libsql.svg | 1 + .../libsql-icon.imageset/Contents.json | 16 + .../libsql-icon.imageset/libsql.svg | 1 + docs/databases/libsql.mdx | 202 +++++ docs/databases/overview.mdx | 3 + docs/docs.json | 1 + docs/index.mdx | 1 + 20 files changed, 1735 insertions(+), 1 deletion(-) create mode 100644 Plugins/LibSQLDriverPlugin/HranaHttpClient.swift create mode 100644 Plugins/LibSQLDriverPlugin/Info.plist create mode 100644 Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift create mode 100644 Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift create mode 100644 TablePro/Assets.xcassets/libsql-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/libsql-icon.imageset/libsql.svg create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/libsql.svg create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/Contents.json create mode 100644 TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/libsql.svg create mode 100644 docs/databases/libsql.mdx diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 58b1a2e87..5a220cc29 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -160,6 +160,11 @@ jobs: DISPLAY_NAME="Cloudflare D1 Driver"; SUMMARY="Cloudflare D1 serverless SQLite-compatible database driver via REST API" DB_TYPE_IDS='["Cloudflare D1"]'; ICON="cloudflare-d1-icon"; BUNDLE_NAME="CloudflareD1DriverPlugin" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-d1" ;; + libsql) + TARGET="LibSQLDriverPlugin"; BUNDLE_ID="com.TablePro.LibSQLDriverPlugin" + DISPLAY_NAME="libSQL / Turso Driver"; SUMMARY="libSQL and Turso database support via Hrana HTTP protocol" + DB_TYPE_IDS='["libSQL","Turso"]'; ICON="libsql-icon"; BUNDLE_NAME="LibSQLDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/libsql" ;; dynamodb) TARGET="DynamoDBDriverPlugin"; BUNDLE_ID="com.TablePro.DynamoDBDriverPlugin" DISPLAY_NAME="DynamoDB Driver"; SUMMARY="Amazon DynamoDB driver with PartiQL queries and AWS IAM/Profile/SSO authentication" diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a07b2a6..afd31c116 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 +- libSQL / Turso database support via downloadable plugin - Import connections from TablePlus, Sequel Ace, and DBeaver with one-click migration - Embedded database CLI terminal (View > Open Terminal or Ctrl+Cmd+`) auto-launches mysql, psql, redis-cli, etc. for the active connection - Structure tab: modify existing tables (add, modify, drop columns, indexes, foreign keys, primary keys) diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift new file mode 100644 index 000000000..9ab45d6b9 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -0,0 +1,320 @@ +// +// HranaHttpClient.swift +// TablePro +// + +import Foundation +import os + +// MARK: - Hrana Protocol Types + +enum HranaValue: Decodable { + case null + case integer(String) + case float(Double) + case text(String) + case blob(Data) + + var stringValue: String? { + switch self { + case .null: + return nil + case .integer(let s): + return s + case .float(let d): + if d.truncatingRemainder(dividingBy: 1) == 0 && d.isFinite { + return String(Int64(d)) + } + return String(d) + case .text(let s): + return s + case .blob(let data): + return data.map { String(format: "%02x", $0) }.joined() + } + } + + private enum CodingKeys: String, CodingKey { + case type, value, base64 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "null": + self = .null + case "integer": + let value = try container.decode(String.self, forKey: .value) + self = .integer(value) + case "float": + let value = try container.decode(Double.self, forKey: .value) + self = .float(value) + case "text": + let value = try container.decode(String.self, forKey: .value) + self = .text(value) + case "blob": + let base64String = try container.decode(String.self, forKey: .base64) + guard let data = Data(base64Encoded: base64String) else { + self = .blob(Data()) + return + } + self = .blob(data) + default: + self = .null + } + } +} + +struct HranaColumn: Decodable { + let name: String + let decltype: String? +} + +struct HranaExecuteResult: Decodable { + let cols: [HranaColumn] + let rows: [[HranaValue]] + let affectedRowCount: Int + let lastInsertRowid: String? + + private enum CodingKeys: String, CodingKey { + case cols, rows + case affectedRowCount = "affected_row_count" + case lastInsertRowid = "last_insert_rowid" + } +} + +struct HranaPipelineEnvelope: Decodable { + let results: [HranaPipelineItem] +} + +struct HranaPipelineItem: Decodable { + let type: String + let response: HranaResponseBody? + let error: HranaErrorDetail? +} + +struct HranaResponseBody: Decodable { + let type: String + let result: HranaExecuteResult? +} + +struct HranaErrorDetail: Decodable { + let message: String + let code: String? +} + +// MARK: - HTTP Client + +final class HranaHttpClient: @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "HranaHttpClient") + + private let baseUrl: URL + private let authToken: String? + private let lock = NSLock() + private var session: URLSession? + private var currentTask: URLSessionDataTask? + + init(baseUrl: URL, authToken: String?) { + self.baseUrl = baseUrl + self.authToken = authToken + } + + 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 execute(sql: String, args: [String?] = []) async throws -> HranaExecuteResult { + let results = try await executeBatch(statements: [(sql: sql, args: args)]) + guard let first = results.first else { + throw HranaHttpError(message: String(localized: "Empty response from server")) + } + return first + } + + func executeBatch(statements: [(sql: String, args: [String?])]) async throws -> [HranaExecuteResult] { + let requests: [[String: Any]] = statements.map { stmt in + var stmtBody: [String: Any] = ["sql": stmt.sql] + if !stmt.args.isEmpty { + stmtBody["args"] = stmt.args.map { encodeArg($0) } + } + return ["type": "execute", "stmt": stmtBody] + } + + let body = try JSONSerialization.data(withJSONObject: ["requests": requests]) + let url = baseUrl.appendingPathComponent("v2/pipeline") + let data = try await performRequest(url: url, body: body) + + let envelope = try JSONDecoder().decode(HranaPipelineEnvelope.self, from: data) + + var results: [HranaExecuteResult] = [] + for item in envelope.results { + if item.type == "error" { + let message = item.error?.message ?? "Unknown error" + throw HranaHttpError(message: message) + } + guard let response = item.response, let result = response.result else { + throw HranaHttpError(message: String(localized: "Invalid response from server")) + } + results.append(result) + } + + return results + } + + // MARK: - Private Helpers + + private func encodeArg(_ value: String?) -> [String: Any] { + guard let value else { + return ["type": "null"] + } + if Int64(value) != nil { + return ["type": "integer", "value": value] + } + return ["type": "text", "value": value] + } + + private func performRequest(url: URL, body: Data) async throws -> Data { + lock.lock() + guard let session else { + lock.unlock() + throw HranaHttpError(message: String(localized: "Not connected to database")) + } + lock.unlock() + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + 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: HranaHttpError(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 HranaHttpError(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("Hrana auth error (\(statusCode)): \(bodyText)") + throw HranaHttpError( + message: String(localized: "Authentication failed. Check your auth token.") + ) + case 404: + Self.logger.error("Hrana server not found (\(statusCode)): \(bodyText)") + throw HranaHttpError( + message: String(localized: "Server not found. Check your database URL.") + ) + case 429: + let retryAfter = response.value(forHTTPHeaderField: "Retry-After") + Self.logger.warning("Hrana rate limited. Retry-After: \(retryAfter ?? "not specified")") + if let seconds = retryAfter { + throw HranaHttpError( + message: String(localized: "Rate limited. Retry after \(seconds) seconds.") + ) + } else { + throw HranaHttpError( + message: String(localized: "Rate limited. Please try again later.") + ) + } + default: + if let errorEnvelope = try? JSONDecoder().decode(HranaPipelineEnvelope.self, from: data) { + for item in errorEnvelope.results where item.type == "error" { + if let errorDetail = item.error { + Self.logger.error("Hrana API error (\(statusCode)): \(errorDetail.message)") + throw HranaHttpError(message: errorDetail.message) + } + } + } + Self.logger.error("Hrana HTTP error (\(statusCode)): \(bodyText)") + throw HranaHttpError(message: bodyText.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + static func normalizeUrl(_ urlString: String) -> String { + var normalized = urlString + if normalized.hasPrefix("libsql://") { + normalized = "https://" + normalized.dropFirst("libsql://".count) + } + while normalized.hasSuffix("/") { + normalized = String(normalized.dropLast()) + } + return normalized + } +} + +// MARK: - Error + +struct HranaHttpError: Error, LocalizedError { + let message: String + + var errorDescription: String? { message } +} diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist new file mode 100644 index 000000000..041041a22 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 5 + + diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift new file mode 100644 index 000000000..748a2aae3 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -0,0 +1,109 @@ +// +// LibSQLPlugin.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "libSQL / Turso Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "libSQL and Turso database support via Hrana HTTP protocol" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "libSQL" + static let additionalDatabaseTypeIds = ["Turso"] + static let databaseDisplayName = "libSQL / Turso" + static let iconName = "libsql-icon" + static let defaultPort = 0 + + // MARK: - UI/Capability Metadata + + static let connectionMode: ConnectionMode = .apiOnly + static let requiresAuthentication = false + static let supportsSSH = false + static let supportsSSL = false + static let isDownloadable = true + static let supportsImport = false + static let supportsSchemaEditing = true + static let supportsDropDatabase = false + static let supportsDatabaseSwitching = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let brandColorHex = "#4FF8D2" + static let urlSchemes: [String] = ["libsql"] + + 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: "databaseUrl", + label: String(localized: "Database URL"), + placeholder: "https://your-db.turso.io", + required: true, + section: .authentication + ) + ] + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + LibSQLPluginDriver(config: config) + } +} diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift new file mode 100644 index 000000000..d7178fed7 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -0,0 +1,786 @@ +// +// LibSQLPluginDriver.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +// MARK: - Error + +private struct LibSQLError: Error, PluginDriverError { + let message: String + + var pluginErrorMessage: String { message } + + static let notConnected = LibSQLError(message: String(localized: "Not connected to database")) +} + +// MARK: - Plugin Driver + +final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private var httpClient: HranaHttpClient? + private var _serverVersion: String? + private let lock = NSLock() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LibSQLPluginDriver") + + 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 rawUrl = config.additionalFields["databaseUrl"], !rawUrl.isEmpty else { + throw LibSQLError(message: String(localized: "Database URL is required")) + } + + let normalized = HranaHttpClient.normalizeUrl(rawUrl) + guard let baseUrl = URL(string: normalized) else { + throw LibSQLError(message: String(localized: "Invalid database URL")) + } + + let token = config.password + let authToken: String? = token.isEmpty ? nil : token + + let client = HranaHttpClient(baseUrl: baseUrl, authToken: authToken) + client.createSession() + + do { + let result = try await client.execute(sql: "SELECT sqlite_version()") + let version = result.rows.first?.first?.stringValue ?? "libSQL" + + lock.lock() + _serverVersion = version + lock.unlock() + } catch { + client.invalidateSession() + Self.logger.error("Connection test failed: \(error.localizedDescription)") + throw LibSQLError(message: String(localized: "Failed to connect to libSQL database")) + } + + lock.lock() + httpClient = client + lock.unlock() + + Self.logger.debug("Connected to libSQL database: \(normalized)") + } + + 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 LibSQLError.notConnected + } + + let startTime = Date() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let result = try await client.execute(sql: trimmed) + let executionTime = Date().timeIntervalSince(startTime) + return mapExecuteResult(result, 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 LibSQLError.notConnected + } + + let startTime = Date() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let result = try await client.execute(sql: trimmed, args: parameters) + let executionTime = Date().timeIntervalSince(startTime) + return mapExecuteResult(result, executionTime: executionTime) + } + + func executeBatch(queries: [String]) async throws -> [PluginQueryResult] { + guard let client = getClient() else { + throw LibSQLError.notConnected + } + + let startTime = Date() + let statements = queries.map { (sql: $0, args: [] as [String?]) } + let results = try await client.executeBatch(statements: statements) + let elapsed = Date().timeIntervalSince(startTime) + + return results.map { result in + mapExecuteResult(result, executionTime: elapsed / Double(results.count)) + } + } + + func cancelQuery() throws { + lock.lock() + httpClient?.cancelCurrentTask() + lock.unlock() + } + + // MARK: - Streaming + + func streamRows(query: String) -> AsyncThrowingStream { + return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let streamTask = Task { + do { + try await self.performStreamRows(query: query, continuation: continuation) + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + streamTask.cancel() + } + } + } + + private func performStreamRows( + query: String, + continuation: AsyncThrowingStream.Continuation + ) async throws { + guard let client = getClient() else { + throw LibSQLError.notConnected + } + + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let baseQuery = stripLimitOffset(from: trimmed) + let result = try await client.execute(sql: baseQuery) + + let columns = result.cols.map(\.name) + let columnTypeNames = result.cols.map { $0.decltype ?? "" } + continuation.yield(.header(PluginStreamHeader( + columns: columns, + columnTypeNames: columnTypeNames, + estimatedRowCount: nil + ))) + + if !result.rows.isEmpty { + let rows = result.rows.map { rawRow in rawRow.map(\.stringValue) } + continuation.yield(.rows(rows)) + } + + continuation.finish() + } + + // 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 'libsql_*' + 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] != nil && row[5] != "0" + 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 GLOB 'libsql_*' + 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] != nil && row[6] != "0" + + 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 GLOB 'libsql_*' + 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 LibSQLError(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 LibSQLError(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: "libSQL" + ) + } + + // MARK: - Database Operations + + func fetchDatabases() async throws -> [String] { + ["main"] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + throw LibSQLError(message: String(localized: "Creating databases is not supported")) + } + + func dropDatabase(name: String) async throws { + throw LibSQLError(message: String(localized: "Dropping databases is not supported")) + } + + func switchDatabase(to database: String) async throws { + throw LibSQLError(message: String(localized: "Switching databases is not supported")) + } + + // 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 'libsql_*' + ORDER BY name + """ + } + + // MARK: - Transactions + + func beginTransaction() async throws { + throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + } + + func commitTransaction() async throws { + throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + } + + func rollbackTransaction() async throws { + throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + } + + // MARK: - DDL Generation + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let tableName = quoteIdentifier(definition.tableName) + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { columnDefinition($0, inlinePK: inlinePK) } + + if pkColumns.count > 1 { + let pkCols = pkColumns.map { quoteIdentifier($0.name) }.joined(separator: ", ") + parts.append("PRIMARY KEY (\(pkCols))") + } + + for fk in definition.foreignKeys { + parts.append(foreignKeyDefinition(fk)) + } + + let sql = "CREATE TABLE \(tableName) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + return sql + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + var def = "\(quoteIdentifier(column.name)) \(column.dataType)" + if !column.isNullable { def += " NOT NULL" } + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + def += " DEFAULT \(sqlDefaultValue(defaultValue))" + } + return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(def)" + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + let uniqueStr = index.isUnique ? "UNIQUE " : "" + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name)) ON \(quoteIdentifier(table)) (\(cols))" + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX IF EXISTS \(quoteIdentifier(indexName))" + } + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + columnDefinition(column, inlinePK: column.isPrimaryKey) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + let uniqueStr = index.isUnique ? "UNIQUE " : "" + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let onClause = tableName.map { " ON \(quoteIdentifier($0))" } ?? "" + return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name))\(onClause) (\(cols))" + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + foreignKeyDefinition(fk) + } + + // MARK: - Private Helpers + + private func columnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var def = "\(quoteIdentifier(col.name)) \(col.dataType)" + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + if col.autoIncrement { + def += " AUTOINCREMENT" + } + } + if !col.isNullable { + def += " NOT NULL" + } + if let defaultValue = col.defaultValue { + def += " DEFAULT \(sqlDefaultValue(defaultValue))" + } + return def + } + + private func sqlDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_DATE" || upper == "CURRENT_TIME" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + + private func foreignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + if fk.onUpdate != "NO ACTION" { + def += " ON UPDATE \(fk.onUpdate)" + } + return def + } + + private func getClient() -> HranaHttpClient? { + lock.lock() + defer { lock.unlock() } + return httpClient + } + + private func mapExecuteResult(_ result: HranaExecuteResult, executionTime: TimeInterval) -> PluginQueryResult { + let columns = result.cols.map(\.name) + let columnTypeNames = result.cols.map { $0.decltype ?? "" } + + var rows: [[String?]] = [] + var truncated = false + + for rawRow in result.rows { + if rows.count >= PluginRowLimits.emergencyMax { + truncated = true + break + } + let row = rawRow.map(\.stringValue) + rows.append(row) + } + + return PluginQueryResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: rows, + rowsAffected: result.affectedRowCount, + 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[..Turso diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 1f6f97c4c..9973e2352 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1080,6 +1080,120 @@ extension PluginMetadataRegistry { ) ] ) + )), + ("libSQL", PluginMetadataSnapshot( + displayName: "libSQL / Turso", iconName: "libsql-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "libsql", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["libsql"], postConnectActions: [], + brandColorHex: "#4FF8D2", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsModifyColumn: false, + supportsRenameColumn: true + ), + 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: "databaseUrl", + label: String(localized: "Database URL"), + placeholder: "https://your-db.turso.io", + required: true, + section: .authentication + ) + ] + ) + )), + ("libSQL", PluginMetadataSnapshot( + displayName: "libSQL / Turso", iconName: "libsql-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "libsql", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["libsql"], postConnectActions: [], + brandColorHex: "#4FF8D2", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsModifyColumn: false, + supportsRenameColumn: true + ), + 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: "databaseUrl", + label: String(localized: "Database URL"), + placeholder: "https://your-db.turso.io", + required: true, + section: .authentication + ) + ] + ) )) ] + cloudPluginDefaults() } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 23ab7bb4a..a1fe7e089 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -586,6 +586,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { reverseTypeIndex["MariaDB"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["ScyllaDB"] = "Cassandra" + reverseTypeIndex["Turso"] = "libSQL" } func register(snapshot: PluginMetadataSnapshot, forTypeId typeId: String, preserveIcon: Bool = false) { diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index f0aa205a3..b5bda75e1 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -36,7 +36,9 @@ extension ConnectionFormView { .filter(\.isRequired) .allSatisfy { !(additionalFieldValues[$0.id] ?? "").isEmpty } basicValid = basicValid && hasRequiredFields - if !hidePasswordField && !promptForPassword { + if !hidePasswordField && !promptForPassword + && PluginManager.shared.requiresAuthentication(for: type) + { basicValid = basicValid && !password.isEmpty } for field in authSectionFields where field.isRequired && isFieldVisible(field) { diff --git a/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/Contents.json new file mode 100644 index 000000000..e01c48af9 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "libsql.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/libsql.svg b/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/libsql.svg new file mode 100644 index 000000000..c1a68df71 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/libsql-icon.imageset/libsql.svg @@ -0,0 +1 @@ +Turso diff --git a/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/Contents.json new file mode 100644 index 000000000..e01c48af9 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "libsql.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/libsql.svg b/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/libsql.svg new file mode 100644 index 000000000..c1a68df71 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/libsql-icon.imageset/libsql.svg @@ -0,0 +1 @@ +Turso diff --git a/docs/databases/libsql.mdx b/docs/databases/libsql.mdx new file mode 100644 index 000000000..12c642592 --- /dev/null +++ b/docs/databases/libsql.mdx @@ -0,0 +1,202 @@ +--- +title: libSQL / Turso +description: Connect to libSQL and Turso databases with TablePro +--- + +# libSQL / Turso Connections + +TablePro supports libSQL and Turso databases via the Hrana HTTP protocol. Works with Turso cloud databases and self-hosted sqld instances. + +## Install Plugin + +The libSQL / Turso driver is available as a downloadable plugin. When you select libSQL / Turso 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 **libSQL / Turso 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 **libSQL / Turso** from the database type selector + + + Fill in your Database URL and Auth Token + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | +|-------|-------------| +| **Name** | Connection identifier | +| **Database URL** | Full URL to your libSQL database (e.g., `https://your-db-name.turso.io` or `http://localhost:8080`) | +| **Auth Token** | Bearer token for authentication (optional for self-hosted sqld without auth) | + + +The Auth Token field is optional. Self-hosted sqld instances can run without authentication, in which case you can leave this field empty. + + +## Getting Your Credentials + +### Turso Cloud + +**Database URL**: Find it on the [Turso dashboard](https://turso.tech/app) or run: + +```bash +turso db show --url +``` + +This returns a URL like `libsql://your-db-name-your-org.turso.io`. TablePro automatically rewrites `libsql://` to `https://` for the HTTP connection. + +**Auth Token**: Generate a token with the Turso CLI: + +```bash +turso db tokens create +``` + + +Store your auth token securely. TablePro saves it in the macOS Keychain, but the token grants full access to your database. + + +### Self-hosted sqld + +**Database URL**: The default sqld HTTP endpoint is `http://localhost:8080`. Adjust the host and port to match your sqld configuration. + +**Auth Token**: Depends on your sqld setup. If you started sqld without `--auth-jwt-key-file`, no token is needed. If JWT authentication is enabled, generate a token matching your configured key. + +## Example Configuration + +### Turso Cloud + +``` +Name: My Turso DB +Database URL: libsql://my-app-db-myorg.turso.io +Auth Token: (your Turso auth token) +``` + +### Self-hosted sqld + +``` +Name: Local sqld +Database URL: http://localhost:8080 +Auth Token: (leave empty if no auth configured) +``` + +## Features + +### Database Browsing + +After connecting, the sidebar shows tables and views in the database. libSQL databases have a single "main" schema, similar to SQLite. + +### 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 Hrana HTTP API. + +### Export + +Export query results or table data to CSV, JSON, SQL, and other formats. + +## SQL Dialect + +libSQL uses SQLite syntax. See [SQLite](/databases/sqlite) for details. + +## Troubleshooting + +### Authentication Failed + +**Symptoms**: "Authentication failed" or HTTP 401 response + +**Solutions**: + +1. Verify your auth token is correct and has not expired +2. For Turso Cloud, generate a new token: `turso db tokens create ` +3. For self-hosted sqld, check that your JWT matches the configured key +4. Ensure you have not accidentally included extra whitespace in the token + +### Invalid URL + +**Symptoms**: "Invalid database URL" or connection fails immediately + +**Solutions**: + +1. Check the URL format: `https://your-db.turso.io` for Turso, `http://localhost:8080` for sqld +2. Do not include a trailing slash or path components +3. `libsql://` URLs are accepted and automatically rewritten to `https://` +4. Verify the database exists: `turso db list` + +### Connection Timeout + +**Symptoms**: Queries take too long or time out + +**Solutions**: + +1. Check your internet connection (for Turso Cloud) +2. Verify sqld is running (for self-hosted): `curl http://localhost:8080/health` +3. Simplify queries that scan large amounts of data + +### Rate Limited + +**Symptoms**: HTTP 429 response or "rate limited" error + +**Solutions**: + +1. Wait for the retry period and try again +2. Reduce query frequency +3. Use pagination for large result sets instead of fetching all rows +4. Check your Turso plan limits at [Turso Pricing](https://turso.tech/pricing) + +## Known Limitations + +- No persistent connections: each query is an independent HTTP request via the Hrana protocol +- No multi-statement transactions: each SQL statement auto-commits independently +- No database creation or management via TablePro (use Turso CLI or sqld admin tools) +- No bulk import through the plugin: use the Turso CLI or direct sqld import +- No custom SSL/SSH tunnels: connections use HTTPS (Turso Cloud) or HTTP (local sqld) +- No database switching: libSQL connections target a single database diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 88247dce4..ba0c0273d 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -55,6 +55,9 @@ Natively supported: Cloudflare D1 serverless SQLite database via Cloudflare API + + libSQL open-source SQLite fork. Works with Turso and self-hosted sqld via Hrana protocol + ## Creating a Connection diff --git a/docs/docs.json b/docs/docs.json index db58b639e..81c9bb90f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -49,6 +49,7 @@ "databases/mssql", "databases/duckdb", "databases/cloudflare-d1", + "databases/libsql", "databases/bigquery", "databases/ssh-tunneling" ] diff --git a/docs/index.mdx b/docs/index.mdx index 54564e0de..b1cb7a2b5 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -68,6 +68,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | Cassandra / ScyllaDB | Fully Supported | 9042 | | Etcd | Fully Supported | 2379 | | Cloudflare D1 | Fully Supported | N/A (API-based) | +| libSQL / Turso | Fully Supported | N/A (API-based) | | DynamoDB | Fully Supported | N/A (API-based) | | BigQuery | Fully Supported | N/A (API-based) | From 9abece1b86e273a733961b843f0c5a50554ad431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 22 Apr 2026 12:53:20 +0700 Subject: [PATCH 2/4] wip --- .../Sources/TableProModels/DatabaseType.swift | 4 +- .../DatabaseTypeTests.swift | 3 +- .../LibSQLDriverPlugin/HranaHttpClient.swift | 2 +- ...ginMetadataRegistry+RegistryDefaults.swift | 57 ------------------- .../Connection/DatabaseConnection.swift | 2 + 5 files changed, 8 insertions(+), 60 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift index 59ab39bdb..fd31bab44 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -25,11 +25,12 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") public static let dynamodb = DatabaseType(rawValue: "DynamoDB") public static let bigquery = DatabaseType(rawValue: "BigQuery") + public static let libsql = DatabaseType(rawValue: "libSQL") public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, - .etcd, .cloudflareD1, .dynamodb, .bigquery + .etcd, .cloudflareD1, .dynamodb, .bigquery, .libsql ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -51,6 +52,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .cloudflareD1: return "cloudflare-d1-icon" case .dynamodb: return "dynamodb-icon" case .bigquery: return "bigquery-icon" + case .libsql: return "libsql-icon" default: return "externaldrive" } } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index af76e5c5d..e8eeacbeb 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -51,9 +51,10 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 16) + #expect(DatabaseType.allKnownTypes.count == 17) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) + #expect(DatabaseType.allKnownTypes.contains(.libsql)) } @Test("Hashable conformance") diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index 9ab45d6b9..831d6dc35 100644 --- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -278,7 +278,7 @@ final class HranaHttpClient: @unchecked Sendable { Self.logger.warning("Hrana rate limited. Retry-After: \(retryAfter ?? "not specified")") if let seconds = retryAfter { throw HranaHttpError( - message: String(localized: "Rate limited. Retry after \(seconds) seconds.") + message: String(format: String(localized: "Rate limited. Retry after %@ seconds."), seconds) ) } else { throw HranaHttpError( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 9973e2352..fb5d6b67a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1138,63 +1138,6 @@ extension PluginMetadataRegistry { ] ) )), - ("libSQL", PluginMetadataSnapshot( - displayName: "libSQL / Turso", iconName: "libsql-icon", defaultPort: 0, - requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: true, primaryUrlScheme: "libsql", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [ - ExplainVariant(id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN") - ], - pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["libsql"], postConnectActions: [], - brandColorHex: "#4FF8D2", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .apiOnly, supportsDatabaseSwitching: false, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: false, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsModifyColumn: false, - supportsRenameColumn: true - ), - 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: "databaseUrl", - label: String(localized: "Database URL"), - placeholder: "https://your-db.turso.io", - required: true, - section: .authentication - ) - ] - ) - )) ] + cloudPluginDefaults() } // swiftlint:enable function_body_length diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index abf69dd5e..54843d7e0 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -41,6 +41,8 @@ extension DatabaseType { static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") static let dynamodb = DatabaseType(rawValue: "DynamoDB") static let bigQuery = DatabaseType(rawValue: "BigQuery") + static let libsql = DatabaseType(rawValue: "libSQL") + static let turso = DatabaseType(rawValue: "Turso") } extension DatabaseType: Codable { From 5fa0ec4c53dd2762c6bad801ff76de0dc692507e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 22 Apr 2026 14:34:36 +0700 Subject: [PATCH 3/4] fix: check driver.supportsTransactions before wrapping saves in transactions --- .../Database/DatabaseManager+Schema.swift | 21 ++++++++++++------- .../MainContentCoordinator+Discard.swift | 20 ++++++++++++------ .../MainContentCoordinator+SaveChanges.swift | 4 +--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index 94014fc70..4bd225392 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -60,15 +60,20 @@ extension DatabaseManager { ) let statements = try generator.generate(changes: changes) - // Execute in transaction - try await driver.beginTransaction() + let useTransaction = driver.supportsTransactions + + if useTransaction { + try await driver.beginTransaction() + } do { for stmt in statements { _ = try await driver.execute(query: stmt.sql) } - try await driver.commitTransaction() + if useTransaction { + try await driver.commitTransaction() + } // Record each statement in query history let connId = connectionId @@ -87,10 +92,12 @@ extension DatabaseManager { // Post notification to refresh UI NotificationCenter.default.post(name: .refreshData, object: nil) } catch { - do { - try await driver.rollbackTransaction() - } catch { - Self.logger.error("Rollback failed after schema change error: \(error.localizedDescription)") + if useTransaction { + do { + try await driver.rollbackTransaction() + } catch { + Self.logger.error("Rollback failed after schema change error: \(error.localizedDescription)") + } } throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 4303ca50c..70c875992 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -35,7 +35,11 @@ extension MainContentCoordinator { throw DatabaseError.notConnected } - try await driver.beginTransaction() + let useTransaction = driver.supportsTransactions + + if useTransaction { + try await driver.beginTransaction() + } do { for stmt in statements { @@ -45,12 +49,16 @@ extension MainContentCoordinator { _ = try await driver.executeParameterized(query: stmt.sql, parameters: stmt.parameters) } } - try await driver.commitTransaction() + if useTransaction { + try await driver.commitTransaction() + } } catch { - do { - try await driver.rollbackTransaction() - } catch { - discardLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") + if useTransaction { + do { + try await driver.rollbackTransaction() + } catch { + discardLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") + } } throw error } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 5bad5eeea..1bfd296e7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -187,9 +187,7 @@ extension MainContentCoordinator { throw DatabaseError.notConnected } - // Redis MULTI/EXEC is not a true transaction (no rollback on failure), - // so execute statements individually without wrapping. - let useTransaction = dbType != .redis + let useTransaction = driver.supportsTransactions if useTransaction { try await driver.beginTransaction() From de536869a8ae9bb8d51a63a0e602612d12184f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 22 Apr 2026 14:41:54 +0700 Subject: [PATCH 4/4] fix: improve Hrana value precision, float encoding, and version detection --- Plugins/LibSQLDriverPlugin/HranaHttpClient.swift | 5 ++++- Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index 831d6dc35..24080efac 100644 --- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -22,7 +22,7 @@ enum HranaValue: Decodable { case .integer(let s): return s case .float(let d): - if d.truncatingRemainder(dividingBy: 1) == 0 && d.isFinite { + if d.isFinite && d == d.rounded() && abs(d) <= 9_007_199_254_740_992 { return String(Int64(d)) } return String(d) @@ -195,6 +195,9 @@ final class HranaHttpClient: @unchecked Sendable { if Int64(value) != nil { return ["type": "integer", "value": value] } + if let d = Double(value), Int64(value) == nil { + return ["type": "float", "value": d] + } return ["type": "text", "value": value] } diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index d7178fed7..9b5860792 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -60,8 +60,11 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { client.createSession() do { - let result = try await client.execute(sql: "SELECT sqlite_version()") - let version = result.rows.first?.first?.stringValue ?? "libSQL" + let libsqlVersion = try? await client.execute(sql: "SELECT libsql_version()") + let sqliteVersion = try await client.execute(sql: "SELECT sqlite_version()") + let version = libsqlVersion?.rows.first?.first?.stringValue + ?? sqliteVersion.rows.first?.first?.stringValue + ?? "libSQL" lock.lock() _serverVersion = version