diff --git a/CHANGELOG.md b/CHANGELOG.md index e5514a6b8..1da8691f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- libSQL / Turso connections can open a local database file: pick Local File mode in the connection form, browse to the file, and work with it offline, transactions included. (#1607) + ### Fixed - Default row sort now applies to the very first table opened after launch, not just tables opened after it. (#1603) +- Cancelling a SQLite query no longer races a disconnect happening at the same moment. (#1610) ## [0.49.1] - 2026-06-06 diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index 748a2aae3..ee16d1e80 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -94,12 +94,32 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "libsqlMode", + label: String(localized: "Connection Mode"), + defaultValue: "remote", + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Turso)")), + ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")) + ]), + section: .authentication, + hidesPassword: true + ), ConnectionField( id: "databaseUrl", label: String(localized: "Database URL"), placeholder: "https://your-db.turso.io", required: true, - section: .authentication + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["remote"]) + ), + ConnectionField( + id: "libsqlFilePath", + label: String(localized: "Database File"), + placeholder: "/path/to/database.db", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"]) ) ] diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 7a09d1419..62f1e62e2 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -5,11 +5,12 @@ import Foundation import os +import SQLite3 import TableProPluginKit // MARK: - Error -private struct LibSQLError: Error, PluginDriverError { +struct LibSQLError: Error, PluginDriverError { let message: String var pluginErrorMessage: String { message } @@ -20,9 +21,15 @@ private struct LibSQLError: Error, PluginDriverError { // MARK: - Plugin Driver final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private enum Backend { + case remote(HranaHttpClient) + case local(SQLiteLocalBackend) + } + private let config: DriverConnectionConfig - private var httpClient: HranaHttpClient? + private var backend: Backend? private var _serverVersion: String? + nonisolated(unsafe) private var _dbHandleForInterrupt: OpaquePointer? private let lock = NSLock() private static let logger = Logger(subsystem: "com.TablePro", category: "LibSQLPluginDriver") @@ -33,27 +40,70 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return _serverVersion } var supportsSchemas: Bool { false } - var supportsTransactions: Bool { false } + var supportsTransactions: Bool { isLocalMode } var currentSchema: String? { nil } var parameterStyle: ParameterStyle { .questionMark } var capabilities: PluginCapabilities { - [ + var base: PluginCapabilities = [ .parameterizedQueries, .alterTableDDL, .foreignKeyToggle, .truncateTable, .cancelQuery, ] + if isLocalMode { + base.insert(.transactions) + base.insert(.batchExecute) + } + return base } init(config: DriverConnectionConfig) { self.config = config } + private var isLocalMode: Bool { + config.additionalFields["libsqlMode"] == "local" + } + // MARK: - Connection func connect() async throws { + if isLocalMode { + try await connectLocal() + } else { + try await connectRemote() + } + } + + private func connectLocal() async throws { + guard let rawPath = config.additionalFields["libsqlFilePath"], !rawPath.isEmpty else { + throw LibSQLError(message: String(localized: "Database file path is required")) + } + + let path = expandPath(rawPath) + if !FileManager.default.fileExists(atPath: path) { + let directory = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + } + + let localBackend = SQLiteLocalBackend() + try await localBackend.open(path: path) + let rawHandle = await localBackend.dbHandleForInterrupt + let versionResult = try await localBackend.executeQuery("SELECT sqlite_version()") + let version = versionResult.rows.first?.first?.asText ?? "SQLite" + + lock.lock() + _dbHandleForInterrupt = rawHandle != 0 ? OpaquePointer(bitPattern: rawHandle) : nil + _serverVersion = version + backend = .local(localBackend) + lock.unlock() + + Self.logger.debug("Connected to local libSQL database file") + } + + private func connectRemote() async throws { guard let rawUrl = config.additionalFields["databaseUrl"], !rawUrl.isEmpty else { throw LibSQLError(message: String(localized: "Database URL is required")) } @@ -86,7 +136,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } lock.lock() - httpClient = client + backend = .remote(client) lock.unlock() Self.logger.debug("Connected to libSQL database: \(normalized)") @@ -94,9 +144,19 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func disconnect() { lock.lock() - httpClient?.invalidateSession() - httpClient = nil + let current = backend + backend = nil + _dbHandleForInterrupt = nil lock.unlock() + + switch current { + case .remote(let client): + client.invalidateSession() + case .local(let localBackend): + Task { await localBackend.close() } + case nil: + break + } } func ping() async throws { @@ -106,15 +166,20 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { - guard let client = getClient() else { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + + switch getBackend() { + case .remote(let client): + let startTime = Date() + let result = try await client.execute(sql: trimmed) + let executionTime = Date().timeIntervalSince(startTime) + return mapExecuteResult(result, executionTime: executionTime) + case .local(let localBackend): + let raw = try await localBackend.executeQuery(trimmed) + return mapLocalResult(raw) + case nil: 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: [PluginCellValue]) async throws -> PluginQueryResult { @@ -122,50 +187,75 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await execute(query: query) } - guard let client = getClient() else { - throw LibSQLError.notConnected - } - - let startTime = Date() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let stringArgs: [String?] = parameters.map { param -> String? in - switch param { - case .null: return nil - case .text(let s): return s - case .bytes(let d): return "X'" + d.map { String(format: "%02X", $0) }.joined() + "'" + + switch getBackend() { + case .remote(let client): + let startTime = Date() + let stringArgs: [String?] = parameters.map { param -> String? in + switch param { + case .null: return nil + case .text(let s): return s + case .bytes(let d): return "X'" + d.map { String(format: "%02X", $0) }.joined() + "'" + } } + let result = try await client.execute(sql: trimmed, args: stringArgs) + let executionTime = Date().timeIntervalSince(startTime) + return mapExecuteResult(result, executionTime: executionTime) + case .local(let localBackend): + let raw = try await localBackend.executeParameterizedQuery(trimmed, parameters: parameters) + return mapLocalResult(raw) + case nil: + throw LibSQLError.notConnected } - let result = try await client.execute(sql: trimmed, args: stringArgs) - let executionTime = Date().timeIntervalSince(startTime) - return mapExecuteResult(result, executionTime: executionTime) } func executeBatch(queries: [String]) async throws -> [PluginQueryResult] { - guard let client = getClient() else { + switch getBackend() { + case .remote(let client): + 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)) + } + case .local(let localBackend): + var results: [PluginQueryResult] = [] + for query in queries { + let raw = try await localBackend.executeQuery(query) + results.append(mapLocalResult(raw)) + } + return results + case nil: 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() + let current = backend + if let interruptHandle = _dbHandleForInterrupt { + sqlite3_interrupt(interruptHandle) + } lock.unlock() + + if case .remote(let client) = current { + client.cancelCurrentTask() + } } func applyQueryTimeout(_ seconds: Int) async throws { - lock.lock() - let client = httpClient - lock.unlock() - client?.setQueryTimeout(seconds) + switch getBackend() { + case .remote(let client): + client.setQueryTimeout(seconds) + case .local(let localBackend): + guard seconds > 0 else { return } + await localBackend.applyBusyTimeout(Int32(seconds * 1_000)) + case nil: + break + } } // MARK: - Streaming @@ -190,28 +280,31 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { query: String, continuation: AsyncThrowingStream.Continuation ) async throws { - guard let client = getClient() else { - throw LibSQLError.notConnected - } - - let result = try await client.execute(sql: query) - - 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).map(PluginCellValue.fromOptional) + switch getBackend() { + case .remote(let client): + let result = try await client.execute(sql: query) + + 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).map(PluginCellValue.fromOptional) + } + continuation.yield(.rows(rows)) } - continuation.yield(.rows(rows)) - } - continuation.finish() + continuation.finish() + case .local(let localBackend): + try await localBackend.streamQuery(query, continuation: continuation) + case nil: + throw LibSQLError.notConnected + } } // MARK: - Schema Operations @@ -560,15 +653,22 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Transactions func beginTransaction() async throws { - throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + _ = try await executeTransactionStatement("BEGIN") } func commitTransaction() async throws { - throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + _ = try await executeTransactionStatement("COMMIT") } func rollbackTransaction() async throws { - throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + _ = try await executeTransactionStatement("ROLLBACK") + } + + private func executeTransactionStatement(_ statement: String) async throws -> PluginQueryResult { + guard case .local = getBackend() else { + throw LibSQLError(message: String(localized: "Transactions are not supported in this mode")) + } + return try await execute(query: statement) } // MARK: - DDL Generation @@ -676,10 +776,28 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return def } - private func getClient() -> HranaHttpClient? { + private func getBackend() -> Backend? { lock.lock() defer { lock.unlock() } - return httpClient + return backend + } + + private func expandPath(_ path: String) -> String { + if path.hasPrefix("~") { + return NSString(string: path).expandingTildeInPath + } + return path + } + + private func mapLocalResult(_ raw: LibSQLLocalRawResult) -> PluginQueryResult { + PluginQueryResult( + columns: raw.columns, + columnTypeNames: raw.columnTypeNames, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated + ) } private func mapExecuteResult(_ result: HranaExecuteResult, executionTime: TimeInterval) -> PluginQueryResult { diff --git a/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift b/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift new file mode 100644 index 000000000..71a7fcb91 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift @@ -0,0 +1,224 @@ +// +// SQLiteLocalBackend.swift +// TablePro +// + +import Foundation +import os +import SQLite3 +import TableProPluginKit + +struct LibSQLLocalRawResult: Sendable { + let columns: [String] + let columnTypeNames: [String] + let rows: [[PluginCellValue]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +actor SQLiteLocalBackend { + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLiteLocalBackend") + + private var db: OpaquePointer? + + var isConnected: Bool { db != nil } + + func open(path: String) throws { + let result = sqlite3_open(path, &db) + + if result != SQLITE_OK { + let errorMessage = db.map { String(cString: sqlite3_errmsg($0)) } + ?? "Unknown SQLite error" + throw LibSQLError(message: errorMessage) + } + } + + func close() { + if db != nil { + sqlite3_close(db) + db = nil + } + } + + func applyBusyTimeout(_ milliseconds: Int32) { + guard let db else { return } + sqlite3_busy_timeout(db, milliseconds) + } + + var dbHandleForInterrupt: Int { db.map { Int(bitPattern: $0) } ?? 0 } + + func executeQuery(_ query: String) throws -> LibSQLLocalRawResult { + try executeParameterizedQuery(query, parameters: []) + } + + func executeParameterizedQuery( + _ query: String, + parameters: [PluginCellValue] + ) throws -> LibSQLLocalRawResult { + guard let db else { + throw LibSQLError.notConnected + } + + let startTime = Date() + var statement: OpaquePointer? + + let prepareResult = sqlite3_prepare_v2(db, query, -1, &statement, nil) + + if prepareResult != SQLITE_OK { + let errorMessage = String(cString: sqlite3_errmsg(db)) + throw LibSQLError(message: errorMessage) + } + + defer { + sqlite3_finalize(statement) + } + + try bind(parameters, to: statement, db: db) + + let columnCount = sqlite3_column_count(statement) + let columns = columnNames(of: statement, count: columnCount) + let columnTypeNames = columnDeclaredTypes(of: statement, count: columnCount) + + var rows: [[PluginCellValue]] = [] + var rowsAffected = 0 + var truncated = false + + while sqlite3_step(statement) == SQLITE_ROW { + if rows.count >= PluginRowLimits.emergencyMax { + truncated = true + break + } + rows.append(rowValues(of: statement, count: columnCount)) + } + + if columns.isEmpty { + rowsAffected = Int(sqlite3_changes(db)) + } + + return LibSQLLocalRawResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: rows, + rowsAffected: rowsAffected, + executionTime: Date().timeIntervalSince(startTime), + isTruncated: truncated + ) + } + + func streamQuery( + _ query: String, + continuation: AsyncThrowingStream.Continuation + ) throws { + guard let db else { + throw LibSQLError.notConnected + } + + var statement: OpaquePointer? + + let prepareResult = sqlite3_prepare_v2(db, query, -1, &statement, nil) + if prepareResult != SQLITE_OK { + let errorMessage = String(cString: sqlite3_errmsg(db)) + throw LibSQLError(message: errorMessage) + } + + let columnCount = sqlite3_column_count(statement) + continuation.yield(.header(PluginStreamHeader( + columns: columnNames(of: statement, count: columnCount), + columnTypeNames: columnDeclaredTypes(of: statement, count: columnCount), + estimatedRowCount: nil + ))) + + let batchSize = 5_000 + var batch: [PluginRow] = [] + batch.reserveCapacity(batchSize) + + while sqlite3_step(statement) == SQLITE_ROW { + if Task.isCancelled { + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + sqlite3_finalize(statement) + continuation.finish(throwing: CancellationError()) + return + } + + batch.append(rowValues(of: statement, count: columnCount)) + if batch.count >= batchSize { + continuation.yield(.rows(batch)) + batch.removeAll(keepingCapacity: true) + } + } + + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + + sqlite3_finalize(statement) + continuation.finish() + } + + private func bind( + _ parameters: [PluginCellValue], + to statement: OpaquePointer?, + db: OpaquePointer + ) throws { + guard !parameters.isEmpty else { return } + + let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + for (index, param) in parameters.enumerated() { + let bindIndex = Int32(index + 1) + let bindResult: Int32 + + switch param { + case .null: + bindResult = sqlite3_bind_null(statement, bindIndex) + case .text(let stringValue): + bindResult = sqlite3_bind_text(statement, bindIndex, stringValue, -1, sqliteTransient) + case .bytes(let data): + bindResult = data.withUnsafeBytes { rawBuffer -> Int32 in + let baseAddress = rawBuffer.baseAddress + return sqlite3_bind_blob(statement, bindIndex, baseAddress, Int32(data.count), sqliteTransient) + } + } + + if bindResult != SQLITE_OK { + let errorMessage = String(cString: sqlite3_errmsg(db)) + throw LibSQLError(message: "Failed to bind parameter \(index): \(errorMessage)") + } + } + } + + private func columnNames(of statement: OpaquePointer?, count: Int32) -> [String] { + (0.. [String] { + (0.. [PluginCellValue] { + (0.. 0, let blobPtr = sqlite3_column_blob(statement, index) else { + return .bytes(Data()) + } + return .bytes(Data(bytes: blobPtr, count: byteCount)) + } + guard let text = sqlite3_column_text(statement, index) else { + return .null + } + return .text(String(cString: text)) + } + } +} diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index dccde405b..a2e468185 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -517,9 +517,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func cancelQuery() throws { interruptLock.lock() - let db = _dbHandleForInterrupt - interruptLock.unlock() - guard let db else { return } + defer { interruptLock.unlock() } + guard let db = _dbHandleForInterrupt else { return } sqlite3_interrupt(db) } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index ea21b838c..bfed51461 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2953,6 +2953,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.LibSQLDriverPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -2979,6 +2980,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.LibSQLDriverPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index e7b6f3ef5..59ad30091 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1168,12 +1168,38 @@ extension PluginMetadataRegistry { ), connection: PluginMetadataSnapshot.ConnectionConfig( additionalConnectionFields: [ + ConnectionField( + id: "libsqlMode", + label: String(localized: "Connection Mode"), + defaultValue: "remote", + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption( + value: "remote", + label: String(localized: "Remote (Turso)") + ), + ConnectionField.DropdownOption( + value: "local", + label: String(localized: "Local File") + ) + ]), + section: .authentication, + hidesPassword: true + ), ConnectionField( id: "databaseUrl", label: String(localized: "Database URL"), placeholder: "https://your-db.turso.io", required: true, - section: .authentication + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["remote"]) + ), + ConnectionField( + id: "libsqlFilePath", + label: String(localized: "Database File"), + placeholder: "/path/to/database.db", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"]) ) ], category: .cloud, diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 75cea15f9..28b90663f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -14497,6 +14497,9 @@ } } } + }, + "Connection Mode" : { + }, "Connection name" : { "localizations" : { @@ -35096,6 +35099,9 @@ } } } + }, + "Local File" : { + }, "Local Listener" : { "localizations" : { @@ -48334,6 +48340,9 @@ } } } + }, + "Remote (Turso)" : { + }, "Remove" : { "localizations" : { diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 848bb67ae..c9528d72c 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -172,10 +172,18 @@ struct GeneralPaneView: View { } ForEach(coordinator.auth.authFields, id: \.id) { field in if coordinator.auth.isFieldVisible(field) { - ConnectionFieldRow( - field: field, - value: authFieldBinding(for: field) - ) + if FilePathConnectionFieldRow.isFilePathField(field) { + FilePathConnectionFieldRow( + field: field, + value: authFieldBinding(for: field), + onBrowse: { browseForAuthFile(field: field) } + ) + } else { + ConnectionFieldRow( + field: field, + value: authFieldBinding(for: field) + ) + } } } if coordinator.auth.usePgpass { @@ -267,6 +275,18 @@ struct GeneralPaneView: View { } private func browseForFile() { + presentFilePanel { path in + coordinator.network.database = path + } + } + + private func browseForAuthFile(field: ConnectionField) { + presentFilePanel { path in + coordinator.auth.additionalFieldValues[field.id] = path + } + } + + private func presentFilePanel(onSelect: @escaping (String) -> Void) { guard let window = NSApp.keyWindow else { return } let panel = NSOpenPanel() panel.allowedContentTypes = [.database, .data] @@ -275,7 +295,7 @@ struct GeneralPaneView: View { panel.beginSheetModal(for: window) { response in if response == .OK, let url = panel.url { - coordinator.network.database = url.path(percentEncoded: false) + onSelect(url.path(percentEncoded: false)) } } } diff --git a/TablePro/Views/ConnectionForm/Support/FilePathConnectionFieldRow.swift b/TablePro/Views/ConnectionForm/Support/FilePathConnectionFieldRow.swift new file mode 100644 index 000000000..28efaabbe --- /dev/null +++ b/TablePro/Views/ConnectionForm/Support/FilePathConnectionFieldRow.swift @@ -0,0 +1,27 @@ +// +// FilePathConnectionFieldRow.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct FilePathConnectionFieldRow: View { + let field: ConnectionField + @Binding var value: String + let onBrowse: () -> Void + + static func isFilePathField(_ field: ConnectionField) -> Bool { + field.fieldType == .text && field.id.hasSuffix("FilePath") + } + + var body: some View { + HStack { + ConnectionFieldRow(field: field, value: $value) + Button(String(localized: "Browse...")) { + onBrowse() + } + .controlSize(.small) + } + } +} diff --git a/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift b/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift new file mode 100644 index 000000000..e388e24d0 --- /dev/null +++ b/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift @@ -0,0 +1,91 @@ +// +// LibSQLConnectionFieldsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("libSQL connection fields") +struct LibSQLConnectionFieldsTests { + private func libsqlFields() throws -> [ConnectionField] { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + let entry = try #require(defaults.first { $0.typeId == "libSQL" }) + return entry.snapshot.connection.additionalConnectionFields + } + + @Test("Registry entry declares mode, URL, and file path fields") + func registryDeclaresAllFields() throws { + let fields = try libsqlFields() + #expect(fields.map(\.id) == ["libsqlMode", "databaseUrl", "libsqlFilePath"]) + } + + @Test("Mode dropdown defaults to remote and offers a local option") + func modeDropdownDefaultsToRemote() throws { + let fields = try libsqlFields() + let mode = try #require(fields.first { $0.id == "libsqlMode" }) + #expect(mode.defaultValue == "remote") + guard case .dropdown(let options) = mode.fieldType else { + Issue.record("Expected a dropdown field type") + return + } + #expect(options.map(\.value) == ["remote", "local"]) + } + + @Test("Database URL is required and visible only in remote mode") + func databaseUrlVisibleOnlyForRemote() throws { + let fields = try libsqlFields() + let url = try #require(fields.first { $0.id == "databaseUrl" }) + #expect(url.isRequired) + #expect(url.visibleWhen == FieldVisibilityRule(fieldId: "libsqlMode", values: ["remote"])) + } + + @Test("File path is a required text field visible only in local mode") + func filePathVisibleOnlyForLocal() throws { + let fields = try libsqlFields() + let path = try #require(fields.first { $0.id == "libsqlFilePath" }) + #expect(path.isRequired) + #expect(path.fieldType == .text) + #expect(path.visibleWhen == FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"])) + } + + @Test("Password row stays for remote mode and hides for local mode") + func passwordHidingFollowsMode() throws { + let fields = try libsqlFields() + #expect(!fields.hidesPassword(forValues: [:])) + #expect(!fields.hidesPassword(forValues: ["libsqlMode": "remote"])) + #expect(fields.hidesPassword(forValues: ["libsqlMode": "local"])) + } + + @Test("Saved connections without a mode value resolve to remote visibility") + @MainActor + func missingModeValueShowsRemoteFields() throws { + let type = DatabaseType(rawValue: "libSQL") + let fields = try libsqlFields() + let url = try #require(fields.first { $0.id == "databaseUrl" }) + let path = try #require(fields.first { $0.id == "libsqlFilePath" }) + #expect(PluginFieldRendering.isFieldVisible(url, type: type, values: [:])) + #expect(!PluginFieldRendering.isFieldVisible(path, type: type, values: [:])) + } + + @Test("Local mode swaps field visibility") + @MainActor + func localModeSwapsVisibility() throws { + let type = DatabaseType(rawValue: "libSQL") + let fields = try libsqlFields() + let url = try #require(fields.first { $0.id == "databaseUrl" }) + let path = try #require(fields.first { $0.id == "libsqlFilePath" }) + let values = ["libsqlMode": "local"] + #expect(!PluginFieldRendering.isFieldVisible(url, type: type, values: values)) + #expect(PluginFieldRendering.isFieldVisible(path, type: type, values: values)) + } + + @Test("libSQL claims no file extensions so SQLite keeps file-open routing") + func noFileExtensionClaim() throws { + let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() + let entry = try #require(defaults.first { $0.typeId == "libSQL" }) + #expect(entry.snapshot.schema.fileExtensions.isEmpty) + } +} diff --git a/docs/databases/libsql.mdx b/docs/databases/libsql.mdx index 12c642592..7ac510da0 100644 --- a/docs/databases/libsql.mdx +++ b/docs/databases/libsql.mdx @@ -5,7 +5,7 @@ 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. +TablePro supports libSQL and Turso databases in two modes: remote connections via the Hrana HTTP protocol (Turso cloud databases and self-hosted sqld instances) and local libSQL database files opened directly from disk. ## Install Plugin @@ -24,8 +24,11 @@ The libSQL / Turso driver is available as a downloadable plugin. When you select Choose **libSQL / Turso** from the database type selector + + Pick **Remote (Turso)** for a Turso or sqld server, or **Local File** to open a database file on disk + - Fill in your Database URL and Auth Token + Remote: fill in your Database URL and Auth Token. Local: pick the database file with **Browse...** Click **Test Connection**, then **Create** @@ -34,7 +37,14 @@ The libSQL / Turso driver is available as a downloadable plugin. When you select ## Connection Settings -### Required Fields +### Connection Mode + +| Mode | Description | +|------|-------------| +| **Remote (Turso)** | Connects over HTTP using the Hrana protocol. For Turso cloud and self-hosted sqld. | +| **Local File** | Opens a libSQL database file directly from disk. Unencrypted libSQL files use the standard SQLite file format. | + +### Remote Fields | Field | Description | |-------|-------------| @@ -46,6 +56,17 @@ The libSQL / Turso driver is available as a downloadable plugin. When you select The Auth Token field is optional. Self-hosted sqld instances can run without authentication, in which case you can leave this field empty. +### Local File Fields + +| Field | Description | +|-------|-------------| +| **Name** | Connection identifier | +| **Database File** | Path to the database file. Use **Browse...** to pick it, or type a path (`~` is expanded). | + + +Do not open a file that is being synced as a Turso embedded replica while the owning app is running. Concurrent access to a syncing replica can corrupt it. + + ## Getting Your Credentials ### Turso Cloud @@ -92,6 +113,14 @@ Database URL: http://localhost:8080 Auth Token: (leave empty if no auth configured) ``` +### Local File + +``` +Name: My Local libSQL DB +Connection Mode: Local File +Database File: ~/Databases/app.db +``` + ## Features ### Database Browsing @@ -137,7 +166,7 @@ Analyze query execution plans using the Explain button in the query editor. Tabl ### 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. +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 in remote mode or directly against the file in local mode. ### Export @@ -192,11 +221,32 @@ libSQL uses SQLite syntax. See [SQLite](/databases/sqlite) for details. 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) +### File Not Found + +**Symptoms**: Local file connection fails to open + +**Solutions**: + +1. Check the file path is correct and the file still exists at that location +2. Use **Browse...** to re-pick the file if it was moved or renamed +3. Make sure the file is not an encrypted libSQL database; encrypted files cannot be opened in local mode + ## Known Limitations +Remote mode: + - 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 custom SSL/SSH tunnels: connections use HTTPS (Turso Cloud) or HTTP (local sqld) + +Local mode: + +- No embedded replica sync: local files open standalone, without syncing to a remote Turso database +- No encrypted database files +- libSQL-only SQL extensions (e.g., `ALTER TABLE ALTER COLUMN`) are unavailable; local mode uses the system SQLite engine + +Both modes: + - 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