From eeca7e2e6c1429dd237777f4d8eb89b057cdc6ad Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 28 May 2026 20:28:47 -0700 Subject: [PATCH 1/8] Add Beancount driver plugin --- .gitignore | 7 + .../BeancountLedgerParser.swift | 453 +++++++++++ .../BeancountPlugin.swift | 73 ++ .../BeancountPluginDriver.swift | 748 ++++++++++++++++++ Plugins/BeancountDriverPlugin/Info.plist | 8 + README.md | 1 + README.vi.md | 1 + TablePro.xcodeproj/project.pbxproj | 177 +++++ ...ginMetadataRegistry+RegistryDefaults.swift | 76 ++ TablePro/Info.plist | 34 + .../BeancountLedgerParser.swift | 1 + .../BeancountPluginDriver.swift | 1 + .../BeancountDriverMetadataTests.swift | 57 ++ .../Plugins/BeancountLedgerParserTests.swift | 92 +++ .../Plugins/BeancountPluginDriverTests.swift | 150 ++++ scripts/download-rustledger.sh | 146 ++++ 16 files changed, 2025 insertions(+) create mode 100644 Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift create mode 100644 Plugins/BeancountDriverPlugin/BeancountPlugin.swift create mode 100644 Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift create mode 100644 Plugins/BeancountDriverPlugin/Info.plist create mode 120000 TableProTests/PluginTestSources/BeancountLedgerParser.swift create mode 120000 TableProTests/PluginTestSources/BeancountPluginDriver.swift create mode 100644 TableProTests/Plugins/BeancountDriverMetadataTests.swift create mode 100644 TableProTests/Plugins/BeancountLedgerParserTests.swift create mode 100644 TableProTests/Plugins/BeancountPluginDriverTests.swift create mode 100755 scripts/download-rustledger.sh diff --git a/.gitignore b/.gitignore index 2c9051166..150b05c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -150,7 +150,14 @@ Libs/*.a Libs/.downloaded Libs/dylibs/ Libs/ios/ +Libs/rustledger/ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ + +# Local planning and assistant history +.specstory/ +.planning/ +.plans/ +planning/ diff --git a/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift b/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift new file mode 100644 index 000000000..44729400e --- /dev/null +++ b/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift @@ -0,0 +1,453 @@ +// +// BeancountLedgerParser.swift +// BeancountDriverPlugin +// + +import Foundation + +struct BeancountLedger: Sendable { + let transactions: [BeancountTransaction] + let postings: [BeancountPosting] + let accounts: [BeancountAccount] + let prices: [BeancountPrice] + let balances: [BeancountBalance] + let sourceFiles: [URL] +} + +struct BeancountTransaction: Sendable { + let id: Int + let date: String + let flag: String + let payee: String? + let narration: String? + let sourceFile: URL + let line: Int +} + +struct BeancountPosting: Sendable { + let id: Int + let transactionId: Int + let date: String + let account: String + let amount: String? + let commodity: String? + let sourceFile: URL + let line: Int +} + +struct BeancountAccount: Sendable { + let name: String + let openDate: String + let currencies: String? + let sourceFile: URL + let line: Int +} + +struct BeancountPrice: Sendable { + let id: Int + let date: String + let commodity: String + let amount: String + let currency: String + let sourceFile: URL + let line: Int +} + +struct BeancountBalance: Sendable { + let id: Int + let date: String + let account: String + let amount: String + let commodity: String + let sourceFile: URL + let line: Int +} + +enum BeancountParserError: LocalizedError { + case includeCycle(String) + case unreadable(URL, Error) + + var errorDescription: String? { + switch self { + case .includeCycle(let path): + return "Beancount include cycle detected at \(path)" + case .unreadable(let url, let error): + return "Could not read \(url.path): \(error.localizedDescription)" + } + } +} + +final class BeancountLedgerParser { + private var visited: Set = [] + private var activeStack: Set = [] + private var sourceFiles: [URL] = [] + private var transactions: [BeancountTransaction] = [] + private var postings: [BeancountPosting] = [] + private var accountsByName: [String: BeancountAccount] = [:] + private var prices: [BeancountPrice] = [] + private var balances: [BeancountBalance] = [] + + func parse(fileURL: URL) throws -> BeancountLedger { + visited.removeAll() + activeStack.removeAll() + sourceFiles.removeAll() + transactions.removeAll() + postings.removeAll() + accountsByName.removeAll() + prices.removeAll() + balances.removeAll() + + try parseFile(fileURL.standardizedFileURL) + + return BeancountLedger( + transactions: transactions, + postings: postings, + accounts: accountsByName.values.sorted { $0.name < $1.name }, + prices: prices, + balances: balances, + sourceFiles: sourceFiles + ) + } + + private func parseFile(_ url: URL) throws { + let normalized = url.standardizedFileURL + if activeStack.contains(normalized) { + throw BeancountParserError.includeCycle(normalized.path) + } + guard !visited.contains(normalized) else { return } + + activeStack.insert(normalized) + defer { activeStack.remove(normalized) } + + let contents: String + do { + contents = try String(contentsOf: normalized, encoding: .utf8) + } catch { + throw BeancountParserError.unreadable(normalized, error) + } + + visited.insert(normalized) + sourceFiles.append(normalized) + + let lines = contents.components(separatedBy: .newlines) + var index = 0 + while index < lines.count { + let lineNumber = index + 1 + let rawLine = lines[index] + let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + defer { index += 1 } + + guard !trimmed.isEmpty else { continue } + + if let includePath = parseInclude(trimmed) { + let includeURLs = try resolveIncludeURLs( + includePath, + relativeTo: normalized.deletingLastPathComponent() + ) + for includeURL in includeURLs { + try parseFile(includeURL) + } + continue + } + + guard let date = parseDatePrefix(trimmed) else { continue } + let remainder = String(trimmed.dropFirst(11)) + + if remainder.hasPrefix("open ") { + parseOpen(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) + } else if remainder.hasPrefix("price ") { + parsePrice(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) + } else if remainder.hasPrefix("balance ") { + parseBalance(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) + } else if let flag = remainder.first, flag == "*" || flag == "!" { + let transactionId = transactions.count + 1 + let transaction = parseTransaction( + id: transactionId, + remainder: remainder, + date: date, + sourceFile: normalized, + line: lineNumber + ) + transactions.append(transaction) + + var postingIndex = index + 1 + while postingIndex < lines.count { + let postingLine = lines[postingIndex] + guard postingLine.first?.isWhitespace == true else { break } + if let posting = parsePosting( + postingLine, + id: postings.count + 1, + transactionId: transactionId, + date: date, + sourceFile: normalized, + line: postingIndex + 1 + ) { + postings.append(posting) + } + postingIndex += 1 + } + index = postingIndex - 1 + } + } + } + + private func parseInclude(_ line: String) -> String? { + guard line.hasPrefix("include ") else { return nil } + return quotedStrings(in: line).first + } + + private func resolveIncludeURLs(_ includePath: String, relativeTo directory: URL) throws -> [URL] { + guard containsGlobPattern(includePath) else { + return [resolveIncludeURL(includePath, relativeTo: directory)] + } + + let patternURL = resolveIncludeURL(includePath, relativeTo: directory) + let patternPath = patternURL.path + let searchRoot = globSearchRoot(for: patternPath) + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: searchRoot.path) else { return [] } + + let regex = try NSRegularExpression(pattern: globRegex(for: patternPath)) + let enumerator = fileManager.enumerator( + at: searchRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + + var matches: [URL] = [] + while let candidate = enumerator?.nextObject() as? URL { + let values = try? candidate.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + + let path = candidate.standardizedFileURL.path + let range = NSRange(location: 0, length: (path as NSString).length) + if regex.firstMatch(in: path, range: range) != nil { + matches.append(candidate.standardizedFileURL) + } + } + + return matches.sorted { $0.path < $1.path } + } + + private func resolveIncludeURL(_ includePath: String, relativeTo directory: URL) -> URL { + if includePath.hasPrefix("/") { + return URL(fileURLWithPath: includePath).standardizedFileURL + } + return directory.appendingPathComponent(includePath).standardizedFileURL + } + + private func containsGlobPattern(_ path: String) -> Bool { + path.contains("*") || path.contains("?") || path.contains("[") + } + + private func globSearchRoot(for patternPath: String) -> URL { + let components = (patternPath as NSString).pathComponents + let prefix = components.prefix { !containsGlobPattern($0) } + let rootPath = NSString.path(withComponents: Array(prefix)) + return URL(fileURLWithPath: rootPath.isEmpty ? "/" : rootPath).standardizedFileURL + } + + private func globRegex(for patternPath: String) -> String { + let characters = Array(patternPath) + var regex = "^" + var index = 0 + + while index < characters.count { + let character = characters[index] + if character == "*" { + let nextIndex = index + 1 + if nextIndex < characters.count, characters[nextIndex] == "*" { + let slashIndex = index + 2 + if slashIndex < characters.count, characters[slashIndex] == "/" { + regex += "(?:.*/)?" + index += 3 + } else { + regex += ".*" + index += 2 + } + } else { + regex += "[^/]*" + index += 1 + } + } else if character == "?" { + regex += "[^/]" + index += 1 + } else if character == "[" { + let start = index + index += 1 + while index < characters.count, characters[index] != "]" { + index += 1 + } + if index < characters.count { + regex += String(characters[start...index]) + index += 1 + } else { + regex += NSRegularExpression.escapedPattern(for: String(character)) + } + } else { + regex += NSRegularExpression.escapedPattern(for: String(character)) + index += 1 + } + } + + return regex + "$" + } + + private func parseOpen(remainder: String, date: String, sourceFile: URL, line: Int) { + let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 2 else { return } + let account = parts[1] + let currencies = parts.dropFirst(2).joined(separator: " ") + accountsByName[account] = BeancountAccount( + name: account, + openDate: date, + currencies: currencies.isEmpty ? nil : currencies, + sourceFile: sourceFile, + line: line + ) + } + + private func parsePrice(remainder: String, date: String, sourceFile: URL, line: Int) { + let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 4 else { return } + prices.append(BeancountPrice( + id: prices.count + 1, + date: date, + commodity: parts[1], + amount: parts[2], + currency: parts[3], + sourceFile: sourceFile, + line: line + )) + } + + private func parseBalance(remainder: String, date: String, sourceFile: URL, line: Int) { + let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 4 else { return } + balances.append(BeancountBalance( + id: balances.count + 1, + date: date, + account: parts[1], + amount: parts[2], + commodity: parts[3], + sourceFile: sourceFile, + line: line + )) + } + + private func parseTransaction( + id: Int, + remainder: String, + date: String, + sourceFile: URL, + line: Int + ) -> BeancountTransaction { + let quoted = quotedStrings(in: remainder) + return BeancountTransaction( + id: id, + date: date, + flag: String(remainder.prefix(1)), + payee: quoted.count >= 2 ? quoted[0] : nil, + narration: quoted.count >= 2 ? quoted[1] : quoted.first, + sourceFile: sourceFile, + line: line + ) + } + + private func parsePosting( + _ rawLine: String, + id: Int, + transactionId: Int, + date: String, + sourceFile: URL, + line: Int + ) -> BeancountPosting? { + let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix(";"), !trimmed.hasPrefix("#") else { return nil } + + let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init) + guard let account = parts.first, account.contains(":") else { return nil } + let amount = parts.count >= 2 ? parts[1] : nil + let commodity = parts.count >= 3 ? parts[2] : nil + + return BeancountPosting( + id: id, + transactionId: transactionId, + date: date, + account: account, + amount: amount, + commodity: commodity, + sourceFile: sourceFile, + line: line + ) + } + + private func parseDatePrefix(_ line: String) -> String? { + guard line.count >= 11 else { return nil } + let prefix = String(line.prefix(10)) + let pattern = #"^\d{4}-\d{2}-\d{2}$"# + guard prefix.range(of: pattern, options: .regularExpression) != nil, + line.dropFirst(10).first?.isWhitespace == true else { + return nil + } + return prefix + } + + private func quotedStrings(in line: String) -> [String] { + var values: [String] = [] + var current = "" + var inQuote = false + var isEscaped = false + + for character in line { + if isEscaped { + current.append(character) + isEscaped = false + continue + } + if character == "\\" { + isEscaped = true + continue + } + if character == "\"" { + if inQuote { + values.append(current) + current = "" + } + inQuote.toggle() + continue + } + if inQuote { + current.append(character) + } + } + + return values + } + + private func stripComment(_ line: String) -> String { + var inQuote = false + var isEscaped = false + var result = "" + for character in line { + if isEscaped { + result.append(character) + isEscaped = false + continue + } + if character == "\\" { + result.append(character) + isEscaped = true + continue + } + if character == "\"" { + inQuote.toggle() + } + if character == ";" && !inQuote { + break + } + result.append(character) + } + return result + } +} diff --git a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift new file mode 100644 index 000000000..62e918995 --- /dev/null +++ b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift @@ -0,0 +1,73 @@ +// +// BeancountPlugin.swift +// BeancountDriverPlugin +// + +import Foundation +import TableProPluginKit + +final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Beancount Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Read-only Beancount ledger support" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Beancount" + static let databaseDisplayName = "Beancount" + static let iconName = "beancount-icon" + static let defaultPort = 0 + + static let isDownloadable = true + static let pathFieldRole: PathFieldRole = .filePath + static let requiresAuthentication = false + static let supportsSSH = false + static let supportsSSL = false + static let connectionMode: ConnectionMode = .fileBased + static let urlSchemes: [String] = ["beancount"] + static let fileExtensions: [String] = ["beancount"] + static let brandColorHex = "#3F7D20" + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = false + static let supportsSchemaSwitching = false + static let supportsImport = false + static let supportsHealthMonitor = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let tableEntityName = "Ledger Tables" + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["INTEGER"], + "String": ["TEXT"], + "Date": ["DATE"], + "Decimal": ["DECIMAL"] + ] + static let immutableColumns: [String] = [ + "id", "transaction_id", "date", "flag", "payee", "narration", + "account", "amount", "commodity", "currency", "source_file", "line" + ] + + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "WITH", "RECURSIVE", "UNION", "INTERSECT", "EXCEPT", + "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", + "ASC", "DESC", "DISTINCT" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "COALESCE", "NULLIF", "ROUND", "ABS", + "DATE", "STRFTIME", "SUBSTR", "LOWER", "UPPER" + ], + dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + BeancountPluginDriver(config: config) + } +} diff --git a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift new file mode 100644 index 000000000..f18b3154d --- /dev/null +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -0,0 +1,748 @@ +// +// BeancountPluginDriver.swift +// BeancountDriverPlugin +// + +import Foundation +import SQLite3 +import TableProPluginKit + +enum BeancountDriverError: LocalizedError { + case notConnected + case connectionFailed(String) + case queryFailed(String) + case readOnly + case rustledgerUnavailable + + var errorDescription: String? { + switch self { + case .notConnected: + return "Not connected to Beancount ledger" + case .connectionFailed(let message): + return "Failed to open Beancount ledger: \(message)" + case .queryFailed(let message): + return message + case .readOnly: + return "Beancount ledgers are exposed as a read-only SQL database" + case .rustledgerUnavailable: + return "BQL requires the bundled rustledger helper" + } + } +} + +extension BeancountDriverError: PluginDriverError { + var pluginErrorMessage: String { errorDescription ?? "Beancount driver error" } +} + +private struct BeancountSQLiteResult { + let columns: [String] + let columnTypeNames: [String] + let rows: [[PluginCellValue]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +private struct BeancountSourceSignature: Equatable { + let modificationDate: Date? + let fileSize: UInt64? +} + +final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private let lock = NSLock() + private var db: OpaquePointer? + private var ledgerURL: URL? + private var ledger: BeancountLedger? + private var sourceSignatures: [String: BeancountSourceSignature] = [:] + + var currentSchema: String? { nil } + var serverVersion: String? { "Beancount" } + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var parameterStyle: ParameterStyle { .questionMark } + + init(config: DriverConnectionConfig) { + self.config = config + } + + func connect() async throws { + let path = expandPath(config.database) + let fileURL = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { + throw BeancountDriverError.connectionFailed("File does not exist at \(path)") + } + + let parsed = try BeancountLedgerParser().parse(fileURL: fileURL) + let signatures = try Self.signatures(for: parsed.sourceFiles) + var handle: OpaquePointer? + guard sqlite3_open(":memory:", &handle) == SQLITE_OK, let handle else { + throw BeancountDriverError.connectionFailed("Could not initialize SQL projection") + } + + do { + try Self.load(parsed, into: handle) + } catch { + sqlite3_close(handle) + throw error + } + + lock.withLock { + db = handle + ledgerURL = fileURL + ledger = parsed + sourceSignatures = signatures + } + } + + func disconnect() { + lock.withLock { + if db != nil { + sqlite3_close(db) + db = nil + } + ledgerURL = nil + ledger = nil + sourceSignatures.removeAll() + } + } + + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + + func beginTransaction() async throws { + throw BeancountDriverError.readOnly + } + + func commitTransaction() async throws { + throw BeancountDriverError.readOnly + } + + func rollbackTransaction() async throws { + throw BeancountDriverError.readOnly + } + + func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + func escapeStringLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + func execute(query: String) async throws -> PluginQueryResult { + if let bql = Self.extractBQLQuery(from: query) { + return try executeBQL(query: bql) + } + let raw = try executeSQLite(query: query, parameters: []) + return PluginQueryResult( + columns: raw.columns, + columnTypeNames: raw.columnTypeNames, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated + ) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + if Self.extractBQLQuery(from: query) != nil { + throw BeancountDriverError.queryFailed("BQL queries do not support SQL parameters") + } + let raw = try executeSQLite(query: query, parameters: parameters) + return PluginQueryResult( + columns: raw.columns, + columnTypeNames: raw.columnTypeNames, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated + ) + } + + func fetchRowCount(query: String) async throws -> Int { + let escaped = query.replacingOccurrences(of: ";", with: "") + let result = try await execute(query: "SELECT COUNT(*) FROM (\(escaped))") + guard let text = result.rows.first?.first?.asText, let count = Int(text) else { return 0 } + return count + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + try await execute(query: "SELECT * FROM (\(query)) LIMIT \(limit) OFFSET \(offset)") + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let result = try await execute(query: """ + SELECT name, type FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY name + """) + return result.rows.compactMap { row in + guard let name = row[safe: 0]?.asText else { return nil } + let type = row[safe: 1]?.asText?.uppercased() ?? "TABLE" + return PluginTableInfo(name: name, type: type) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let result = try await execute(query: "PRAGMA table_info('\(escapeStringLiteral(table))')") + return result.rows.compactMap { row in + guard row.count >= 6, + let name = row[1].asText, + let type = row[2].asText else { + return nil + } + return PluginColumnInfo( + name: name, + dataType: type, + isNullable: row[3].asText == "0", + isPrimaryKey: (row[5].asText ?? "0") != "0", + defaultValue: row[4].asText + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let result = try await execute(query: """ + SELECT sql FROM sqlite_master + WHERE type = 'table' AND name = '\(escapeStringLiteral(table))' + """) + guard let ddl = result.rows.first?.first?.asText else { + throw BeancountDriverError.queryFailed("Failed to fetch DDL for table '\(table)'") + } + return ddl.hasSuffix(";") ? ddl : ddl + ";" + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let result = try await execute(query: """ + SELECT sql FROM sqlite_master + WHERE type = 'view' AND name = '\(escapeStringLiteral(view))' + """) + return result.rows.first?.first?.asText ?? "" + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let result = try await execute(query: "SELECT COUNT(*) FROM \(quoteIdentifier(table))") + let rowCount = result.rows.first?.first?.asText.flatMap(Int64.init) + return PluginTableMetadata(tableName: table, rowCount: rowCount, engine: "Beancount") + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + let result = try await execute(query: "SELECT COUNT(*) FROM \(quoteIdentifier(table))") + return result.rows.first?.first?.asText.flatMap(Int.init) + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + var query = "SELECT * FROM \(quoteIdentifier(table))" + if !sortColumns.isEmpty, !columns.isEmpty { + let order = sortColumns.compactMap { sort -> String? in + guard columns.indices.contains(sort.columnIndex) else { return nil } + return "\(quoteIdentifier(columns[sort.columnIndex])) \(sort.ascending ? "ASC" : "DESC")" + } + if !order.isEmpty { + query += " ORDER BY " + order.joined(separator: ", ") + } + } + query += " LIMIT \(limit) OFFSET \(offset)" + return query + } + + func defaultExportQuery(table: String) -> String? { + "SELECT * FROM \(quoteIdentifier(table))" + } + + func streamRows(query: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + do { + let result: BeancountSQLiteResult + if let bql = Self.extractBQLQuery(from: query) { + let bqlResult = try executeBQL(query: bql) + result = BeancountSQLiteResult( + columns: bqlResult.columns, + columnTypeNames: bqlResult.columnTypeNames, + rows: bqlResult.rows, + rowsAffected: bqlResult.rowsAffected, + executionTime: bqlResult.executionTime, + isTruncated: bqlResult.isTruncated + ) + } else { + result = try executeSQLite(query: query, parameters: []) + } + continuation.yield(.header(PluginStreamHeader( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + estimatedRowCount: result.rows.count + ))) + continuation.yield(.rows(result.rows)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + + private func executeBQL(query: String) throws -> PluginQueryResult { + let ledgerPath = try lock.withLock { () -> String in + guard let ledgerURL else { throw BeancountDriverError.notConnected } + return ledgerURL.path + } + let rustledgerPath = try Self.rustledgerExecutablePath() + let start = Date() + + let process = Process() + process.executableURL = URL(fileURLWithPath: rustledgerPath) + process.arguments = ["query", "-f", "json", "--no-errors", ledgerPath, query] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let message = String(data: errorOutput, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw BeancountDriverError.queryFailed(message?.isEmpty == false ? message! : "rustledger query failed") + } + + return try Self.decodeRustledgerQueryOutput( + output, + executionTime: Date().timeIntervalSince(start) + ) + } + + private func executeSQLite(query: String, parameters: [PluginCellValue]) throws -> BeancountSQLiteResult { + guard Self.isReadOnlyQuery(query) else { + throw BeancountDriverError.readOnly + } + + return try lock.withLock { + try reloadProjectionIfNeeded() + guard let db = self.db else { throw BeancountDriverError.notConnected } + + let start = Date() + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { + throw BeancountDriverError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(statement) } + + for (index, parameter) in parameters.enumerated() { + let position = Int32(index + 1) + switch parameter { + case .null: + sqlite3_bind_null(statement, position) + case .text(let value): + sqlite3_bind_text(statement, position, value, -1, SQLITE_TRANSIENT) + case .bytes(let data): + _ = data.withUnsafeBytes { buffer in + sqlite3_bind_blob(statement, position, buffer.baseAddress, Int32(data.count), SQLITE_TRANSIENT) + } + } + } + + let columnCount = sqlite3_column_count(statement) + let columns = (0.. String in + sqlite3_column_name(statement, index).map { String(cString: $0) } ?? "column_\(index)" + } + let columnTypeNames = (0.. String in + sqlite3_column_decltype(statement, index).map { String(cString: $0) } ?? "" + } + + var rows: [[PluginCellValue]] = [] + var truncated = false + + while true { + let step = sqlite3_step(statement) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { + throw BeancountDriverError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + if rows.count >= PluginRowLimits.emergencyMax { + truncated = true + break + } + rows.append((0.. PluginCellValue { + let type = sqlite3_column_type(statement, column) + if type == SQLITE_NULL { + return .null + } + if type == SQLITE_BLOB { + let byteCount = Int(sqlite3_column_bytes(statement, column)) + guard byteCount > 0, let blob = sqlite3_column_blob(statement, column) else { + return .bytes(Data()) + } + return .bytes(Data(bytes: blob, count: byteCount)) + } + guard let text = sqlite3_column_text(statement, column) else { + return .null + } + return .text(String(cString: text)) + } + + private static func isReadOnlyQuery(_ query: String) -> Bool { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + let lower = trimmed.lowercased() + return lower.hasPrefix("select") + || lower.hasPrefix("with") + || lower.hasPrefix("pragma table_info") + || lower.hasPrefix("pragma database_list") + || lower.hasPrefix("explain") + } + + private static func extractBQLQuery(from query: String) -> String? { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercased = trimmed.lowercased() + if lowercased.hasPrefix("bql:") { + return String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + } + if lowercased.hasPrefix("bql ") { + return String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func rustledgerExecutablePath() throws -> String { + let environment = ProcessInfo.processInfo.environment + if let path = environment["TABLEPRO_RUSTLEDGER_BINARY"], + FileManager.default.isExecutableFile(atPath: path) { + return path + } + + let bundleCandidates = [ + Bundle(for: BeancountPluginDriver.self).url(forResource: "rledger", withExtension: nil)?.path, + Bundle.main.builtInPlugInsURL? + .appendingPathComponent("BeancountDriver.tableplugin") + .appendingPathComponent("Contents/Resources/rledger") + .path + ].compactMap { $0 } + if let path = bundleCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { + return path + } + + let pathCandidates = [ + "/opt/homebrew/bin/rledger", + "/usr/local/bin/rledger" + ] + (environment["PATH"] ?? "") + .split(separator: ":") + .map { "\($0)/rledger" } + + if let path = pathCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { + return path + } + + throw BeancountDriverError.rustledgerUnavailable + } + + private static func decodeRustledgerQueryOutput( + _ data: Data, + executionTime: TimeInterval + ) throws -> PluginQueryResult { + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any], + let columns = dictionary["columns"] as? [String], + let rawRows = dictionary["rows"] as? [[String: Any]] else { + throw BeancountDriverError.queryFailed("Invalid rustledger JSON output") + } + + let rows = rawRows.prefix(PluginRowLimits.emergencyMax).map { rawRow in + columns.map { column -> PluginCellValue in + guard let value = rawRow[column], !(value is NSNull) else { return .null } + return .text(rustledgerCellValue(value)) + } + } + + return PluginQueryResult( + columns: columns, + columnTypeNames: Array(repeating: "TEXT", count: columns.count), + rows: rows, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: rawRows.count > rows.count + ) + } + + private static func rustledgerCellValue(_ value: Any) -> String { + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + if let amount = value as? [String: Any], + let number = amount["number"] as? String, + let currency = amount["currency"] as? String { + return "\(number) \(currency)" + } + if let inventory = value as? [String: Any], + let positions = inventory["positions"] as? [[String: Any]] { + return positions.compactMap { position in + guard let number = position["number"] as? String, + let currency = position["currency"] as? String else { + return nil + } + return "\(number) \(currency)" + }.joined(separator: ", ") + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) { + return string + } + return String(describing: value) + } + + private static func load(_ ledger: BeancountLedger, into db: OpaquePointer) throws { + try exec(db, """ + CREATE TABLE transactions ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + flag TEXT NOT NULL, + payee TEXT, + narration TEXT, + source_file TEXT NOT NULL, + line INTEGER NOT NULL + ); + CREATE TABLE postings ( + id INTEGER PRIMARY KEY, + transaction_id INTEGER NOT NULL, + date DATE NOT NULL, + account TEXT NOT NULL, + amount DECIMAL, + commodity TEXT, + source_file TEXT NOT NULL, + line INTEGER NOT NULL + ); + CREATE TABLE accounts ( + name TEXT PRIMARY KEY, + open_date DATE NOT NULL, + currencies TEXT, + source_file TEXT NOT NULL, + line INTEGER NOT NULL + ); + CREATE TABLE prices ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + commodity TEXT NOT NULL, + amount DECIMAL NOT NULL, + currency TEXT NOT NULL, + source_file TEXT NOT NULL, + line INTEGER NOT NULL + ); + CREATE TABLE balances ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + account TEXT NOT NULL, + amount DECIMAL NOT NULL, + commodity TEXT NOT NULL, + source_file TEXT NOT NULL, + line INTEGER NOT NULL + ); + CREATE TABLE source_files ( + path TEXT PRIMARY KEY + ); + """) + + for transaction in ledger.transactions { + try insert(db, sql: """ + INSERT INTO transactions (id, date, flag, payee, narration, source_file, line) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, values: [ + String(transaction.id), + transaction.date, + transaction.flag, + transaction.payee, + transaction.narration, + transaction.sourceFile.path, + String(transaction.line) + ]) + } + for posting in ledger.postings { + try insert(db, sql: """ + INSERT INTO postings (id, transaction_id, date, account, amount, commodity, source_file, line) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, values: [ + String(posting.id), + String(posting.transactionId), + posting.date, + posting.account, + posting.amount, + posting.commodity, + posting.sourceFile.path, + String(posting.line) + ]) + } + for account in ledger.accounts { + try insert(db, sql: """ + INSERT INTO accounts (name, open_date, currencies, source_file, line) + VALUES (?, ?, ?, ?, ?) + """, values: [ + account.name, + account.openDate, + account.currencies, + account.sourceFile.path, + String(account.line) + ]) + } + for price in ledger.prices { + try insert(db, sql: """ + INSERT INTO prices (id, date, commodity, amount, currency, source_file, line) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, values: [ + String(price.id), + price.date, + price.commodity, + price.amount, + price.currency, + price.sourceFile.path, + String(price.line) + ]) + } + for balance in ledger.balances { + try insert(db, sql: """ + INSERT INTO balances (id, date, account, amount, commodity, source_file, line) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, values: [ + String(balance.id), + balance.date, + balance.account, + balance.amount, + balance.commodity, + balance.sourceFile.path, + String(balance.line) + ]) + } + for sourceFile in ledger.sourceFiles { + try insert(db, sql: "INSERT INTO source_files (path) VALUES (?)", values: [sourceFile.path]) + } + + try exec(db, "PRAGMA query_only = ON") + } + + private static func exec(_ db: OpaquePointer, _ sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? String(cString: sqlite3_errmsg(db)) + if error != nil { + sqlite3_free(error) + } + throw BeancountDriverError.queryFailed(message) + } + } + + private static func insert(_ db: OpaquePointer, sql: String, values: [String?]) throws { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw BeancountDriverError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(statement) } + + for (index, value) in values.enumerated() { + let position = Int32(index + 1) + if let value { + sqlite3_bind_text(statement, position, value, -1, SQLITE_TRANSIENT) + } else { + sqlite3_bind_null(statement, position) + } + } + + guard sqlite3_step(statement) == SQLITE_DONE else { + throw BeancountDriverError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + } + + private static func signatures(for sourceFiles: [URL]) throws -> [String: BeancountSourceSignature] { + try sourceFiles.reduce(into: [:]) { signatures, fileURL in + let path = fileURL.path + let attributes = try FileManager.default.attributesOfItem(atPath: path) + signatures[path] = BeancountSourceSignature( + modificationDate: attributes[.modificationDate] as? Date, + fileSize: (attributes[.size] as? NSNumber)?.uint64Value + ) + } + } + + private func expandPath(_ path: String) -> String { + guard path.hasPrefix("~") else { return path } + return NSString(string: path).expandingTildeInPath + } +} + +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist new file mode 100644 index 000000000..c48cad80a --- /dev/null +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 16 + + diff --git a/README.md b/README.md index 8abab9c1e..e8375cd6c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ TablePro is the missing fourth: native, multi-database, and open source. | MongoDB | Plugin | | Oracle Database | Plugin | | DuckDB | Plugin | +| Beancount | Plugin | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/README.vi.md b/README.vi.md index 19ab87eb4..458911e77 100644 --- a/README.vi.md +++ b/README.vi.md @@ -82,6 +82,7 @@ TablePro là mảnh thứ tư còn thiếu: native, đa database, và mã nguồ | MongoDB | Plugin | | Oracle Database | Plugin | | DuckDB | Plugin | +| Beancount | Plugin | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 9c9b74a42..2178f46a3 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ 5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5ABC147400000000000002 /* BeancountDriver.tableplugin in Copy Plug-Ins (13 items) */ = {isa = PBXBuildFile; fileRef = 5ABC147400000000000003 /* BeancountDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -146,6 +148,20 @@ remoteGlobalIDString = 5A869000000000000; remoteInfo = DuckDBDriver; }; + 5ABC147400000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5ABC147400000000000005; + remoteInfo = BeancountDriver; + }; + 5ABC14740000000000000F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A860000000000000; + remoteInfo = TableProPluginKit; + }; 5A86A000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -250,6 +266,7 @@ 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5ABC147400000000000002 /* BeancountDriver.tableplugin in Copy Plug-Ins (13 items) */, 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins (12 items) */, @@ -288,6 +305,7 @@ 5A867000100000000 /* RedisDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RedisDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A868000100000000 /* PostgreSQLDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostgreSQLDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A869000100000000 /* DuckDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DuckDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5ABC147400000000000003 /* BeancountDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BeancountDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86A000100000000 /* CSVExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86B000100000000 /* JSONExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86C000100000000 /* SQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -430,6 +448,13 @@ ); target = 5A869000000000000 /* DuckDBDriver */; }; + 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5ABC147400000000000005 /* BeancountDriver */; + }; 5A86A000900000000 /* Exceptions for "Plugins/CSVExportPlugin" folder in "CSVExport" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -608,6 +633,14 @@ path = Plugins/DuckDBDriverPlugin; sourceTree = ""; }; + 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5ABC147400000000000006 /* Exceptions for "Plugins/BeancountDriverPlugin" folder in "BeancountDriver" target */, + ); + path = Plugins/BeancountDriverPlugin; + sourceTree = ""; + }; 5A86A000500000000 /* Plugins/CSVExportPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -797,6 +830,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC147400000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000300000000 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -930,6 +971,7 @@ 5A867000500000000 /* Plugins/RedisDriverPlugin */, 5A868000500000000 /* Plugins/PostgreSQLDriverPlugin */, 5A869000500000000 /* Plugins/DuckDBDriverPlugin */, + 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */, 5A87A000500000000 /* Plugins/CassandraDriverPlugin */, 5A86A000500000000 /* Plugins/CSVExportPlugin */, 5ABBED7C2FB55E1400A78382 /* Plugins/CSVInspectorPlugin */, @@ -960,6 +1002,7 @@ 5A867000100000000 /* RedisDriver.tableplugin */, 5A868000100000000 /* PostgreSQLDriver.tableplugin */, 5A869000100000000 /* DuckDBDriver.tableplugin */, + 5ABC147400000000000003 /* BeancountDriver.tableplugin */, 5A87A000100000000 /* CassandraDriver.tableplugin */, 5A86A000100000000 /* CSVExport.tableplugin */, 5A86B000100000000 /* JSONExport.tableplugin */, @@ -1066,6 +1109,7 @@ 5A867000C00000000 /* PBXTargetDependency */, 5A868000C00000000 /* PBXTargetDependency */, 5A869000C00000000 /* PBXTargetDependency */, + 5ABC14740000000000000C /* PBXTargetDependency */, 5A86A000C00000000 /* PBXTargetDependency */, 5A86B000C00000000 /* PBXTargetDependency */, 5A86C000C00000000 /* PBXTargetDependency */, @@ -1343,6 +1387,28 @@ productReference = 5A869000100000000 /* DuckDBDriver.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5ABC147400000000000005 /* BeancountDriver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */; + buildPhases = ( + 5ABC14740000000000000A /* Sources */, + 5ABC147400000000000008 /* Frameworks */, + 5ABC14740000000000000B /* Resources */, + 5ABC147400000000000012 /* Bundle RustLedger */, + ); + buildRules = ( + ); + dependencies = ( + 5ABC147400000000000010 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */, + ); + name = BeancountDriver; + productName = BeancountDriver; + productReference = 5ABC147400000000000003 /* BeancountDriver.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5A86A000000000000 /* CSVExport */ = { isa = PBXNativeTarget; buildConfigurationList = 5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */; @@ -1652,6 +1718,9 @@ 5A869000000000000 = { CreatedOnToolsVersion = 26.2; }; + 5ABC147400000000000005 = { + CreatedOnToolsVersion = 26.5; + }; 5A86A000000000000 = { CreatedOnToolsVersion = 26.2; }; @@ -1717,6 +1786,7 @@ 5A867000000000000 /* RedisDriver */, 5A868000000000000 /* PostgreSQLDriver */, 5A869000000000000 /* DuckDBDriver */, + 5ABC147400000000000005 /* BeancountDriver */, 5A87A000000000000 /* CassandraDriver */, 5A86A000000000000 /* CSVExport */, 5A86B000000000000 /* JSONExport */, @@ -1821,6 +1891,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC14740000000000000B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000400000000 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1914,6 +1991,30 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 5ABC147400000000000012 /* Bundle RustLedger */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/scripts/download-rustledger.sh", + ); + name = "Bundle RustLedger"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/rledger", + "$(TARGET_TEMP_DIR)/rustledger-cache", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "TABLEPRO_RUSTLEDGER_CACHE=\"$TARGET_TEMP_DIR/rustledger-cache\" \"$SRCROOT/scripts/download-rustledger.sh\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/rledger\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 5A1091C32EF17EDC0055EA7C /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -2006,6 +2107,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC14740000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000200000000 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2172,6 +2280,16 @@ target = 5A869000000000000 /* DuckDBDriver */; targetProxy = 5A869000B00000000 /* PBXContainerItemProxy */; }; + 5ABC14740000000000000C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5ABC147400000000000005 /* BeancountDriver */; + targetProxy = 5ABC147400000000000004 /* PBXContainerItemProxy */; + }; + 5ABC147400000000000010 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A860000000000000 /* TableProPluginKit */; + targetProxy = 5ABC14740000000000000F /* PBXContainerItemProxy */; + }; 5A86A000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A86A000000000000 /* CSVExport */; @@ -3253,6 +3371,56 @@ }; name = Release; }; + 5ABC14740000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5ABC14740000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/BeancountDriverPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).BeancountPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.BeancountDriver; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5A86A000600000000 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4035,6 +4203,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5ABC147400000000000009 /* Build configuration list for PBXNativeTarget "BeancountDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5ABC14740000000000000D /* Debug */, + 5ABC14740000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 7637a0b4e..652868123 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -855,6 +855,82 @@ extension PluginMetadataRegistry { tagline: String(localized: "Embedded analytical SQL") ) )), + ("Beancount", PluginMetadataSnapshot( + displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "beancount", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + supportsHealthMonitor: false, urlSchemes: ["beancount"], postConnectActions: [], + brandColorHex: "#3F7D20", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Ledger Tables", + defaultPrimaryKeyColumn: nil, + immutableColumns: [ + "id", "transaction_id", "date", "flag", "payee", "narration", + "account", "amount", "commodity", "currency", "source_file", "line" + ], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: ["beancount"], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "WITH", "UNION", "INTERSECT", "EXCEPT", + "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", + "ASC", "DESC", "DISTINCT" + ], + functions: ["COUNT", "SUM", "AVG", "MAX", "MIN", "ROUND", "DATE", "STRFTIME"], + dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ), + statementCompletions: [], + columnTypesByCategory: [ + "Integer": ["INTEGER"], + "String": ["TEXT"], + "Date": ["DATE"], + "Decimal": ["DECIMAL"] + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .analytical, + tagline: String(localized: "Plain-text accounting ledgers") + ) + )), ("Cassandra", PluginMetadataSnapshot( displayName: "Cassandra / ScyllaDB", iconName: "cassandra-icon", defaultPort: 9_042, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, diff --git a/TablePro/Info.plist b/TablePro/Info.plist index fe3c5d7ae..24622cdcd 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -103,6 +103,22 @@ com.tablepro.duckdb + + CFBundleTypeName + Beancount Ledger + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + CFBundleTypeExtensions + + beancount + + LSItemContentTypes + + com.tablepro.beancount + + CFBundleTypeExtensions @@ -202,6 +218,24 @@ + + UTTypeIdentifier + com.tablepro.beancount + UTTypeDescription + Beancount Ledger + UTTypeConformsTo + + public.plain-text + public.data + + UTTypeTagSpecification + + public.filename-extension + + beancount + + + UTTypeIdentifier com.tablepro.connection-share diff --git a/TableProTests/PluginTestSources/BeancountLedgerParser.swift b/TableProTests/PluginTestSources/BeancountLedgerParser.swift new file mode 120000 index 000000000..80626376b --- /dev/null +++ b/TableProTests/PluginTestSources/BeancountLedgerParser.swift @@ -0,0 +1 @@ +../../Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/BeancountPluginDriver.swift b/TableProTests/PluginTestSources/BeancountPluginDriver.swift new file mode 120000 index 000000000..24339cc9c --- /dev/null +++ b/TableProTests/PluginTestSources/BeancountPluginDriver.swift @@ -0,0 +1 @@ +../../Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift \ No newline at end of file diff --git a/TableProTests/Plugins/BeancountDriverMetadataTests.swift b/TableProTests/Plugins/BeancountDriverMetadataTests.swift new file mode 100644 index 000000000..ed892cb81 --- /dev/null +++ b/TableProTests/Plugins/BeancountDriverMetadataTests.swift @@ -0,0 +1,57 @@ +// +// BeancountDriverMetadataTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("Beancount driver metadata") +struct BeancountDriverMetadataTests { + @Test("registry exposes Beancount as a downloadable file-based driver") + func registryMetadata() throws { + let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forTypeId: "Beancount")) + #expect(snapshot.displayName == "Beancount") + #expect(snapshot.isDownloadable == true) + #expect(snapshot.connectionMode == .fileBased) + #expect(snapshot.schema.fileExtensions == ["beancount"]) + #expect(snapshot.pathFieldRole == .filePath) + #expect(snapshot.supportsSchemaEditing == false) + #expect(snapshot.supportsDatabaseSwitching == false) + #expect(snapshot.supportsHealthMonitor == false) + } + + @Test("URLClassifier resolves .beancount files to the Beancount database type") + func urlClassifierResolvesBeancountFiles() { + #expect(PluginManager.shared.allRegisteredFileExtensions["beancount"] == DatabaseType(rawValue: "Beancount")) + } + + @Test("app bundle claims .beancount files as the owner viewer") + func appBundleClaimsBeancountFilesAsOwnerViewer() throws { + let plistURL = Bundle(for: AppDelegate.self) + .bundleURL + .appendingPathComponent("Contents/Info.plist") + let data = try Data(contentsOf: plistURL) + let plistObject = try PropertyListSerialization.propertyList(from: data, format: nil) + let plist = try #require(plistObject as? [String: Any]) + let documentTypes = try #require(plist["CFBundleDocumentTypes"] as? [[String: Any]]) + let beancountDocumentType = try #require(documentTypes.first { documentType in + let contentTypes = documentType["LSItemContentTypes"] as? [String] + return contentTypes?.contains("com.tablepro.beancount") == true + }) + + #expect(beancountDocumentType["CFBundleTypeRole"] as? String == "Viewer") + #expect(beancountDocumentType["LSHandlerRank"] as? String == "Owner") + #expect(beancountDocumentType["CFBundleTypeExtensions"] as? [String] == ["beancount"]) + + let exportedTypes = try #require(plist["UTExportedTypeDeclarations"] as? [[String: Any]]) + let beancountType = try #require(exportedTypes.first { + $0["UTTypeIdentifier"] as? String == "com.tablepro.beancount" + }) + let tags = try #require(beancountType["UTTypeTagSpecification"] as? [String: Any]) + #expect(tags["public.filename-extension"] as? [String] == ["beancount"]) + } +} diff --git a/TableProTests/Plugins/BeancountLedgerParserTests.swift b/TableProTests/Plugins/BeancountLedgerParserTests.swift new file mode 100644 index 000000000..e57549682 --- /dev/null +++ b/TableProTests/Plugins/BeancountLedgerParserTests.swift @@ -0,0 +1,92 @@ +// +// BeancountLedgerParserTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("Beancount ledger parser") +struct BeancountLedgerParserTests { + @Test("loads transactions, postings, accounts, prices, balances, and includes") + func parsesCoreTablesAndIncludes() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let included = tempDirectory.appendingPathComponent("prices.beancount") + try """ + 2024-01-02 price USD 1.35 CAD + 2024-01-31 balance Assets:Bank:Checking 900.00 USD + """.write(to: included, atomically: true, encoding: .utf8) + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + option "title" "Household" + include "prices.beancount" + + 2024-01-01 open Assets:Bank:Checking USD + 2024-01-01 open Expenses:Food USD + + 2024-01-15 * "Grocery Store" "Weekly shop" + Assets:Bank:Checking -100.00 USD + Expenses:Food 100.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let parsed = try BeancountLedgerParser().parse(fileURL: ledger) + + #expect(parsed.accounts.map(\.name).sorted() == ["Assets:Bank:Checking", "Expenses:Food"]) + #expect(parsed.transactions.map(\.payee) == ["Grocery Store"]) + #expect(parsed.transactions.map(\.narration) == ["Weekly shop"]) + #expect(parsed.postings.count == 2) + #expect(parsed.prices.first?.commodity == "USD") + #expect(parsed.prices.first?.currency == "CAD") + #expect(parsed.balances.first?.account == "Assets:Bank:Checking") + #expect(parsed.sourceFiles.map(\.lastPathComponent).sorted() == ["main.beancount", "prices.beancount"]) + } + + @Test("expands glob include paths") + func parsesGlobIncludes() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let imports = tempDirectory.appendingPathComponent("imports", isDirectory: true) + let nested = imports.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + + try """ + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) + + try """ + 2024-01-01 open Expenses:Food USD + """.write(to: nested.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + include "imports/*.beancount" + include "imports/**/*.beancount" + + 2024-01-15 * "Grocery Store" "Weekly shop" + Assets:Bank:Checking -100.00 USD + Expenses:Food 100.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let parsed = try BeancountLedgerParser().parse(fileURL: ledger) + + #expect(parsed.accounts.map(\.name).sorted() == ["Assets:Bank:Checking", "Expenses:Food"]) + #expect(parsed.transactions.count == 1) + #expect(parsed.sourceFiles.map(\.lastPathComponent).sorted() == [ + "accounts.beancount", + "expenses.beancount", + "main.beancount" + ]) + } +} diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift new file mode 100644 index 000000000..844e346d0 --- /dev/null +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -0,0 +1,150 @@ +// +// BeancountPluginDriverTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Beancount plugin driver") +struct BeancountPluginDriverTests { + @Test("reloads the SQL projection when an included ledger file changes") + func reloadsWhenIncludedFileChanges() async throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-driver-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let included = tempDirectory.appendingPathComponent("accounts.beancount") + try """ + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: included, atomically: true, encoding: .utf8) + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + include "accounts.beancount" + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: DriverConnectionConfig( + host: "", + port: 0, + username: "", + password: "", + database: ledger.path + )) + try await driver.connect() + defer { + driver.disconnect() + } + + var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) + + try """ + 2024-01-01 open Assets:Bank:Checking USD + 2024-01-02 open Expenses:Food USD + """.write(to: included, atomically: true, encoding: .utf8) + + result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + } + + @Test("rejects write queries") + func rejectsWriteQueries() async throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-driver-read-only-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: DriverConnectionConfig( + host: "", + port: 0, + username: "", + password: "", + database: ledger.path + )) + try await driver.connect() + defer { + driver.disconnect() + } + + await #expect(throws: BeancountDriverError.self) { + _ = try await driver.execute(query: "DELETE FROM accounts") + } + } + + @Test("executes BQL queries through the rustledger helper") + func executesBQLQueriesThroughRustledgerHelper() async throws { + let rustledger = try #require(Self.bundledRustledgerPath() ?? Self.installedRustledgerPath()) + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-driver-bql-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + unsetenv("TABLEPRO_RUSTLEDGER_BINARY") + try? FileManager.default.removeItem(at: tempDirectory) + } + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Bank:Checking USD + 2024-01-01 open Expenses:Food USD + 2024-01-01 open Income:Salary USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + setenv("TABLEPRO_RUSTLEDGER_BINARY", rustledger, 1) + + let driver = BeancountPluginDriver(config: DriverConnectionConfig( + host: "", + port: 0, + username: "", + password: "", + database: ledger.path + )) + try await driver.connect() + defer { + driver.disconnect() + } + + let result = try await driver.execute(query: "BQL: SELECT account FROM accounts ORDER BY account") + + #expect(result.columns == ["account"]) + #expect(result.rows.map { $0.first?.asText } == [ + "Assets:Bank:Checking", + "Expenses:Food", + "Income:Salary" + ]) + } + + private static func installedRustledgerPath() -> String? { + let candidates = [ + ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"], + "/opt/homebrew/bin/rledger", + "/usr/local/bin/rledger" + ].compactMap { $0 } + + return candidates.first { path in + FileManager.default.isExecutableFile(atPath: path) + } + } + + private static func bundledRustledgerPath() -> String? { + guard let path = Bundle.main.builtInPlugInsURL? + .appendingPathComponent("BeancountDriver.tableplugin") + .appendingPathComponent("Contents/Resources/rledger") + .path else { + return nil + } + + return FileManager.default.isExecutableFile(atPath: path) ? path : nil + } +} diff --git a/scripts/download-rustledger.sh b/scripts/download-rustledger.sh new file mode 100755 index 000000000..644476058 --- /dev/null +++ b/scripts/download-rustledger.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bundle the rustledger CLI helper used by the Beancount driver for BQL queries. +# Usage: scripts/download-rustledger.sh [output-rledger-path] + +VERSION="v0.15.0" +REPO="rustledger/rustledger" +PROJECT_ROOT="${SRCROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +CACHE_ROOT="${TABLEPRO_RUSTLEDGER_CACHE:-$PROJECT_ROOT/Libs/rustledger}" +OUTPUT="${1:-${TARGET_BUILD_DIR:?}/${UNLOCALIZED_RESOURCES_FOLDER_PATH:?}/rledger}" + +triple_for_arch() { + case "$1" in + arm64|aarch64) echo "aarch64-apple-darwin" ;; + x86_64) echo "x86_64-apple-darwin" ;; + *) return 1 ;; + esac +} + +sha_for_triple() { + case "$1" in + aarch64-apple-darwin) echo "b8f1190898b1e7ed1585ce3833a9a1814b30b5703e75eed7c276dafac00bc00a" ;; + x86_64-apple-darwin) echo "b264a51d792d00d138725d8d7eaa6e5354e7ad54e8e93e9efde535d830e217ff" ;; + *) return 1 ;; + esac +} + +host_triple() { + triple_for_arch "$(uname -m)" +} + +copy_helper() { + local source_path="$1" + local message="$2" + + if [[ ! -x "$source_path" ]]; then + return 1 + fi + + mkdir -p "$(dirname "$OUTPUT")" + cp -f "$source_path" "$OUTPUT" + chmod 755 "$OUTPUT" + echo "$message: $OUTPUT" + return 0 +} + +download_release() { + local triple="$1" + local archive="rustledger-${VERSION}-${triple}.tar.gz" + local sha256 tmpdir archive_path actual_sha extracted_helper + + sha256="$(sha_for_triple "$triple")" + tmpdir="$(mktemp -d)" + trap "rm -rf '$tmpdir'" RETURN + archive_path="$tmpdir/$archive" + + if command -v gh >/dev/null 2>&1; then + gh release download "$VERSION" \ + --repo "$REPO" \ + --pattern "$archive" \ + --dir "$tmpdir" \ + --clobber + else + curl -fSL -o "$archive_path" "https://github.com/$REPO/releases/download/$VERSION/$archive" + fi + + actual_sha="$(shasum -a 256 "$archive_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$sha256" ]]; then + echo "Checksum mismatch for $archive" >&2 + echo "Expected: $sha256" >&2 + echo "Actual: $actual_sha" >&2 + return 1 + fi + + mkdir -p "$tmpdir/extract" "$CACHE_ROOT/$VERSION/$triple" + tar -xzf "$archive_path" -C "$tmpdir/extract" + extracted_helper="$(find "$tmpdir/extract" -type f -name rledger | head -n 1)" + if [[ -z "$extracted_helper" ]]; then + echo "Could not find rledger in $archive" >&2 + return 1 + fi + + cp -f "$extracted_helper" "$CACHE_ROOT/$VERSION/$triple/rledger" + chmod 755 "$CACHE_ROOT/$VERSION/$triple/rledger" +} + +ensure_cached_helper() { + local triple="$1" + local helper="$CACHE_ROOT/$VERSION/$triple/rledger" + + if [[ -x "$helper" ]]; then + echo "$helper" + return 0 + fi + + download_release "$triple" >&2 + echo "$helper" +} + +requested_triples=() +if [[ -n "${ARCHS:-}" ]]; then + for arch in $ARCHS; do + triple="$(triple_for_arch "$arch" 2>/dev/null || true)" + if [[ -n "${triple:-}" && " ${requested_triples[*]} " != *" $triple "* ]]; then + requested_triples+=("$triple") + fi + done +fi +if [[ "${#requested_triples[@]}" -eq 0 ]]; then + requested_triples+=("$(host_triple)") +fi + +if [[ -n "${TABLEPRO_RUSTLEDGER_BINARY:-}" ]]; then + copy_helper "$TABLEPRO_RUSTLEDGER_BINARY" "Bundled rustledger helper from TABLEPRO_RUSTLEDGER_BINARY" + exit 0 +fi + +helpers=() +for triple in "${requested_triples[@]}"; do + if helper="$(ensure_cached_helper "$triple")"; then + helpers+=("$helper") + fi +done + +if [[ "${#helpers[@]}" -eq 1 ]]; then + copy_helper "${helpers[0]}" "Bundled rustledger helper" + exit 0 +fi + +if [[ "${#helpers[@]}" -gt 1 && -x "$(command -v lipo)" ]]; then + mkdir -p "$(dirname "$OUTPUT")" + lipo -create "${helpers[@]}" -output "$OUTPUT" + chmod 755 "$OUTPUT" + echo "Bundled universal rustledger helper: $OUTPUT" + exit 0 +fi + +for installed in /opt/homebrew/bin/rledger /usr/local/bin/rledger; do + if copy_helper "$installed" "Bundled installed rustledger helper after release download failed"; then + exit 0 + fi +done + +echo "Unable to bundle rustledger. Install rledger or set TABLEPRO_RUSTLEDGER_BINARY." >&2 +exit 1 From b2581268950caa1a491f7b334d8eed2b46a21aec Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 30 May 2026 11:26:41 -0700 Subject: [PATCH 2/8] Address Beancount driver review feedback --- CHANGELOG.md | 4 + .../Sources/TableProModels/DatabaseType.swift | 4 +- .../DatabaseTypeTests.swift | 4 +- .../BeancountLedgerParser.swift | 53 +++++++++++-- .../BeancountPlugin.swift | 2 +- .../BeancountPluginDriver.swift | 79 +++++++++++++++---- README.md | 2 +- README.vi.md | 2 +- .../beancount-icon.imageset/Contents.json | 16 ++++ .../beancount-icon.imageset/beancount.svg | 1 + ...ginMetadataRegistry+RegistryDefaults.swift | 76 ------------------ .../Core/Plugins/PluginMetadataRegistry.swift | 76 ++++++++++++++++++ .../Connection/DatabaseConnection.swift | 2 + TableProTests/Models/DatabaseTypeTests.swift | 3 +- .../BeancountDriverMetadataTests.swift | 4 +- .../Plugins/BeancountLedgerParserTests.swift | 24 ++++++ .../Plugins/BeancountPluginDriverTests.swift | 53 +++++++++++++ docs/databases/beancount.mdx | 66 ++++++++++++++++ docs/databases/overview.mdx | 4 +- docs/docs.json | 1 + docs/index.mdx | 3 +- scripts/download-rustledger.sh | 8 +- 22 files changed, 372 insertions(+), 115 deletions(-) create mode 100644 TablePro/Assets.xcassets/beancount-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/beancount-icon.imageset/beancount.svg create mode 100644 docs/databases/beancount.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..6eef9ed54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Beancount ledger support as a bundled, read-only file-based driver with SQL projection and BQL queries. + ## [0.46.0] - 2026-05-28 ### Added diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift index 4272a587b..bcad2d50f 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -26,11 +26,12 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let dynamodb = DatabaseType(rawValue: "DynamoDB") public static let bigquery = DatabaseType(rawValue: "BigQuery") public static let libsql = DatabaseType(rawValue: "libSQL") + public static let beancount = DatabaseType(rawValue: "Beancount") public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, - .etcd, .cloudflareD1, .dynamodb, .bigquery, .libsql + .etcd, .cloudflareD1, .dynamodb, .bigquery, .libsql, .beancount ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -53,6 +54,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .dynamodb: return "dynamodb-icon" case .bigquery: return "bigquery-icon" case .libsql: return "libsql-icon" + case .beancount: return "beancount-icon" default: return "externaldrive" } } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index e8eeacbeb..3d92cdf57 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -15,6 +15,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.mssql.rawValue == "SQL Server") #expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1") #expect(DatabaseType.bigquery.rawValue == "BigQuery") + #expect(DatabaseType.beancount.rawValue == "Beancount") } @Test("pluginTypeId maps multi-type databases") @@ -51,10 +52,11 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 17) + #expect(DatabaseType.allKnownTypes.count == 18) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.libsql)) + #expect(DatabaseType.allKnownTypes.contains(.beancount)) } @Test("Hashable conformance") diff --git a/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift b/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift index 44729400e..32212367a 100644 --- a/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift +++ b/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift @@ -12,6 +12,7 @@ struct BeancountLedger: Sendable { let prices: [BeancountPrice] let balances: [BeancountBalance] let sourceFiles: [URL] + let watchedDirectories: [URL] } struct BeancountTransaction: Sendable { @@ -86,6 +87,7 @@ final class BeancountLedgerParser { private var accountsByName: [String: BeancountAccount] = [:] private var prices: [BeancountPrice] = [] private var balances: [BeancountBalance] = [] + private var watchedDirectories: Set = [] func parse(fileURL: URL) throws -> BeancountLedger { visited.removeAll() @@ -96,6 +98,7 @@ final class BeancountLedgerParser { accountsByName.removeAll() prices.removeAll() balances.removeAll() + watchedDirectories.removeAll() try parseFile(fileURL.standardizedFileURL) @@ -105,7 +108,8 @@ final class BeancountLedgerParser { accounts: accountsByName.values.sorted { $0.name < $1.name }, prices: prices, balances: balances, - sourceFiles: sourceFiles + sourceFiles: sourceFiles, + watchedDirectories: watchedDirectories.sorted { $0.path < $1.path } ) } @@ -205,18 +209,26 @@ final class BeancountLedgerParser { let patternPath = patternURL.path let searchRoot = globSearchRoot(for: patternPath) let fileManager = FileManager.default - guard fileManager.fileExists(atPath: searchRoot.path) else { return [] } + guard fileManager.fileExists(atPath: searchRoot.path) else { + watchedDirectories.insert(existingWatchDirectory(for: searchRoot)) + return [] + } + watchedDirectories.insert(searchRoot) let regex = try NSRegularExpression(pattern: globRegex(for: patternPath)) let enumerator = fileManager.enumerator( at: searchRoot, - includingPropertiesForKeys: [.isRegularFileKey], + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], options: [.skipsHiddenFiles] ) var matches: [URL] = [] while let candidate = enumerator?.nextObject() as? URL { - let values = try? candidate.resourceValues(forKeys: [.isRegularFileKey]) + let values = try? candidate.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + watchedDirectories.insert(candidate.standardizedFileURL) + continue + } guard values?.isRegularFile == true else { continue } let path = candidate.standardizedFileURL.path @@ -247,6 +259,20 @@ final class BeancountLedgerParser { return URL(fileURLWithPath: rootPath.isEmpty ? "/" : rootPath).standardizedFileURL } + private func existingWatchDirectory(for missingDirectory: URL) -> URL { + var candidate = missingDirectory.standardizedFileURL + let fileManager = FileManager.default + while candidate.path != "/" { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), + isDirectory.boolValue { + return candidate + } + candidate.deleteLastPathComponent() + } + return URL(fileURLWithPath: "/") + } + private func globRegex(for patternPath: String) -> String { let characters = Array(patternPath) var regex = "^" @@ -366,7 +392,7 @@ final class BeancountLedgerParser { guard !trimmed.isEmpty, !trimmed.hasPrefix(";"), !trimmed.hasPrefix("#") else { return nil } let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init) - guard let account = parts.first, account.contains(":") else { return nil } + guard let account = parts.first, isAccountName(account) else { return nil } let amount = parts.count >= 2 ? parts[1] : nil let commodity = parts.count >= 3 ? parts[2] : nil @@ -382,6 +408,23 @@ final class BeancountLedgerParser { ) } + private func isAccountName(_ value: String) -> Bool { + guard value.contains(":"), !value.hasSuffix(":") else { return false } + let components = value.split(separator: ":", omittingEmptySubsequences: false) + guard components.count >= 2 else { return false } + + let allowedSymbols = CharacterSet(charactersIn: "-_") + return components.allSatisfy { component in + guard let first = component.unicodeScalars.first, + CharacterSet.uppercaseLetters.contains(first) else { + return false + } + return component.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) || allowedSymbols.contains(scalar) + } + } + } + private func parseDatePrefix(_ line: String) -> String? { guard line.count >= 11 else { return nil } let prefix = String(line.prefix(10)) diff --git a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift index 62e918995..cb68c4875 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift @@ -17,7 +17,7 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { static let iconName = "beancount-icon" static let defaultPort = 0 - static let isDownloadable = true + static let isDownloadable = false static let pathFieldRole: PathFieldRole = .filePath static let requiresAuthentication = false static let supportsSSH = false diff --git a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift index f18b3154d..658b55114 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -4,6 +4,7 @@ // import Foundation +import Dispatch import SQLite3 import TableProPluginKit @@ -74,7 +75,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let parsed = try BeancountLedgerParser().parse(fileURL: fileURL) - let signatures = try Self.signatures(for: parsed.sourceFiles) + let signatures = try Self.signatures(for: Self.watchedURLs(for: parsed)) var handle: OpaquePointer? guard sqlite3_open(":memory:", &handle) == SQLITE_OK, let handle else { throw BeancountDriverError.connectionFailed("Could not initialize SQL projection") @@ -162,6 +163,9 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchRowCount(query: String) async throws -> Int { + if let bql = Self.extractBQLQuery(from: query) { + return try executeBQL(query: bql).rows.count + } let escaped = query.replacingOccurrences(of: ";", with: "") let result = try await execute(query: "SELECT COUNT(*) FROM (\(escaped))") guard let text = result.rows.first?.first?.asText, let count = Int(text) else { return 0 } @@ -169,7 +173,10 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { - try await execute(query: "SELECT * FROM (\(query)) LIMIT \(limit) OFFSET \(offset)") + if let bql = Self.extractBQLQuery(from: query) { + return Self.paginatedResult(try executeBQL(query: bql), offset: offset, limit: limit) + } + return try await execute(query: "SELECT * FROM (\(query)) LIMIT \(limit) OFFSET \(offset)") } func fetchTables(schema: String?) async throws -> [PluginTableInfo] { @@ -315,11 +322,26 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { process.standardOutput = stdout process.standardError = stderr + let outputCollector = PipeDataCollector() + let errorCollector = PipeDataCollector() + let readers = DispatchGroup() + readers.enter() + DispatchQueue.global(qos: .userInitiated).async { + outputCollector.set(stdout.fileHandleForReading.readDataToEndOfFile()) + readers.leave() + } + readers.enter() + DispatchQueue.global(qos: .userInitiated).async { + errorCollector.set(stderr.fileHandleForReading.readDataToEndOfFile()) + readers.leave() + } + try process.run() process.waitUntilExit() + readers.wait() - let output = stdout.fileHandleForReading.readDataToEndOfFile() - let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile() + let output = outputCollector.data + let errorOutput = errorCollector.data guard process.terminationStatus == 0 else { let message = String(data: errorOutput, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -397,13 +419,28 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + private static func paginatedResult(_ result: PluginQueryResult, offset: Int, limit: Int) -> PluginQueryResult { + let safeOffset = max(offset, 0) + let safeLimit = max(limit, 0) + let start = min(safeOffset, result.rows.count) + let end = min(start + safeLimit, result.rows.count) + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: Array(result.rows[start.. [URL] { + Array(Set(ledger.sourceFiles + ledger.watchedDirectories)).sorted { $0.path < $1.path } + } + private func expandPath(_ path: String) -> String { guard path.hasPrefix("~") else { return path } return NSString(string: path).expandingTildeInPath @@ -733,6 +763,21 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +private final class PipeDataCollector: @unchecked Sendable { + private let lock = NSLock() + private var storage = Data() + + var data: Data { + lock.withLock { storage } + } + + func set(_ data: Data) { + lock.withLock { + storage = data + } + } +} + private extension NSLock { func withLock(_ body: () throws -> T) rethrows -> T { lock() diff --git a/README.md b/README.md index e8375cd6c..0abdcd192 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ TablePro is the missing fourth: native, multi-database, and open source. | MongoDB | Plugin | | Oracle Database | Plugin | | DuckDB | Plugin | -| Beancount | Plugin | +| Beancount | Built-in | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/README.vi.md b/README.vi.md index 458911e77..939ab0e69 100644 --- a/README.vi.md +++ b/README.vi.md @@ -82,7 +82,7 @@ TablePro là mảnh thứ tư còn thiếu: native, đa database, và mã nguồ | MongoDB | Plugin | | Oracle Database | Plugin | | DuckDB | Plugin | -| Beancount | Plugin | +| Beancount | Tích hợp sẵn | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/TablePro/Assets.xcassets/beancount-icon.imageset/Contents.json b/TablePro/Assets.xcassets/beancount-icon.imageset/Contents.json new file mode 100644 index 000000000..ba8496a7b --- /dev/null +++ b/TablePro/Assets.xcassets/beancount-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "beancount.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/beancount-icon.imageset/beancount.svg b/TablePro/Assets.xcassets/beancount-icon.imageset/beancount.svg new file mode 100644 index 000000000..f9f4702c7 --- /dev/null +++ b/TablePro/Assets.xcassets/beancount-icon.imageset/beancount.svg @@ -0,0 +1 @@ +Beancount diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 652868123..7637a0b4e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -855,82 +855,6 @@ extension PluginMetadataRegistry { tagline: String(localized: "Embedded analytical SQL") ) )), - ("Beancount", PluginMetadataSnapshot( - displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, - requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, - isDownloadable: true, primaryUrlScheme: "beancount", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, - supportsHealthMonitor: false, urlSchemes: ["beancount"], postConnectActions: [], - brandColorHex: "#3F7D20", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .fileBased, supportsDatabaseSwitching: false, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: false, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsAddColumn: false, - supportsModifyColumn: false, - supportsDropColumn: false, - supportsRenameColumn: false, - supportsAddIndex: false, - supportsDropIndex: false, - supportsModifyPrimaryKey: false - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Ledger Tables", - defaultPrimaryKeyColumn: nil, - immutableColumns: [ - "id", "transaction_id", "date", "flag", "payee", "narration", - "account", "amount", "commodity", "currency", "source_file", "line" - ], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: ["beancount"], - databaseGroupingStrategy: .flat, - structureColumnFields: [.name, .type, .nullable, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "WITH", "UNION", "INTERSECT", "EXCEPT", - "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", - "ASC", "DESC", "DISTINCT" - ], - functions: ["COUNT", "SUM", "AVG", "MAX", "MIN", "ROUND", "DATE", "STRFTIME"], - dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .limit - ), - statementCompletions: [], - columnTypesByCategory: [ - "Integer": ["INTEGER"], - "String": ["TEXT"], - "Date": ["DATE"], - "Decimal": ["DECIMAL"] - ] - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - category: .analytical, - tagline: String(localized: "Plain-text accounting ledgers") - ) - )), ("Cassandra", PluginMetadataSnapshot( displayName: "Cassandra / ScyllaDB", iconName: "cassandra-icon", defaultPort: 9_042, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index fcf845315..6a73909ec 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -775,6 +775,82 @@ final class PluginMetadataRegistry: @unchecked Sendable { category: .relational, tagline: String(localized: "Embedded zero-config SQL database") ) + )), + ("Beancount", PluginMetadataSnapshot( + displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "beancount", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + supportsHealthMonitor: false, urlSchemes: ["beancount"], postConnectActions: [], + brandColorHex: "#3F7D20", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Ledger Tables", + defaultPrimaryKeyColumn: nil, + immutableColumns: [ + "id", "transaction_id", "date", "flag", "payee", "narration", + "account", "amount", "commodity", "currency", "source_file", "line" + ], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: ["beancount"], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "WITH", "UNION", "INTERSECT", "EXCEPT", + "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", + "ASC", "DESC", "DISTINCT" + ], + functions: ["COUNT", "SUM", "AVG", "MAX", "MIN", "ROUND", "DATE", "STRFTIME"], + dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ), + statementCompletions: [], + columnTypesByCategory: [ + "Integer": ["INTEGER"], + "String": ["TEXT"], + "Date": ["DATE"], + "Decimal": ["DECIMAL"] + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .analytical, + tagline: String(localized: "Plain-text accounting ledgers") + ) )) ] // swiftlint:enable function_body_length diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index dda3adc46..219cdddcc 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -45,6 +45,7 @@ extension DatabaseType { static let bigQuery = DatabaseType(rawValue: "BigQuery") static let libsql = DatabaseType(rawValue: "libSQL") static let turso = DatabaseType(rawValue: "Turso") + static let beancount = DatabaseType(rawValue: "Beancount") } extension DatabaseType: Codable { @@ -179,6 +180,7 @@ extension DatabaseType { case "libSQL", "Turso": Color(hex: "4FF8D2") case "DynamoDB": Color(hex: "4053D6") case "BigQuery": Color(hex: "4285F4") + case "Beancount": Color(hex: "3F7D20") default: Color.accentColor } } diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index ee4d1616b..f987721f0 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -65,7 +65,8 @@ struct DatabaseTypeTests { (DatabaseType.clickhouse, "ClickHouse"), (DatabaseType.duckdb, "DuckDB"), (DatabaseType.cassandra, "Cassandra"), - (DatabaseType.scylladb, "ScyllaDB") + (DatabaseType.scylladb, "ScyllaDB"), + (DatabaseType.beancount, "Beancount") ]) func testRawValueMatchesDisplayName(dbType: DatabaseType, expectedRawValue: String) { #expect(dbType.rawValue == expectedRawValue) diff --git a/TableProTests/Plugins/BeancountDriverMetadataTests.swift b/TableProTests/Plugins/BeancountDriverMetadataTests.swift index ed892cb81..a64705e2c 100644 --- a/TableProTests/Plugins/BeancountDriverMetadataTests.swift +++ b/TableProTests/Plugins/BeancountDriverMetadataTests.swift @@ -11,11 +11,11 @@ import Testing @MainActor @Suite("Beancount driver metadata") struct BeancountDriverMetadataTests { - @Test("registry exposes Beancount as a downloadable file-based driver") + @Test("registry exposes Beancount as a bundled file-based driver") func registryMetadata() throws { let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forTypeId: "Beancount")) #expect(snapshot.displayName == "Beancount") - #expect(snapshot.isDownloadable == true) + #expect(snapshot.isDownloadable == false) #expect(snapshot.connectionMode == .fileBased) #expect(snapshot.schema.fileExtensions == ["beancount"]) #expect(snapshot.pathFieldRole == .filePath) diff --git a/TableProTests/Plugins/BeancountLedgerParserTests.swift b/TableProTests/Plugins/BeancountLedgerParserTests.swift index e57549682..99207b6b0 100644 --- a/TableProTests/Plugins/BeancountLedgerParserTests.swift +++ b/TableProTests/Plugins/BeancountLedgerParserTests.swift @@ -88,5 +88,29 @@ struct BeancountLedgerParserTests { "expenses.beancount", "main.beancount" ]) + #expect(parsed.watchedDirectories.map(\.lastPathComponent).contains("imports")) + #expect(parsed.watchedDirectories.map(\.lastPathComponent).contains("nested")) + } + + @Test("ignores transaction metadata lines when parsing postings") + func ignoresMetadataLinesInsideTransactions() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + 2024-01-15 * "Grocery Store" "Weekly shop" + receipt: "abc.pdf" + Assets:Bank:Checking -100.00 USD + Expenses:Food 100.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let parsed = try BeancountLedgerParser().parse(fileURL: ledger) + + #expect(parsed.postings.map(\.account) == ["Assets:Bank:Checking", "Expenses:Food"]) } } diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift index 844e346d0..f5648ca77 100644 --- a/TableProTests/Plugins/BeancountPluginDriverTests.swift +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -52,6 +52,49 @@ struct BeancountPluginDriverTests { #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) } + @Test("reloads the SQL projection when a glob include matches a new file") + func reloadsWhenGlobIncludeMatchesNewFile() async throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-driver-glob-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + + let imports = tempDirectory.appendingPathComponent("imports", isDirectory: true) + try FileManager.default.createDirectory(at: imports, withIntermediateDirectories: true) + try """ + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) + + let ledger = tempDirectory.appendingPathComponent("main.beancount") + try """ + include "imports/*.beancount" + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: DriverConnectionConfig( + host: "", + port: 0, + username: "", + password: "", + database: ledger.path + )) + try await driver.connect() + defer { + driver.disconnect() + } + + var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) + + try """ + 2024-01-02 open Expenses:Food USD + """.write(to: imports.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) + + result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + } + @Test("rejects write queries") func rejectsWriteQueries() async throws { let tempDirectory = FileManager.default.temporaryDirectory @@ -123,6 +166,16 @@ struct BeancountPluginDriverTests { "Expenses:Food", "Income:Salary" ]) + + let count = try await driver.fetchRowCount(query: "BQL: SELECT account FROM accounts ORDER BY account") + #expect(count == 3) + + let page = try await driver.fetchRows( + query: "BQL: SELECT account FROM accounts ORDER BY account", + offset: 1, + limit: 1 + ) + #expect(page.rows.map { $0.first?.asText } == ["Expenses:Food"]) } private static func installedRustledgerPath() -> String? { diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx new file mode 100644 index 000000000..e6a586501 --- /dev/null +++ b/docs/databases/beancount.mdx @@ -0,0 +1,66 @@ +--- +title: Beancount +description: Open Beancount ledgers with TablePro +--- + +# Beancount + +TablePro opens `.beancount` ledgers as read-only, file-based connections. The driver projects transactions, postings, accounts, prices, balances, and source files into SQL tables for browsing and exports. + +The bundled driver also supports BQL queries through the bundled `rustledger` helper. + +## Connecting to a Beancount ledger + + + + Open TablePro and click **New Connection** or press ⌘N. + + + Choose **Beancount** from the database type list. + + + Click **Browse** and select a `.beancount` file. + + + Click **Connect** to open the ledger. + + + +## Connection URL + +```text +beancount:///path/to/main.beancount +``` + +See [Connection URL Reference](/databases/connection-urls) for all parameters. + +## Tables + +| Table | Contents | +|-------|----------| +| `transactions` | Transaction date, flag, payee, narration, source file, and line | +| `postings` | Posting account, amount, commodity, source file, and line | +| `accounts` | Opened accounts and declared currencies | +| `prices` | Price directives | +| `balances` | Balance directives | +| `source_files` | Parsed ledger and include files | + +## Includes + +The parser follows Beancount `include` directives. Literal includes and glob patterns such as `include "imports/*.beancount"` and `include "imports/**/*.beancount"` are supported. + +## BQL + +Prefix a query with `BQL:` to run it through `rustledger`: + +```sql +BQL: SELECT account FROM accounts ORDER BY account +``` + +Table browsing, row counts, and pagination work for BQL results. BQL queries do not support SQL parameters. + +## Limitations + +- Beancount connections are read-only. +- Schema editing, imports, SSH, SSL, and database switching are not available for ledgers. +- The SQL projection covers common ledger directives; unsupported directives remain available in the original source files. diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index cde26ce68..544712b7a 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -49,6 +49,9 @@ Natively supported: DuckDB embedded OLAP database. File-based, no server required + + Plain-text accounting ledgers. File-based, read-only + Amazon DynamoDB via AWS SDK. NoSQL key-value and document database @@ -497,4 +500,3 @@ Right-click a connection to edit or delete it. Changes take effect on the next c ## Backup and Restore Connections are stored in `~/Library/Preferences/com.TablePro.plist`. Passwords are in the macOS Keychain. Copy the `.plist` file to back up. You'll need to re-enter passwords after restoring since Keychain entries don't transfer. - diff --git a/docs/docs.json b/docs/docs.json index e1a0367c9..a564515f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -59,6 +59,7 @@ "pages": [ "databases/sqlite", "databases/duckdb", + "databases/beancount", "databases/libsql" ] }, diff --git a/docs/index.mdx b/docs/index.mdx index a7ce035cb..62b42340c 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -28,7 +28,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under Swift & Apple frameworks. No Electron. Pure macOS responsiveness. - 17 databases: MySQL, PostgreSQL, SQLite, MongoDB, Redis, Oracle, and more. + 18 databases: MySQL, PostgreSQL, SQLite, MongoDB, Redis, Oracle, and more. Context-aware autocomplete with schema and syntax awareness. @@ -72,6 +72,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | MongoDB | 27017 | Plugin | | Oracle Database | 1521 | Plugin | | DuckDB | N/A (file-based) | Plugin | +| Beancount | N/A (file-based) | Built-in | | Cassandra / ScyllaDB | 9042 | Plugin | | Etcd | 2379 | Plugin | | Cloudflare D1 | N/A (API-based) | Plugin | diff --git a/scripts/download-rustledger.sh b/scripts/download-rustledger.sh index 644476058..1912b9f04 100755 --- a/scripts/download-rustledger.sh +++ b/scripts/download-rustledger.sh @@ -136,11 +136,5 @@ if [[ "${#helpers[@]}" -gt 1 && -x "$(command -v lipo)" ]]; then exit 0 fi -for installed in /opt/homebrew/bin/rledger /usr/local/bin/rledger; do - if copy_helper "$installed" "Bundled installed rustledger helper after release download failed"; then - exit 0 - fi -done - -echo "Unable to bundle rustledger. Install rledger or set TABLEPRO_RUSTLEDGER_BINARY." >&2 +echo "Unable to bundle rustledger from the pinned release. Set TABLEPRO_RUSTLEDGER_BINARY for local builds or retry the release download." >&2 exit 1 From 1b76e0ff6819fcc50f990740e2bf951a64f79d81 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 30 May 2026 12:21:23 -0700 Subject: [PATCH 3/8] Make Beancount a downloadable plugin --- .github/workflows/build-plugin.yml | 5 ++ CHANGELOG.md | 2 +- .../BeancountPlugin.swift | 2 +- README.md | 2 +- TablePro.xcodeproj/project.pbxproj | 15 ---- ...ginMetadataRegistry+RegistryDefaults.swift | 76 +++++++++++++++++++ .../Core/Plugins/PluginMetadataRegistry.swift | 76 ------------------- .../BeancountDriverMetadataTests.swift | 4 +- docs/index.mdx | 2 +- scripts/build-plugin.sh | 10 +++ scripts/download-rustledger.sh | 4 + scripts/release-all-plugins.sh | 1 + 12 files changed, 102 insertions(+), 97 deletions(-) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 33b0cdfdb..210d44129 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -154,6 +154,11 @@ jobs: DISPLAY_NAME="DuckDB Driver"; SUMMARY="DuckDB analytical database driver" DB_TYPE_IDS='["DuckDB"]'; ICON="bird"; BUNDLE_NAME="DuckDBDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; + beancount) + TARGET="BeancountDriver"; BUNDLE_ID="com.TablePro.BeancountDriver" + DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver with bundled rustledger BQL helper" + DB_TYPE_IDS='["Beancount"]'; ICON="beancount-icon"; BUNDLE_NAME="BeancountDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/beancount" ;; cassandra) TARGET="CassandraDriver"; BUNDLE_ID="com.TablePro.CassandraDriver" DISPLAY_NAME="Cassandra Driver"; SUMMARY="Apache Cassandra and ScyllaDB driver via DataStax C driver" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eef9ed54..bc3b69826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Beancount ledger support as a bundled, read-only file-based driver with SQL projection and BQL queries. +- Beancount ledger support as a downloadable, read-only file-based driver with SQL projection and BQL queries. ## [0.46.0] - 2026-05-28 diff --git a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift index cb68c4875..62e918995 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift @@ -17,7 +17,7 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { static let iconName = "beancount-icon" static let defaultPort = 0 - static let isDownloadable = false + static let isDownloadable = true static let pathFieldRole: PathFieldRole = .filePath static let requiresAuthentication = false static let supportsSSH = false diff --git a/README.md b/README.md index 0abdcd192..e8375cd6c 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ TablePro is the missing fourth: native, multi-database, and open source. | MongoDB | Plugin | | Oracle Database | Plugin | | DuckDB | Plugin | -| Beancount | Built-in | +| Beancount | Plugin | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 2178f46a3..79940d485 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5ABC147400000000000002 /* BeancountDriver.tableplugin in Copy Plug-Ins (13 items) */ = {isa = PBXBuildFile; fileRef = 5ABC147400000000000003 /* BeancountDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -148,13 +147,6 @@ remoteGlobalIDString = 5A869000000000000; remoteInfo = DuckDBDriver; }; - 5ABC147400000000000004 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5ABC147400000000000005; - remoteInfo = BeancountDriver; - }; 5ABC14740000000000000F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -266,7 +258,6 @@ 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins (12 items) */, 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins (12 items) */, - 5ABC147400000000000002 /* BeancountDriver.tableplugin in Copy Plug-Ins (13 items) */, 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins (12 items) */, @@ -1109,7 +1100,6 @@ 5A867000C00000000 /* PBXTargetDependency */, 5A868000C00000000 /* PBXTargetDependency */, 5A869000C00000000 /* PBXTargetDependency */, - 5ABC14740000000000000C /* PBXTargetDependency */, 5A86A000C00000000 /* PBXTargetDependency */, 5A86B000C00000000 /* PBXTargetDependency */, 5A86C000C00000000 /* PBXTargetDependency */, @@ -2280,11 +2270,6 @@ target = 5A869000000000000 /* DuckDBDriver */; targetProxy = 5A869000B00000000 /* PBXContainerItemProxy */; }; - 5ABC14740000000000000C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5ABC147400000000000005 /* BeancountDriver */; - targetProxy = 5ABC147400000000000004 /* PBXContainerItemProxy */; - }; 5ABC147400000000000010 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A860000000000000 /* TableProPluginKit */; diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 7637a0b4e..652868123 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -855,6 +855,82 @@ extension PluginMetadataRegistry { tagline: String(localized: "Embedded analytical SQL") ) )), + ("Beancount", PluginMetadataSnapshot( + displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "beancount", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + supportsHealthMonitor: false, urlSchemes: ["beancount"], postConnectActions: [], + brandColorHex: "#3F7D20", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Ledger Tables", + defaultPrimaryKeyColumn: nil, + immutableColumns: [ + "id", "transaction_id", "date", "flag", "payee", "narration", + "account", "amount", "commodity", "currency", "source_file", "line" + ], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: ["beancount"], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "WITH", "UNION", "INTERSECT", "EXCEPT", + "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", + "ASC", "DESC", "DISTINCT" + ], + functions: ["COUNT", "SUM", "AVG", "MAX", "MIN", "ROUND", "DATE", "STRFTIME"], + dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ), + statementCompletions: [], + columnTypesByCategory: [ + "Integer": ["INTEGER"], + "String": ["TEXT"], + "Date": ["DATE"], + "Decimal": ["DECIMAL"] + ] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .analytical, + tagline: String(localized: "Plain-text accounting ledgers") + ) + )), ("Cassandra", PluginMetadataSnapshot( displayName: "Cassandra / ScyllaDB", iconName: "cassandra-icon", defaultPort: 9_042, requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 6a73909ec..fcf845315 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -775,82 +775,6 @@ final class PluginMetadataRegistry: @unchecked Sendable { category: .relational, tagline: String(localized: "Embedded zero-config SQL database") ) - )), - ("Beancount", PluginMetadataSnapshot( - displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0, - requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "beancount", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, - supportsHealthMonitor: false, urlSchemes: ["beancount"], postConnectActions: [], - brandColorHex: "#3F7D20", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .fileBased, supportsDatabaseSwitching: false, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: false, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsAddColumn: false, - supportsModifyColumn: false, - supportsDropColumn: false, - supportsRenameColumn: false, - supportsAddIndex: false, - supportsDropIndex: false, - supportsModifyPrimaryKey: false - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Ledger Tables", - defaultPrimaryKeyColumn: nil, - immutableColumns: [ - "id", "transaction_id", "date", "flag", "payee", "narration", - "account", "amount", "commodity", "currency", "source_file", "line" - ], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: ["beancount"], - databaseGroupingStrategy: .flat, - structureColumnFields: [.name, .type, .nullable, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "WITH", "UNION", "INTERSECT", "EXCEPT", - "CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS", - "ASC", "DESC", "DISTINCT" - ], - functions: ["COUNT", "SUM", "AVG", "MAX", "MIN", "ROUND", "DATE", "STRFTIME"], - dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .limit - ), - statementCompletions: [], - columnTypesByCategory: [ - "Integer": ["INTEGER"], - "String": ["TEXT"], - "Date": ["DATE"], - "Decimal": ["DECIMAL"] - ] - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - category: .analytical, - tagline: String(localized: "Plain-text accounting ledgers") - ) )) ] // swiftlint:enable function_body_length diff --git a/TableProTests/Plugins/BeancountDriverMetadataTests.swift b/TableProTests/Plugins/BeancountDriverMetadataTests.swift index a64705e2c..ed892cb81 100644 --- a/TableProTests/Plugins/BeancountDriverMetadataTests.swift +++ b/TableProTests/Plugins/BeancountDriverMetadataTests.swift @@ -11,11 +11,11 @@ import Testing @MainActor @Suite("Beancount driver metadata") struct BeancountDriverMetadataTests { - @Test("registry exposes Beancount as a bundled file-based driver") + @Test("registry exposes Beancount as a downloadable file-based driver") func registryMetadata() throws { let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forTypeId: "Beancount")) #expect(snapshot.displayName == "Beancount") - #expect(snapshot.isDownloadable == false) + #expect(snapshot.isDownloadable == true) #expect(snapshot.connectionMode == .fileBased) #expect(snapshot.schema.fileExtensions == ["beancount"]) #expect(snapshot.pathFieldRole == .filePath) diff --git a/docs/index.mdx b/docs/index.mdx index 62b42340c..835f9fb26 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -72,7 +72,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under | MongoDB | 27017 | Plugin | | Oracle Database | 1521 | Plugin | | DuckDB | N/A (file-based) | Plugin | -| Beancount | N/A (file-based) | Built-in | +| Beancount | N/A (file-based) | Plugin | | Cassandra / ScyllaDB | 9042 | Plugin | | Etcd | 2379 | Plugin | | Cloudflare D1 | N/A (API-based) | Plugin | diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh index 836a31d9a..a3088e659 100755 --- a/scripts/build-plugin.sh +++ b/scripts/build-plugin.sh @@ -125,6 +125,16 @@ build_plugin() { done fi + # Sign executable helper resources such as Beancount's bundled rledger. + if [ -d "$plugin_bundle/Contents/Resources" ]; then + find "$plugin_bundle/Contents/Resources" -type f -perm -111 | sort | while read -r nested; do + if file "$nested" | grep -q "Mach-O"; then + echo " Signing executable resource: $(basename "$nested")" >&2 + codesign -fs "$SIGN_IDENTITY" --force --options runtime --timestamp "$nested" + fi + done + fi + # Sign the main binary if [ -f "$plugin_binary" ]; then codesign -fs "$SIGN_IDENTITY" --force --options runtime --timestamp "$plugin_binary" diff --git a/scripts/download-rustledger.sh b/scripts/download-rustledger.sh index 1912b9f04..2f60340d0 100755 --- a/scripts/download-rustledger.sh +++ b/scripts/download-rustledger.sh @@ -112,6 +112,10 @@ if [[ "${#requested_triples[@]}" -eq 0 ]]; then fi if [[ -n "${TABLEPRO_RUSTLEDGER_BINARY:-}" ]]; then + if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "TABLEPRO_RUSTLEDGER_BINARY is only allowed for local builds; release builds must use the pinned rustledger download." >&2 + exit 1 + fi copy_helper "$TABLEPRO_RUSTLEDGER_BINARY" "Bundled rustledger helper from TABLEPRO_RUSTLEDGER_BINARY" exit 0 fi diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index 8db2bf85c..b48f94594 100755 --- a/scripts/release-all-plugins.sh +++ b/scripts/release-all-plugins.sh @@ -30,6 +30,7 @@ PLUGINS=( mongodb oracle duckdb + beancount mssql cassandra etcd From 0066ed0d7eb058154f051251bfbcc47b099971f9 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 30 May 2026 12:28:45 -0700 Subject: [PATCH 4/8] Clarify Beancount plugin install docs --- docs/databases/beancount.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index e6a586501..479cd6fe6 100644 --- a/docs/databases/beancount.mdx +++ b/docs/databases/beancount.mdx @@ -5,9 +5,9 @@ description: Open Beancount ledgers with TablePro # Beancount -TablePro opens `.beancount` ledgers as read-only, file-based connections. The driver projects transactions, postings, accounts, prices, balances, and source files into SQL tables for browsing and exports. +TablePro opens `.beancount` ledgers as read-only, file-based connections. If the Beancount plugin is not installed yet, TablePro prompts you to download it before opening the ledger. The driver projects transactions, postings, accounts, prices, balances, and source files into SQL tables for browsing and exports. -The bundled driver also supports BQL queries through the bundled `rustledger` helper. +The plugin also supports BQL queries through its packaged `rustledger` helper. ## Connecting to a Beancount ledger From 616afd535b78b910c517d11d725ddb2b64baa081 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: Tue, 9 Jun 2026 16:34:37 +0700 Subject: [PATCH 5/8] refactor(plugin-beancount): source SQL projection from rledger and drop the hand parser --- CHANGELOG.md | 2 +- .../BeancountIncludeResolver.swift | 256 +++++++ .../BeancountLedgerParser.swift | 496 ------------- .../BeancountPlugin.swift | 8 +- .../BeancountPluginDriver.swift | 659 ++++++++++-------- ...ginMetadataRegistry+RegistryDefaults.swift | 502 +------------ ...MetadataRegistry+RegistryIngredients.swift | 534 ++++++++++++++ .../BeancountIncludeResolver.swift | 1 + .../BeancountLedgerParser.swift | 1 - .../BeancountIncludeResolverTests.swift | 100 +++ .../Plugins/BeancountLedgerParserTests.swift | 116 --- .../Plugins/BeancountPluginDriverTests.swift | 364 ++++++---- docs/databases/beancount.mdx | 6 +- 13 files changed, 1482 insertions(+), 1563 deletions(-) create mode 100644 Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift delete mode 100644 Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift create mode 100644 TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift create mode 120000 TableProTests/PluginTestSources/BeancountIncludeResolver.swift delete mode 120000 TableProTests/PluginTestSources/BeancountLedgerParser.swift create mode 100644 TableProTests/Plugins/BeancountIncludeResolverTests.swift delete mode 100644 TableProTests/Plugins/BeancountLedgerParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9f42aef..a49f7c4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Beancount ledger support as a downloadable, read-only file-based driver with SQL projection and BQL queries. (#1474) +- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, and balances project to SQL tables with amounts booked by rustledger, and BQL runs with a `BQL:` prefix. (#1474) ### Fixed diff --git a/Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift b/Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift new file mode 100644 index 000000000..2b0bbc948 --- /dev/null +++ b/Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift @@ -0,0 +1,256 @@ +// +// BeancountIncludeResolver.swift +// BeancountDriverPlugin +// + +import Foundation + +struct BeancountSourceGraph: Sendable { + let sourceFiles: [URL] + let watchedDirectories: [URL] +} + +enum BeancountResolverError: LocalizedError { + case includeCycle(String) + case unreadable(URL, Error) + + var errorDescription: String? { + switch self { + case .includeCycle(let path): + return String(format: String(localized: "Beancount include cycle detected at %@"), path) + case .unreadable(let url, let error): + return String(format: String(localized: "Could not read %@: %@"), url.path, error.localizedDescription) + } + } +} + +final class BeancountIncludeResolver { + private var visited: Set = [] + private var activeStack: Set = [] + private var sourceFiles: [URL] = [] + private var watchedDirectories: Set = [] + + func resolve(fileURL: URL) throws -> BeancountSourceGraph { + visited.removeAll() + activeStack.removeAll() + sourceFiles.removeAll() + watchedDirectories.removeAll() + + try resolveFile(fileURL.standardizedFileURL) + + return BeancountSourceGraph( + sourceFiles: sourceFiles, + watchedDirectories: watchedDirectories.sorted { $0.path < $1.path } + ) + } + + private func resolveFile(_ url: URL) throws { + let normalized = url.standardizedFileURL + if activeStack.contains(normalized) { + throw BeancountResolverError.includeCycle(normalized.path) + } + guard !visited.contains(normalized) else { return } + + activeStack.insert(normalized) + defer { activeStack.remove(normalized) } + + let contents: String + do { + contents = try String(contentsOf: normalized, encoding: .utf8) + } catch { + throw BeancountResolverError.unreadable(normalized, error) + } + + visited.insert(normalized) + sourceFiles.append(normalized) + + for rawLine in contents.components(separatedBy: .newlines) { + let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("include "), let includePath = quotedString(in: trimmed) else { continue } + let includeURLs = try resolveIncludeURLs( + includePath, + relativeTo: normalized.deletingLastPathComponent() + ) + for includeURL in includeURLs { + try resolveFile(includeURL) + } + } + } + + private func resolveIncludeURLs(_ includePath: String, relativeTo directory: URL) throws -> [URL] { + guard containsGlobPattern(includePath) else { + return [resolveIncludeURL(includePath, relativeTo: directory)] + } + + let patternURL = resolveIncludeURL(includePath, relativeTo: directory) + let patternPath = patternURL.path + let searchRoot = globSearchRoot(for: patternPath) + guard searchRoot.path != "/" else { return [] } + + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: searchRoot.path) else { + watchedDirectories.insert(existingWatchDirectory(for: searchRoot)) + return [] + } + watchedDirectories.insert(searchRoot) + + let regex = try NSRegularExpression(pattern: globRegex(for: patternPath)) + let enumerator = fileManager.enumerator( + at: searchRoot, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + + var matches: [URL] = [] + while let candidate = enumerator?.nextObject() as? URL { + let values = try? candidate.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values?.isDirectory == true { + watchedDirectories.insert(candidate.standardizedFileURL) + continue + } + guard values?.isRegularFile == true else { continue } + + let path = candidate.standardizedFileURL.path + let range = NSRange(location: 0, length: (path as NSString).length) + if regex.firstMatch(in: path, range: range) != nil { + matches.append(candidate.standardizedFileURL) + } + } + + return matches.sorted { $0.path < $1.path } + } + + private func resolveIncludeURL(_ includePath: String, relativeTo directory: URL) -> URL { + if includePath.hasPrefix("/") { + return URL(fileURLWithPath: includePath).standardizedFileURL + } + return directory.appendingPathComponent(includePath).standardizedFileURL + } + + private func containsGlobPattern(_ path: String) -> Bool { + path.contains("*") || path.contains("?") || path.contains("[") + } + + private func globSearchRoot(for patternPath: String) -> URL { + let components = (patternPath as NSString).pathComponents + let prefix = components.prefix { !containsGlobPattern($0) } + let rootPath = NSString.path(withComponents: Array(prefix)) + return URL(fileURLWithPath: rootPath.isEmpty ? "/" : rootPath).standardizedFileURL + } + + private func existingWatchDirectory(for missingDirectory: URL) -> URL { + var candidate = missingDirectory.standardizedFileURL + let fileManager = FileManager.default + while candidate.path != "/" { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), + isDirectory.boolValue { + return candidate + } + candidate.deleteLastPathComponent() + } + return URL(fileURLWithPath: "/") + } + + private func globRegex(for patternPath: String) -> String { + let characters = Array(patternPath) + var regex = "^" + var index = 0 + + while index < characters.count { + let character = characters[index] + if character == "*" { + let nextIndex = index + 1 + if nextIndex < characters.count, characters[nextIndex] == "*" { + let slashIndex = index + 2 + if slashIndex < characters.count, characters[slashIndex] == "/" { + regex += "(?:.*/)?" + index += 3 + } else { + regex += ".*" + index += 2 + } + } else { + regex += "[^/]*" + index += 1 + } + } else if character == "?" { + regex += "[^/]" + index += 1 + } else if character == "[" { + let start = index + index += 1 + while index < characters.count, characters[index] != "]" { + index += 1 + } + if index < characters.count { + regex += String(characters[start...index]) + index += 1 + } else { + regex += NSRegularExpression.escapedPattern(for: String(character)) + } + } else { + regex += NSRegularExpression.escapedPattern(for: String(character)) + index += 1 + } + } + + return regex + "$" + } + + private func quotedString(in line: String) -> String? { + var inQuote = false + var isEscaped = false + var current = "" + + for character in line { + if isEscaped { + current.append(character) + isEscaped = false + continue + } + if character == "\\" { + isEscaped = true + continue + } + if character == "\"" { + if inQuote { + return current + } + inQuote = true + continue + } + if inQuote { + current.append(character) + } + } + + return nil + } + + private func stripComment(_ line: String) -> String { + var inQuote = false + var isEscaped = false + var result = "" + for character in line { + if isEscaped { + result.append(character) + isEscaped = false + continue + } + if character == "\\" { + result.append(character) + isEscaped = true + continue + } + if character == "\"" { + inQuote.toggle() + } + if character == ";" && !inQuote { + break + } + result.append(character) + } + return result + } +} diff --git a/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift b/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift deleted file mode 100644 index 32212367a..000000000 --- a/Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift +++ /dev/null @@ -1,496 +0,0 @@ -// -// BeancountLedgerParser.swift -// BeancountDriverPlugin -// - -import Foundation - -struct BeancountLedger: Sendable { - let transactions: [BeancountTransaction] - let postings: [BeancountPosting] - let accounts: [BeancountAccount] - let prices: [BeancountPrice] - let balances: [BeancountBalance] - let sourceFiles: [URL] - let watchedDirectories: [URL] -} - -struct BeancountTransaction: Sendable { - let id: Int - let date: String - let flag: String - let payee: String? - let narration: String? - let sourceFile: URL - let line: Int -} - -struct BeancountPosting: Sendable { - let id: Int - let transactionId: Int - let date: String - let account: String - let amount: String? - let commodity: String? - let sourceFile: URL - let line: Int -} - -struct BeancountAccount: Sendable { - let name: String - let openDate: String - let currencies: String? - let sourceFile: URL - let line: Int -} - -struct BeancountPrice: Sendable { - let id: Int - let date: String - let commodity: String - let amount: String - let currency: String - let sourceFile: URL - let line: Int -} - -struct BeancountBalance: Sendable { - let id: Int - let date: String - let account: String - let amount: String - let commodity: String - let sourceFile: URL - let line: Int -} - -enum BeancountParserError: LocalizedError { - case includeCycle(String) - case unreadable(URL, Error) - - var errorDescription: String? { - switch self { - case .includeCycle(let path): - return "Beancount include cycle detected at \(path)" - case .unreadable(let url, let error): - return "Could not read \(url.path): \(error.localizedDescription)" - } - } -} - -final class BeancountLedgerParser { - private var visited: Set = [] - private var activeStack: Set = [] - private var sourceFiles: [URL] = [] - private var transactions: [BeancountTransaction] = [] - private var postings: [BeancountPosting] = [] - private var accountsByName: [String: BeancountAccount] = [:] - private var prices: [BeancountPrice] = [] - private var balances: [BeancountBalance] = [] - private var watchedDirectories: Set = [] - - func parse(fileURL: URL) throws -> BeancountLedger { - visited.removeAll() - activeStack.removeAll() - sourceFiles.removeAll() - transactions.removeAll() - postings.removeAll() - accountsByName.removeAll() - prices.removeAll() - balances.removeAll() - watchedDirectories.removeAll() - - try parseFile(fileURL.standardizedFileURL) - - return BeancountLedger( - transactions: transactions, - postings: postings, - accounts: accountsByName.values.sorted { $0.name < $1.name }, - prices: prices, - balances: balances, - sourceFiles: sourceFiles, - watchedDirectories: watchedDirectories.sorted { $0.path < $1.path } - ) - } - - private func parseFile(_ url: URL) throws { - let normalized = url.standardizedFileURL - if activeStack.contains(normalized) { - throw BeancountParserError.includeCycle(normalized.path) - } - guard !visited.contains(normalized) else { return } - - activeStack.insert(normalized) - defer { activeStack.remove(normalized) } - - let contents: String - do { - contents = try String(contentsOf: normalized, encoding: .utf8) - } catch { - throw BeancountParserError.unreadable(normalized, error) - } - - visited.insert(normalized) - sourceFiles.append(normalized) - - let lines = contents.components(separatedBy: .newlines) - var index = 0 - while index < lines.count { - let lineNumber = index + 1 - let rawLine = lines[index] - let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces) - defer { index += 1 } - - guard !trimmed.isEmpty else { continue } - - if let includePath = parseInclude(trimmed) { - let includeURLs = try resolveIncludeURLs( - includePath, - relativeTo: normalized.deletingLastPathComponent() - ) - for includeURL in includeURLs { - try parseFile(includeURL) - } - continue - } - - guard let date = parseDatePrefix(trimmed) else { continue } - let remainder = String(trimmed.dropFirst(11)) - - if remainder.hasPrefix("open ") { - parseOpen(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) - } else if remainder.hasPrefix("price ") { - parsePrice(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) - } else if remainder.hasPrefix("balance ") { - parseBalance(remainder: remainder, date: date, sourceFile: normalized, line: lineNumber) - } else if let flag = remainder.first, flag == "*" || flag == "!" { - let transactionId = transactions.count + 1 - let transaction = parseTransaction( - id: transactionId, - remainder: remainder, - date: date, - sourceFile: normalized, - line: lineNumber - ) - transactions.append(transaction) - - var postingIndex = index + 1 - while postingIndex < lines.count { - let postingLine = lines[postingIndex] - guard postingLine.first?.isWhitespace == true else { break } - if let posting = parsePosting( - postingLine, - id: postings.count + 1, - transactionId: transactionId, - date: date, - sourceFile: normalized, - line: postingIndex + 1 - ) { - postings.append(posting) - } - postingIndex += 1 - } - index = postingIndex - 1 - } - } - } - - private func parseInclude(_ line: String) -> String? { - guard line.hasPrefix("include ") else { return nil } - return quotedStrings(in: line).first - } - - private func resolveIncludeURLs(_ includePath: String, relativeTo directory: URL) throws -> [URL] { - guard containsGlobPattern(includePath) else { - return [resolveIncludeURL(includePath, relativeTo: directory)] - } - - let patternURL = resolveIncludeURL(includePath, relativeTo: directory) - let patternPath = patternURL.path - let searchRoot = globSearchRoot(for: patternPath) - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: searchRoot.path) else { - watchedDirectories.insert(existingWatchDirectory(for: searchRoot)) - return [] - } - watchedDirectories.insert(searchRoot) - - let regex = try NSRegularExpression(pattern: globRegex(for: patternPath)) - let enumerator = fileManager.enumerator( - at: searchRoot, - includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) - - var matches: [URL] = [] - while let candidate = enumerator?.nextObject() as? URL { - let values = try? candidate.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) - if values?.isDirectory == true { - watchedDirectories.insert(candidate.standardizedFileURL) - continue - } - guard values?.isRegularFile == true else { continue } - - let path = candidate.standardizedFileURL.path - let range = NSRange(location: 0, length: (path as NSString).length) - if regex.firstMatch(in: path, range: range) != nil { - matches.append(candidate.standardizedFileURL) - } - } - - return matches.sorted { $0.path < $1.path } - } - - private func resolveIncludeURL(_ includePath: String, relativeTo directory: URL) -> URL { - if includePath.hasPrefix("/") { - return URL(fileURLWithPath: includePath).standardizedFileURL - } - return directory.appendingPathComponent(includePath).standardizedFileURL - } - - private func containsGlobPattern(_ path: String) -> Bool { - path.contains("*") || path.contains("?") || path.contains("[") - } - - private func globSearchRoot(for patternPath: String) -> URL { - let components = (patternPath as NSString).pathComponents - let prefix = components.prefix { !containsGlobPattern($0) } - let rootPath = NSString.path(withComponents: Array(prefix)) - return URL(fileURLWithPath: rootPath.isEmpty ? "/" : rootPath).standardizedFileURL - } - - private func existingWatchDirectory(for missingDirectory: URL) -> URL { - var candidate = missingDirectory.standardizedFileURL - let fileManager = FileManager.default - while candidate.path != "/" { - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), - isDirectory.boolValue { - return candidate - } - candidate.deleteLastPathComponent() - } - return URL(fileURLWithPath: "/") - } - - private func globRegex(for patternPath: String) -> String { - let characters = Array(patternPath) - var regex = "^" - var index = 0 - - while index < characters.count { - let character = characters[index] - if character == "*" { - let nextIndex = index + 1 - if nextIndex < characters.count, characters[nextIndex] == "*" { - let slashIndex = index + 2 - if slashIndex < characters.count, characters[slashIndex] == "/" { - regex += "(?:.*/)?" - index += 3 - } else { - regex += ".*" - index += 2 - } - } else { - regex += "[^/]*" - index += 1 - } - } else if character == "?" { - regex += "[^/]" - index += 1 - } else if character == "[" { - let start = index - index += 1 - while index < characters.count, characters[index] != "]" { - index += 1 - } - if index < characters.count { - regex += String(characters[start...index]) - index += 1 - } else { - regex += NSRegularExpression.escapedPattern(for: String(character)) - } - } else { - regex += NSRegularExpression.escapedPattern(for: String(character)) - index += 1 - } - } - - return regex + "$" - } - - private func parseOpen(remainder: String, date: String, sourceFile: URL, line: Int) { - let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) - guard parts.count >= 2 else { return } - let account = parts[1] - let currencies = parts.dropFirst(2).joined(separator: " ") - accountsByName[account] = BeancountAccount( - name: account, - openDate: date, - currencies: currencies.isEmpty ? nil : currencies, - sourceFile: sourceFile, - line: line - ) - } - - private func parsePrice(remainder: String, date: String, sourceFile: URL, line: Int) { - let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) - guard parts.count >= 4 else { return } - prices.append(BeancountPrice( - id: prices.count + 1, - date: date, - commodity: parts[1], - amount: parts[2], - currency: parts[3], - sourceFile: sourceFile, - line: line - )) - } - - private func parseBalance(remainder: String, date: String, sourceFile: URL, line: Int) { - let parts = remainder.split(whereSeparator: \.isWhitespace).map(String.init) - guard parts.count >= 4 else { return } - balances.append(BeancountBalance( - id: balances.count + 1, - date: date, - account: parts[1], - amount: parts[2], - commodity: parts[3], - sourceFile: sourceFile, - line: line - )) - } - - private func parseTransaction( - id: Int, - remainder: String, - date: String, - sourceFile: URL, - line: Int - ) -> BeancountTransaction { - let quoted = quotedStrings(in: remainder) - return BeancountTransaction( - id: id, - date: date, - flag: String(remainder.prefix(1)), - payee: quoted.count >= 2 ? quoted[0] : nil, - narration: quoted.count >= 2 ? quoted[1] : quoted.first, - sourceFile: sourceFile, - line: line - ) - } - - private func parsePosting( - _ rawLine: String, - id: Int, - transactionId: Int, - date: String, - sourceFile: URL, - line: Int - ) -> BeancountPosting? { - let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, !trimmed.hasPrefix(";"), !trimmed.hasPrefix("#") else { return nil } - - let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init) - guard let account = parts.first, isAccountName(account) else { return nil } - let amount = parts.count >= 2 ? parts[1] : nil - let commodity = parts.count >= 3 ? parts[2] : nil - - return BeancountPosting( - id: id, - transactionId: transactionId, - date: date, - account: account, - amount: amount, - commodity: commodity, - sourceFile: sourceFile, - line: line - ) - } - - private func isAccountName(_ value: String) -> Bool { - guard value.contains(":"), !value.hasSuffix(":") else { return false } - let components = value.split(separator: ":", omittingEmptySubsequences: false) - guard components.count >= 2 else { return false } - - let allowedSymbols = CharacterSet(charactersIn: "-_") - return components.allSatisfy { component in - guard let first = component.unicodeScalars.first, - CharacterSet.uppercaseLetters.contains(first) else { - return false - } - return component.unicodeScalars.allSatisfy { scalar in - CharacterSet.alphanumerics.contains(scalar) || allowedSymbols.contains(scalar) - } - } - } - - private func parseDatePrefix(_ line: String) -> String? { - guard line.count >= 11 else { return nil } - let prefix = String(line.prefix(10)) - let pattern = #"^\d{4}-\d{2}-\d{2}$"# - guard prefix.range(of: pattern, options: .regularExpression) != nil, - line.dropFirst(10).first?.isWhitespace == true else { - return nil - } - return prefix - } - - private func quotedStrings(in line: String) -> [String] { - var values: [String] = [] - var current = "" - var inQuote = false - var isEscaped = false - - for character in line { - if isEscaped { - current.append(character) - isEscaped = false - continue - } - if character == "\\" { - isEscaped = true - continue - } - if character == "\"" { - if inQuote { - values.append(current) - current = "" - } - inQuote.toggle() - continue - } - if inQuote { - current.append(character) - } - } - - return values - } - - private func stripComment(_ line: String) -> String { - var inQuote = false - var isEscaped = false - var result = "" - for character in line { - if isEscaped { - result.append(character) - isEscaped = false - continue - } - if character == "\\" { - result.append(character) - isEscaped = true - continue - } - if character == "\"" { - inQuote.toggle() - } - if character == ";" && !inQuote { - break - } - result.append(character) - } - return result - } -} diff --git a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift index 62e918995..dab793c19 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPlugin.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift @@ -37,12 +37,12 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { static let columnTypesByCategory: [String: [String]] = [ "Integer": ["INTEGER"], "String": ["TEXT"], - "Date": ["DATE"], - "Decimal": ["DECIMAL"] + "Date": ["DATE"] ] static let immutableColumns: [String] = [ "id", "transaction_id", "date", "flag", "payee", "narration", - "account", "amount", "commodity", "currency", "source_file", "line" + "account", "amount", "commodity", "cost_number", "cost_currency", + "currency", "currencies", "name", "open_date", "path" ] static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( @@ -60,7 +60,7 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { "COALESCE", "NULLIF", "ROUND", "ABS", "DATE", "STRFTIME", "SUBSTR", "LOWER", "UPPER" ], - dataTypes: ["INTEGER", "TEXT", "DATE", "DECIMAL"], + dataTypes: ["INTEGER", "TEXT", "DATE"], regexSyntax: .unsupported, booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, diff --git a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift index 658b55114..fc927708f 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -3,8 +3,8 @@ // BeancountDriverPlugin // -import Foundation import Dispatch +import Foundation import SQLite3 import TableProPluginKit @@ -18,15 +18,15 @@ enum BeancountDriverError: LocalizedError { var errorDescription: String? { switch self { case .notConnected: - return "Not connected to Beancount ledger" + return String(localized: "Not connected to Beancount ledger") case .connectionFailed(let message): - return "Failed to open Beancount ledger: \(message)" + return String(format: String(localized: "Failed to open Beancount ledger: %@"), message) case .queryFailed(let message): return message case .readOnly: - return "Beancount ledgers are exposed as a read-only SQL database" + return String(localized: "Beancount ledgers are exposed as a read-only SQL database") case .rustledgerUnavailable: - return "BQL requires the bundled rustledger helper" + return String(localized: "The Beancount driver requires its bundled rustledger helper") } } } @@ -35,28 +35,32 @@ extension BeancountDriverError: PluginDriverError { var pluginErrorMessage: String { errorDescription ?? "Beancount driver error" } } -private struct BeancountSQLiteResult { - let columns: [String] - let columnTypeNames: [String] - let rows: [[PluginCellValue]] - let rowsAffected: Int - let executionTime: TimeInterval - let isTruncated: Bool -} - private struct BeancountSourceSignature: Equatable { let modificationDate: Date? let fileSize: UInt64? } +private struct BeancountProjection { + let handle: OpaquePointer + let watchedURLs: [URL] + let signatures: [String: BeancountSourceSignature] +} + final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private let lock = NSLock() private var db: OpaquePointer? private var ledgerURL: URL? - private var ledger: BeancountLedger? + private var watchedURLs: [URL] = [] private var sourceSignatures: [String: BeancountSourceSignature] = [:] + private static let postingsQuery = + "SELECT id, date, flag, payee, narration, account, number, currency, cost_number, cost_currency " + + "FROM #postings ORDER BY id" + private static let accountsQuery = "SELECT account, open, currencies FROM #accounts ORDER BY account" + private static let pricesQuery = "SELECT date, currency, amount FROM #prices ORDER BY date, currency" + private static let balancesQuery = "SELECT date, account, amount FROM #balances ORDER BY date, account" + var currentSchema: String? { nil } var serverVersion: String? { "Beancount" } var supportsSchemas: Bool { false } @@ -71,28 +75,18 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let path = expandPath(config.database) let fileURL = URL(fileURLWithPath: path) guard FileManager.default.fileExists(atPath: path) else { - throw BeancountDriverError.connectionFailed("File does not exist at \(path)") - } - - let parsed = try BeancountLedgerParser().parse(fileURL: fileURL) - let signatures = try Self.signatures(for: Self.watchedURLs(for: parsed)) - var handle: OpaquePointer? - guard sqlite3_open(":memory:", &handle) == SQLITE_OK, let handle else { - throw BeancountDriverError.connectionFailed("Could not initialize SQL projection") + throw BeancountDriverError.connectionFailed( + String(format: String(localized: "File does not exist at %@"), path) + ) } - do { - try Self.load(parsed, into: handle) - } catch { - sqlite3_close(handle) - throw error - } + let projection = try Self.buildProjection(ledgerURL: fileURL) lock.withLock { - db = handle + db = projection.handle ledgerURL = fileURL - ledger = parsed - sourceSignatures = signatures + watchedURLs = projection.watchedURLs + sourceSignatures = projection.signatures } } @@ -103,7 +97,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { db = nil } ledgerURL = nil - ledger = nil + watchedURLs = [] sourceSignatures.removeAll() } } @@ -136,30 +130,16 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let bql = Self.extractBQLQuery(from: query) { return try executeBQL(query: bql) } - let raw = try executeSQLite(query: query, parameters: []) - return PluginQueryResult( - columns: raw.columns, - columnTypeNames: raw.columnTypeNames, - rows: raw.rows, - rowsAffected: raw.rowsAffected, - executionTime: raw.executionTime, - isTruncated: raw.isTruncated - ) + return try executeSQLite(query: query, parameters: []) } func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { if Self.extractBQLQuery(from: query) != nil { - throw BeancountDriverError.queryFailed("BQL queries do not support SQL parameters") + throw BeancountDriverError.queryFailed( + String(localized: "BQL queries do not support SQL parameters") + ) } - let raw = try executeSQLite(query: query, parameters: parameters) - return PluginQueryResult( - columns: raw.columns, - columnTypeNames: raw.columnTypeNames, - rows: raw.rows, - rowsAffected: raw.rowsAffected, - executionTime: raw.executionTime, - isTruncated: raw.isTruncated - ) + return try executeSQLite(query: query, parameters: parameters) } func fetchRowCount(query: String) async throws -> Int { @@ -220,7 +200,9 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { WHERE type = 'table' AND name = '\(escapeStringLiteral(table))' """) guard let ddl = result.rows.first?.first?.asText else { - throw BeancountDriverError.queryFailed("Failed to fetch DDL for table '\(table)'") + throw BeancountDriverError.queryFailed( + String(format: String(localized: "Failed to fetch DDL for table '%@'"), table) + ) } return ddl.hasSuffix(";") ? ddl : ddl + ";" } @@ -277,90 +259,49 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func streamRows(query: String) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - do { - let result: BeancountSQLiteResult - if let bql = Self.extractBQLQuery(from: query) { - let bqlResult = try executeBQL(query: bql) - result = BeancountSQLiteResult( - columns: bqlResult.columns, - columnTypeNames: bqlResult.columnTypeNames, - rows: bqlResult.rows, - rowsAffected: bqlResult.rowsAffected, - executionTime: bqlResult.executionTime, - isTruncated: bqlResult.isTruncated - ) - } else { - result = try executeSQLite(query: query, parameters: []) + Task { + do { + let result: PluginQueryResult + if let bql = Self.extractBQLQuery(from: query) { + result = try self.executeBQL(query: bql) + } else { + result = try self.executeSQLite(query: query, parameters: []) + } + continuation.yield(.header(PluginStreamHeader( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + estimatedRowCount: result.rows.count + ))) + continuation.yield(.rows(result.rows)) + continuation.finish() + } catch { + continuation.finish(throwing: error) } - continuation.yield(.header(PluginStreamHeader( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - estimatedRowCount: result.rows.count - ))) - continuation.yield(.rows(result.rows)) - continuation.finish() - } catch { - continuation.finish(throwing: error) } } } + // MARK: - BQL + private func executeBQL(query: String) throws -> PluginQueryResult { let ledgerPath = try lock.withLock { () -> String in guard let ledgerURL else { throw BeancountDriverError.notConnected } return ledgerURL.path } - let rustledgerPath = try Self.rustledgerExecutablePath() let start = Date() - - let process = Process() - process.executableURL = URL(fileURLWithPath: rustledgerPath) - process.arguments = ["query", "-f", "json", "--no-errors", ledgerPath, query] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - let outputCollector = PipeDataCollector() - let errorCollector = PipeDataCollector() - let readers = DispatchGroup() - readers.enter() - DispatchQueue.global(qos: .userInitiated).async { - outputCollector.set(stdout.fileHandleForReading.readDataToEndOfFile()) - readers.leave() - } - readers.enter() - DispatchQueue.global(qos: .userInitiated).async { - errorCollector.set(stderr.fileHandleForReading.readDataToEndOfFile()) - readers.leave() - } - - try process.run() - process.waitUntilExit() - readers.wait() - - let output = outputCollector.data - let errorOutput = errorCollector.data - guard process.terminationStatus == 0 else { - let message = String(data: errorOutput, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - throw BeancountDriverError.queryFailed(message?.isEmpty == false ? message! : "rustledger query failed") - } - - return try Self.decodeRustledgerQueryOutput( - output, - executionTime: Date().timeIntervalSince(start) - ) + let output = try Self.runRledger(arguments: ["query", "-f", "json", "--no-errors", ledgerPath, query]) + return try Self.decodeRustledgerQueryOutput(output, executionTime: Date().timeIntervalSince(start)) } - private func executeSQLite(query: String, parameters: [PluginCellValue]) throws -> BeancountSQLiteResult { + // MARK: - SQLite Projection + + private func executeSQLite(query: String, parameters: [PluginCellValue]) throws -> PluginQueryResult { guard Self.isReadOnlyQuery(query) else { throw BeancountDriverError.readOnly } + try reloadProjectionIfNeeded() return try lock.withLock { - try reloadProjectionIfNeeded() guard let db = self.db else { throw BeancountDriverError.notConnected } let start = Date() @@ -408,7 +349,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { rows.append((0.. PluginQueryResult { let safeOffset = max(offset, 0) let safeLimit = max(limit, 0) @@ -434,72 +401,223 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - private func reloadProjectionIfNeeded() throws { - guard let ledgerURL, let ledger else { return } - let currentSignatures = try Self.signatures(for: Self.watchedURLs(for: ledger)) - guard currentSignatures != sourceSignatures else { return } + private static func buildProjection(ledgerURL: URL) throws -> BeancountProjection { + let graph = try BeancountIncludeResolver().resolve(fileURL: ledgerURL) + let watched = Array(Set(graph.sourceFiles + graph.watchedDirectories)).sorted { $0.path < $1.path } + let fileSignatures = signatures(for: watched) + let ledgerPath = ledgerURL.path - let parsed = try BeancountLedgerParser().parse(fileURL: ledgerURL) - let newSignatures = try Self.signatures(for: Self.watchedURLs(for: parsed)) var handle: OpaquePointer? guard sqlite3_open(":memory:", &handle) == SQLITE_OK, let handle else { - throw BeancountDriverError.connectionFailed("Could not initialize SQL projection") + throw BeancountDriverError.connectionFailed( + String(localized: "Could not initialize SQL projection") + ) } do { - try Self.load(parsed, into: handle) + try createSchema(handle) + try loadTransactionsAndPostings(query(ledgerPath: ledgerPath, bql: postingsQuery), into: handle) + try loadAccounts(query(ledgerPath: ledgerPath, bql: accountsQuery), into: handle) + try loadPrices(query(ledgerPath: ledgerPath, bql: pricesQuery), into: handle) + try loadBalances(query(ledgerPath: ledgerPath, bql: balancesQuery), into: handle) + try loadSourceFiles(graph.sourceFiles, into: handle) + try exec(handle, "PRAGMA query_only = ON") } catch { sqlite3_close(handle) throw error } - if let db { - sqlite3_close(db) + return BeancountProjection(handle: handle, watchedURLs: watched, signatures: fileSignatures) + } + + private static func query(ledgerPath: String, bql: String) throws -> [[String: Any]] { + let data = try runRledger(arguments: ["query", "-f", "json", "--no-errors", ledgerPath, bql]) + return try decodeRledgerRows(data) + } + + private static func createSchema(_ db: OpaquePointer) throws { + try exec(db, """ + CREATE TABLE transactions ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + flag TEXT NOT NULL, + payee TEXT, + narration TEXT + ); + CREATE TABLE postings ( + id INTEGER PRIMARY KEY, + transaction_id INTEGER NOT NULL, + date DATE NOT NULL, + account TEXT NOT NULL, + amount TEXT, + commodity TEXT, + cost_number TEXT, + cost_currency TEXT + ); + CREATE TABLE accounts ( + name TEXT PRIMARY KEY, + open_date DATE, + currencies TEXT + ); + CREATE TABLE prices ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + commodity TEXT NOT NULL, + amount TEXT NOT NULL, + currency TEXT NOT NULL + ); + CREATE TABLE balances ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL, + account TEXT NOT NULL, + amount TEXT NOT NULL, + commodity TEXT NOT NULL + ); + CREATE TABLE source_files ( + path TEXT PRIMARY KEY + ); + """) + } + + private static func loadTransactionsAndPostings(_ rows: [[String: Any]], into db: OpaquePointer) throws { + var seenTransactions: Set = [] + var postingId = 0 + + for row in rows { + guard let transactionId = intValue(row["id"]), + let date = stringValue(row["date"]) else { + continue + } + let flag = stringValue(row["flag"]) ?? "*" + + if seenTransactions.insert(transactionId).inserted { + try insert(db, sql: """ + INSERT INTO transactions (id, date, flag, payee, narration) + VALUES (?, ?, ?, ?, ?) + """, values: [ + String(transactionId), + date, + flag, + stringValue(row["payee"]), + stringValue(row["narration"]) + ]) + } + + guard let account = stringValue(row["account"]) else { continue } + postingId += 1 + try insert(db, sql: """ + INSERT INTO postings + (id, transaction_id, date, account, amount, commodity, cost_number, cost_currency) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, values: [ + String(postingId), + String(transactionId), + date, + account, + stringValue(row["number"]), + stringValue(row["currency"]), + stringValue(row["cost_number"]), + stringValue(row["cost_currency"]) + ]) } - db = handle - self.ledger = parsed - sourceSignatures = newSignatures } - private static func cellValue(statement: OpaquePointer?, column: Int32) -> PluginCellValue { - let type = sqlite3_column_type(statement, column) - if type == SQLITE_NULL { - return .null + private static func loadAccounts(_ rows: [[String: Any]], into db: OpaquePointer) throws { + for row in rows { + guard let name = stringValue(row["account"]) else { continue } + try insert(db, sql: """ + INSERT OR REPLACE INTO accounts (name, open_date, currencies) + VALUES (?, ?, ?) + """, values: [ + name, + stringValue(row["open"]), + currencyList(row["currencies"]) + ]) } - if type == SQLITE_BLOB { - let byteCount = Int(sqlite3_column_bytes(statement, column)) - guard byteCount > 0, let blob = sqlite3_column_blob(statement, column) else { - return .bytes(Data()) + } + + private static func loadPrices(_ rows: [[String: Any]], into db: OpaquePointer) throws { + var priceId = 0 + for row in rows { + guard let commodity = stringValue(row["currency"]), + let date = stringValue(row["date"]) else { + continue } - return .bytes(Data(bytes: blob, count: byteCount)) + let amount = amountFields(row["amount"]) + guard let number = amount.number, let currency = amount.currency else { continue } + priceId += 1 + try insert(db, sql: """ + INSERT INTO prices (id, date, commodity, amount, currency) + VALUES (?, ?, ?, ?, ?) + """, values: [String(priceId), date, commodity, number, currency]) } - guard let text = sqlite3_column_text(statement, column) else { - return .null + } + + private static func loadBalances(_ rows: [[String: Any]], into db: OpaquePointer) throws { + var balanceId = 0 + for row in rows { + guard let account = stringValue(row["account"]), + let date = stringValue(row["date"]) else { + continue + } + let amount = amountFields(row["amount"]) + guard let number = amount.number, let commodity = amount.currency else { continue } + balanceId += 1 + try insert(db, sql: """ + INSERT INTO balances (id, date, account, amount, commodity) + VALUES (?, ?, ?, ?, ?) + """, values: [String(balanceId), date, account, number, commodity]) } - return .text(String(cString: text)) } - private static func isReadOnlyQuery(_ query: String) -> Bool { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return true } - let lower = trimmed.lowercased() - return lower.hasPrefix("select") - || lower.hasPrefix("with") - || lower.hasPrefix("pragma table_info") - || lower.hasPrefix("pragma database_list") - || lower.hasPrefix("explain") + private static func loadSourceFiles(_ files: [URL], into db: OpaquePointer) throws { + for file in files { + try insert(db, sql: "INSERT OR IGNORE INTO source_files (path) VALUES (?)", values: [file.path]) + } } - private static func extractBQLQuery(from query: String) -> String? { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercased = trimmed.lowercased() - if lowercased.hasPrefix("bql:") { - return String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + // MARK: - rustledger Helpers + + private static func runRledger(arguments: [String]) throws -> Data { + let rustledgerPath = try rustledgerExecutablePath() + + let process = Process() + process.executableURL = URL(fileURLWithPath: rustledgerPath) + process.arguments = arguments + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + let outputCollector = PipeDataCollector() + let errorCollector = PipeDataCollector() + let readers = DispatchGroup() + readers.enter() + DispatchQueue.global(qos: .userInitiated).async { + outputCollector.set(stdout.fileHandleForReading.readDataToEndOfFile()) + readers.leave() } - if lowercased.hasPrefix("bql ") { - return String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + readers.enter() + DispatchQueue.global(qos: .userInitiated).async { + errorCollector.set(stderr.fileHandleForReading.readDataToEndOfFile()) + readers.leave() } - return nil + + try process.run() + readers.wait() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let message = String(data: errorCollector.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let message, !message.isEmpty { + throw BeancountDriverError.queryFailed(message) + } + throw BeancountDriverError.queryFailed(String(localized: "rustledger command failed")) + } + + return outputCollector.data } private static func rustledgerExecutablePath() throws -> String { @@ -523,16 +641,27 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw BeancountDriverError.rustledgerUnavailable } + private static func parseRledgerJSON(_ data: Data) throws -> (columns: [String]?, rows: [[String: Any]]) { + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw BeancountDriverError.queryFailed(String(localized: "Invalid rustledger JSON output")) + } + return (dictionary["columns"] as? [String], (dictionary["rows"] as? [[String: Any]]) ?? []) + } + + private static func decodeRledgerRows(_ data: Data) throws -> [[String: Any]] { + try parseRledgerJSON(data).rows + } + private static func decodeRustledgerQueryOutput( _ data: Data, executionTime: TimeInterval ) throws -> PluginQueryResult { - let object = try JSONSerialization.jsonObject(with: data) - guard let dictionary = object as? [String: Any], - let columns = dictionary["columns"] as? [String], - let rawRows = dictionary["rows"] as? [[String: Any]] else { - throw BeancountDriverError.queryFailed("Invalid rustledger JSON output") + let parsed = try parseRledgerJSON(data) + guard let columns = parsed.columns else { + throw BeancountDriverError.queryFailed(String(localized: "Invalid rustledger JSON output")) } + let rawRows = parsed.rows let rows = rawRows.prefix(PluginRowLimits.emergencyMax).map { rawRow in columns.map { column -> PluginCellValue in @@ -581,131 +710,77 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return String(describing: value) } - private static func load(_ ledger: BeancountLedger, into db: OpaquePointer) throws { - try exec(db, """ - CREATE TABLE transactions ( - id INTEGER PRIMARY KEY, - date DATE NOT NULL, - flag TEXT NOT NULL, - payee TEXT, - narration TEXT, - source_file TEXT NOT NULL, - line INTEGER NOT NULL - ); - CREATE TABLE postings ( - id INTEGER PRIMARY KEY, - transaction_id INTEGER NOT NULL, - date DATE NOT NULL, - account TEXT NOT NULL, - amount DECIMAL, - commodity TEXT, - source_file TEXT NOT NULL, - line INTEGER NOT NULL - ); - CREATE TABLE accounts ( - name TEXT PRIMARY KEY, - open_date DATE NOT NULL, - currencies TEXT, - source_file TEXT NOT NULL, - line INTEGER NOT NULL - ); - CREATE TABLE prices ( - id INTEGER PRIMARY KEY, - date DATE NOT NULL, - commodity TEXT NOT NULL, - amount DECIMAL NOT NULL, - currency TEXT NOT NULL, - source_file TEXT NOT NULL, - line INTEGER NOT NULL - ); - CREATE TABLE balances ( - id INTEGER PRIMARY KEY, - date DATE NOT NULL, - account TEXT NOT NULL, - amount DECIMAL NOT NULL, - commodity TEXT NOT NULL, - source_file TEXT NOT NULL, - line INTEGER NOT NULL - ); - CREATE TABLE source_files ( - path TEXT PRIMARY KEY - ); - """) + // MARK: - Value Decoding - for transaction in ledger.transactions { - try insert(db, sql: """ - INSERT INTO transactions (id, date, flag, payee, narration, source_file, line) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, values: [ - String(transaction.id), - transaction.date, - transaction.flag, - transaction.payee, - transaction.narration, - transaction.sourceFile.path, - String(transaction.line) - ]) - } - for posting in ledger.postings { - try insert(db, sql: """ - INSERT INTO postings (id, transaction_id, date, account, amount, commodity, source_file, line) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, values: [ - String(posting.id), - String(posting.transactionId), - posting.date, - posting.account, - posting.amount, - posting.commodity, - posting.sourceFile.path, - String(posting.line) - ]) + private static func stringValue(_ value: Any?) -> String? { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + default: + return nil } - for account in ledger.accounts { - try insert(db, sql: """ - INSERT INTO accounts (name, open_date, currencies, source_file, line) - VALUES (?, ?, ?, ?, ?) - """, values: [ - account.name, - account.openDate, - account.currencies, - account.sourceFile.path, - String(account.line) - ]) + } + + private static func intValue(_ value: Any?) -> Int? { + switch value { + case let number as NSNumber: + return number.intValue + case let string as String: + return Int(string) + default: + return nil } - for price in ledger.prices { - try insert(db, sql: """ - INSERT INTO prices (id, date, commodity, amount, currency, source_file, line) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, values: [ - String(price.id), - price.date, - price.commodity, - price.amount, - price.currency, - price.sourceFile.path, - String(price.line) - ]) + } + + private static func amountFields(_ value: Any?) -> (number: String?, currency: String?) { + guard let dictionary = value as? [String: Any] else { return (nil, nil) } + return (stringValue(dictionary["number"]), stringValue(dictionary["currency"])) + } + + private static func currencyList(_ value: Any?) -> String? { + guard let array = value as? [Any] else { return stringValue(value) } + let items = array.compactMap { $0 as? String } + return items.isEmpty ? nil : items.joined(separator: " ") + } + + // MARK: - SQLite Helpers + + private static func cellValue(statement: OpaquePointer?, column: Int32) -> PluginCellValue { + let type = sqlite3_column_type(statement, column) + if type == SQLITE_NULL { + return .null } - for balance in ledger.balances { - try insert(db, sql: """ - INSERT INTO balances (id, date, account, amount, commodity, source_file, line) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, values: [ - String(balance.id), - balance.date, - balance.account, - balance.amount, - balance.commodity, - balance.sourceFile.path, - String(balance.line) - ]) + if type == SQLITE_BLOB { + let byteCount = Int(sqlite3_column_bytes(statement, column)) + guard byteCount > 0, let blob = sqlite3_column_blob(statement, column) else { + return .bytes(Data()) + } + return .bytes(Data(bytes: blob, count: byteCount)) } - for sourceFile in ledger.sourceFiles { - try insert(db, sql: "INSERT INTO source_files (path) VALUES (?)", values: [sourceFile.path]) + guard let text = sqlite3_column_text(statement, column) else { + return .null } + return .text(String(cString: text)) + } - try exec(db, "PRAGMA query_only = ON") + private static func isReadOnlyQuery(_ query: String) -> Bool { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + let lower = trimmed.lowercased() + return lower.hasPrefix("select") + || lower.hasPrefix("with") + || lower.hasPrefix("pragma table_info") + || lower.hasPrefix("pragma database_list") + || lower.hasPrefix("explain") + } + + private static func extractBQLQuery(from query: String) -> String? { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercased = trimmed.lowercased() + guard lowercased.hasPrefix("bql:") || lowercased.hasPrefix("bql ") else { return nil } + return String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) } private static func exec(_ db: OpaquePointer, _ sql: String) throws { @@ -740,21 +815,17 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - private static func signatures(for sourceFiles: [URL]) throws -> [String: BeancountSourceSignature] { - try sourceFiles.reduce(into: [:]) { signatures, fileURL in + private static func signatures(for sourceFiles: [URL]) -> [String: BeancountSourceSignature] { + sourceFiles.reduce(into: [:]) { signatures, fileURL in let path = fileURL.path - let attributes = try FileManager.default.attributesOfItem(atPath: path) + let attributes = try? FileManager.default.attributesOfItem(atPath: path) signatures[path] = BeancountSourceSignature( - modificationDate: attributes[.modificationDate] as? Date, - fileSize: (attributes[.size] as? NSNumber)?.uint64Value + modificationDate: attributes?[.modificationDate] as? Date, + fileSize: (attributes?[.size] as? NSNumber)?.uint64Value ) } } - private static func watchedURLs(for ledger: BeancountLedger) -> [URL] { - Array(Set(ledger.sourceFiles + ledger.watchedDirectories)).sorted { $0.path < $1.path } - } - private func expandPath(_ path: String) -> String { guard path.hasPrefix("~") else { return path } return NSString(string: path).expandingTildeInPath diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 2c0c3c721..c09bc4112 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -9,502 +9,12 @@ import TableProPluginKit extension PluginMetadataRegistry { // swiftlint:disable function_body_length func registryPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { - let clickhouseDialect = SQLDialectDescriptor( - identifierQuote: "`", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", - "UNION", "INTERSECT", "EXCEPT", - "FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS", - "OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC", - "MATERIALIZED", "WITH" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - "NOW", "TODAY", "YESTERDAY", - "CAST", - "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", - "TOSTRING", "TOINT32", "FORMATDATETIME", - "IF", "MULTIIF", - "ARRAYMAP", "ARRAYJOIN", - "MATCH", "CURRENTDATABASE", "VERSION", - "QUANTILE", "TOPK" - ], - dataTypes: [ - "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", - "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256", - "FLOAT32", "FLOAT64", - "DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256", - "STRING", "FIXEDSTRING", "UUID", - "DATE", "DATE32", "DATETIME", "DATETIME64", - "ARRAY", "TUPLE", "MAP", - "NULLABLE", "LOWCARDINALITY", - "ENUM8", "ENUM16", - "IPV4", "IPV6", - "JSON", "BOOL" - ], - tableOptions: [ - "ENGINE=MergeTree()", "ORDER BY", "PARTITION BY", "SETTINGS" - ], - regexSyntax: .match, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .implicit, - paginationStyle: .limit, - requiresBackslashEscaping: true - ) - - let clickhouseColumnTypes: [String: [String]] = [ - "Integer": [ - "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", - "Int8", "Int16", "Int32", "Int64", "Int128", "Int256" - ], - "Float": ["Float32", "Float64", "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256"], - "String": ["String", "FixedString", "Enum8", "Enum16"], - "Date": ["Date", "Date32", "DateTime", "DateTime64"], - "Binary": [], - "Boolean": ["Bool"], - "JSON": ["JSON"], - "UUID": ["UUID"], - "Array": ["Array"], - "Map": ["Map"], - "Tuple": ["Tuple"], - "IP": ["IPv4", "IPv6"], - "Geo": ["Point", "Ring", "Polygon", "MultiPolygon"] - ] - - let mssqlDialect = SQLDialectDescriptor( - identifierQuote: "[", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "TOP", "OFFSET", "FETCH", "NEXT", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", "EXEC", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "IDENTITY", "NOLOCK", "WITH", "ROWCOUNT", "NEWID", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "IIF", - "UNION", "INTERSECT", "EXCEPT", - "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", - "PRINT", "GO", "EXECUTE", - "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", - "RETURNING", "OUTPUT", "INSERTED", "DELETED" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LEN", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "CHARINDEX", "PATINDEX", - "STUFF", "FORMAT", - "GETDATE", "GETUTCDATE", "SYSDATETIME", "CURRENT_TIMESTAMP", - "DATEADD", "DATEDIFF", "DATENAME", "DATEPART", - "CONVERT", "CAST", - "ROUND", "CEILING", "FLOOR", "ABS", "POWER", "SQRT", "RAND", - "ISNULL", "ISNUMERIC", "ISDATE", "COALESCE", "NEWID", - "OBJECT_ID", "OBJECT_NAME", "SCHEMA_NAME", "DB_NAME", - "SCOPE_IDENTITY", "@@IDENTITY", "@@ROWCOUNT" - ], - dataTypes: [ - "INT", "INTEGER", "TINYINT", "SMALLINT", "BIGINT", - "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", - "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT", - "BINARY", "VARBINARY", "IMAGE", - "DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", - "BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", - "ROWVERSION", "TIMESTAMP", "HIERARCHYID" - ], - tableOptions: [ - "ON", "CLUSTERED", "NONCLUSTERED", "WITH", "TEXTIMAGE_ON" - ], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .offsetFetch, - autoLimitStyle: .top - ) - - let mssqlColumnTypes: [String: [String]] = [ - "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], - "Float": ["FLOAT", "REAL", "DECIMAL", "NUMERIC", "MONEY", "SMALLMONEY"], - "String": ["CHAR", "VARCHAR", "TEXT", "NCHAR", "NVARCHAR", "NTEXT"], - "Date": ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"], - "Binary": ["BINARY", "VARBINARY", "IMAGE"], - "Boolean": ["BIT"], - "XML": ["XML"], - "UUID": ["UNIQUEIDENTIFIER"], - "Spatial": ["GEOMETRY", "GEOGRAPHY"], - "Other": ["SQL_VARIANT", "TIMESTAMP", "ROWVERSION", "CURSOR", "TABLE", "HIERARCHYID"] - ] - - let oracleDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "FETCH", "FIRST", "ROWS", "ONLY", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "DECODE", - "UNION", "INTERSECT", "MINUS", - "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", - "EXECUTE", "IMMEDIATE", - "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", - "RETURNING", "CONNECT", "LEVEL", "START", "WITH", "PRIOR", - "ROWNUM", "ROWID", "DUAL", "SYSDATE", "SYSTIMESTAMP" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", - "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", - "INITCAP", "TRANSLATE", - "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", - "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", - "EXTRACT", "TO_DATE", "TO_CHAR", "TO_NUMBER", "TO_TIMESTAMP", - "TRUNC", "ROUND", - "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", "SIGN", - "NVL", "NVL2", "DECODE", "COALESCE", "NULLIF", - "GREATEST", "LEAST", "CAST", - "SYS_GUID", "DBMS_RANDOM.VALUE", "USER", "SYS_CONTEXT" - ], - dataTypes: [ - "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", - "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", - "BLOB", "RAW", "LONG RAW", "BFILE", - "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", - "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", - "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" - ], - tableOptions: [ - "TABLESPACE", "PCTFREE", "INITRANS" - ], - regexSyntax: .regexpLike, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .offsetFetch, - offsetFetchOrderBy: "ORDER BY 1", - autoLimitStyle: .fetchFirst - ) - - let oracleColumnTypes: [String: [String]] = [ - "Integer": ["NUMBER", "INTEGER", "INT", "SMALLINT"], - "Float": ["FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION"], - "String": ["VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR", "CLOB", "NCLOB", "LONG"], - "Date": [ - "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", - "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND" - ], - "Binary": ["RAW", "LONG RAW", "BLOB", "BFILE"], - "Boolean": [], - "XML": ["XMLTYPE"], - "Spatial": ["SDO_GEOMETRY"], - "Other": ["ROWID", "UROWID"] - ] - - let duckdbDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT", - "QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING", - "INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH", - "EXPORT", "IMPORT", - "WITH", "RECURSIVE", "MATERIALIZED", - "EXPLAIN", "ANALYZE", - "WINDOW", "OVER", "PARTITION" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - "LIST_AGG", "STRING_AGG", "ARRAY_AGG", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", - "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", - "EPOCH_MS", - "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", - "CAST", - "REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON", - "GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST", - "GENERATE_SERIES", "RANGE" - ], - dataTypes: [ - "INTEGER", "BIGINT", "HUGEINT", "UHUGEINT", - "DOUBLE", "FLOAT", "DECIMAL", - "VARCHAR", "TEXT", "BLOB", - "BOOLEAN", - "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL", - "UUID", "JSON", - "LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT" - ], - regexSyntax: .regexpMatches, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .limit - ) - - let duckdbColumnTypes: [String: [String]] = [ - "Integer": [ - "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", - "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT" - ], - "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC"], - "String": ["VARCHAR", "TEXT", "CHAR", "BPCHAR"], - "Date": [ - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", - "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "INTERVAL" - ], - "Binary": ["BLOB", "BYTEA", "BIT", "BITSTRING"], - "Boolean": ["BOOLEAN"], - "JSON": ["JSON"], - "UUID": ["UUID"], - "List": ["LIST"], - "Struct": ["STRUCT"], - "Map": ["MAP"], - "Union": ["UNION"], - "Enum": ["ENUM"] - ] - - let cassandraDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "AS", - "ORDER", "BY", "LIMIT", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", - "PRIMARY", "KEY", "ADD", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", - "CASE", "WHEN", "THEN", "ELSE", "END", - "KEYSPACE", "USE", "TRUNCATE", "BATCH", "GRANT", "REVOKE", - "CLUSTERING", "PARTITION", "TTL", "WRITETIME", - "ALLOW FILTERING", "IF NOT EXISTS", "IF EXISTS", - "USING TIMESTAMP", "USING TTL", - "MATERIALIZED VIEW", "CONTAINS", "FROZEN", "COUNTER", "TOKEN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - "NOW", "UUID", "TOTIMESTAMP", "TOKEN", "TTL", "WRITETIME", - "MINTIMEUUID", "MAXTIMEUUID", "TODATE", "TOUNIXTIMESTAMP", - "CAST" - ], - dataTypes: [ - "TEXT", "VARCHAR", "ASCII", - "INT", "BIGINT", "SMALLINT", "TINYINT", "VARINT", - "FLOAT", "DOUBLE", "DECIMAL", - "BOOLEAN", "UUID", "TIMEUUID", - "TIMESTAMP", "DATE", "TIME", - "BLOB", "INET", "COUNTER", - "LIST", "SET", "MAP", "TUPLE", "FROZEN" - ], - regexSyntax: .unsupported, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .limit, - autoLimitStyle: .limit - ) - - let cassandraColumnTypes: [String: [String]] = [ - "Numeric": [ - "TINYINT", "SMALLINT", "INT", "BIGINT", "VARINT", - "FLOAT", "DOUBLE", "DECIMAL", "COUNTER" - ], - "String": ["TEXT", "VARCHAR", "ASCII"], - "Date": ["TIMESTAMP", "DATE", "TIME"], - "Binary": ["BLOB"], - "Boolean": ["BOOLEAN"], - "Other": ["UUID", "TIMEUUID", "INET", "LIST", "SET", "MAP", "TUPLE", "FROZEN"] - ] - - let mongoCompletions: [CompletionEntry] = [ - CompletionEntry(label: "db.", insertText: "db."), - CompletionEntry(label: "db.runCommand", insertText: "db.runCommand"), - CompletionEntry(label: "db.adminCommand", insertText: "db.adminCommand"), - CompletionEntry(label: "db.createView", insertText: "db.createView"), - CompletionEntry(label: "db.createCollection", insertText: "db.createCollection"), - CompletionEntry(label: "show dbs", insertText: "show dbs"), - CompletionEntry(label: "show collections", insertText: "show collections"), - CompletionEntry(label: ".find", insertText: ".find"), - CompletionEntry(label: ".findOne", insertText: ".findOne"), - CompletionEntry(label: ".aggregate", insertText: ".aggregate"), - CompletionEntry(label: ".insertOne", insertText: ".insertOne"), - CompletionEntry(label: ".insertMany", insertText: ".insertMany"), - CompletionEntry(label: ".updateOne", insertText: ".updateOne"), - CompletionEntry(label: ".updateMany", insertText: ".updateMany"), - CompletionEntry(label: ".deleteOne", insertText: ".deleteOne"), - CompletionEntry(label: ".deleteMany", insertText: ".deleteMany"), - CompletionEntry(label: ".replaceOne", insertText: ".replaceOne"), - CompletionEntry(label: ".findOneAndUpdate", insertText: ".findOneAndUpdate"), - CompletionEntry(label: ".findOneAndReplace", insertText: ".findOneAndReplace"), - CompletionEntry(label: ".findOneAndDelete", insertText: ".findOneAndDelete"), - CompletionEntry(label: ".countDocuments", insertText: ".countDocuments"), - CompletionEntry(label: ".createIndex", insertText: ".createIndex") - ] - - let mongoColumnTypes: [String: [String]] = [ - "String": ["string", "objectId", "regex"], - "Number": ["int", "long", "double", "decimal"], - "Date": ["date", "timestamp"], - "Binary": ["binData"], - "Boolean": ["bool"], - "Array": ["array"], - "Object": ["object"], - "Null": ["null"], - "Other": ["javascript", "minKey", "maxKey"] - ] - - let etcdCompletions: [CompletionEntry] = [ - CompletionEntry(label: "get", insertText: "get"), - CompletionEntry(label: "put", insertText: "put"), - CompletionEntry(label: "del", insertText: "del"), - CompletionEntry(label: "watch", insertText: "watch"), - CompletionEntry(label: "lease grant", insertText: "lease grant"), - CompletionEntry(label: "lease revoke", insertText: "lease revoke"), - CompletionEntry(label: "lease timetolive", insertText: "lease timetolive"), - CompletionEntry(label: "lease list", insertText: "lease list"), - CompletionEntry(label: "lease keep-alive", insertText: "lease keep-alive"), - CompletionEntry(label: "member list", insertText: "member list"), - CompletionEntry(label: "endpoint status", insertText: "endpoint status"), - CompletionEntry(label: "endpoint health", insertText: "endpoint health"), - CompletionEntry(label: "compaction", insertText: "compaction"), - CompletionEntry(label: "auth enable", insertText: "auth enable"), - CompletionEntry(label: "auth disable", insertText: "auth disable"), - CompletionEntry(label: "user add", insertText: "user add"), - CompletionEntry(label: "user delete", insertText: "user delete"), - CompletionEntry(label: "user list", insertText: "user list"), - CompletionEntry(label: "role add", insertText: "role add"), - CompletionEntry(label: "role delete", insertText: "role delete"), - CompletionEntry(label: "role list", insertText: "role list"), - CompletionEntry(label: "user grant-role", insertText: "user grant-role"), - CompletionEntry(label: "user revoke-role", insertText: "user revoke-role"), - CompletionEntry(label: "--prefix", insertText: "--prefix"), - CompletionEntry(label: "--limit", insertText: "--limit="), - CompletionEntry(label: "--keys-only", insertText: "--keys-only"), - CompletionEntry(label: "--lease", insertText: "--lease="), - ] - - let redisCompletions: [CompletionEntry] = [ - CompletionEntry(label: "GET", insertText: "GET"), - CompletionEntry(label: "SET", insertText: "SET"), - CompletionEntry(label: "DEL", insertText: "DEL"), - CompletionEntry(label: "EXISTS", insertText: "EXISTS"), - CompletionEntry(label: "KEYS", insertText: "KEYS"), - CompletionEntry(label: "HGET", insertText: "HGET"), - CompletionEntry(label: "HSET", insertText: "HSET"), - CompletionEntry(label: "HGETALL", insertText: "HGETALL"), - CompletionEntry(label: "HDEL", insertText: "HDEL"), - CompletionEntry(label: "LPUSH", insertText: "LPUSH"), - CompletionEntry(label: "RPUSH", insertText: "RPUSH"), - CompletionEntry(label: "LRANGE", insertText: "LRANGE"), - CompletionEntry(label: "LLEN", insertText: "LLEN"), - CompletionEntry(label: "SADD", insertText: "SADD"), - CompletionEntry(label: "SMEMBERS", insertText: "SMEMBERS"), - CompletionEntry(label: "SREM", insertText: "SREM"), - CompletionEntry(label: "SCARD", insertText: "SCARD"), - CompletionEntry(label: "ZADD", insertText: "ZADD"), - CompletionEntry(label: "ZRANGE", insertText: "ZRANGE"), - CompletionEntry(label: "ZREM", insertText: "ZREM"), - CompletionEntry(label: "ZSCORE", insertText: "ZSCORE"), - CompletionEntry(label: "EXPIRE", insertText: "EXPIRE"), - CompletionEntry(label: "TTL", insertText: "TTL"), - CompletionEntry(label: "PERSIST", insertText: "PERSIST"), - CompletionEntry(label: "TYPE", insertText: "TYPE"), - CompletionEntry(label: "SCAN", insertText: "SCAN"), - CompletionEntry(label: "HSCAN", insertText: "HSCAN"), - CompletionEntry(label: "SSCAN", insertText: "SSCAN"), - CompletionEntry(label: "ZSCAN", insertText: "ZSCAN"), - CompletionEntry(label: "INFO", insertText: "INFO"), - CompletionEntry(label: "DBSIZE", insertText: "DBSIZE"), - CompletionEntry(label: "FLUSHDB", insertText: "FLUSHDB"), - CompletionEntry(label: "SELECT", insertText: "SELECT"), - CompletionEntry(label: "INCR", insertText: "INCR"), - CompletionEntry(label: "DECR", insertText: "DECR"), - CompletionEntry(label: "APPEND", insertText: "APPEND"), - CompletionEntry(label: "MGET", insertText: "MGET"), - CompletionEntry(label: "MSET", insertText: "MSET") - ] - - let redisColumnTypes: [String: [String]] = [ - "String": ["string"], - "List": ["list"], - "Set": ["set"], - "Sorted Set": ["zset"], - "Hash": ["hash"], - "Stream": ["stream"], - "HyperLogLog": ["hyperloglog"], - "Bitmap": ["bitmap"], - "Geospatial": ["geo"] - ] - - let d1Dialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", - "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", - "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", - "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", - "REPLACE", "INSTR", "PRINTF", - "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", - "ABS", "ROUND", "RANDOM", - "CAST", "TYPEOF", - "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" - ], - dataTypes: [ - "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", - "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "UNSIGNED", "BIG", "INT2", "INT8", - "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", - "NVARCHAR", "CLOB", - "DOUBLE", "PRECISION", "FLOAT", - "DECIMAL", "BOOLEAN", "DATE", "DATETIME" - ], - tableOptions: ["WITHOUT ROWID", "STRICT"], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .limit - ) - - let d1ColumnTypes: [String: [String]] = [ - "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], - "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], - "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], - "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], - "Binary": ["BLOB"], - "Boolean": ["BOOLEAN"] - ] + let ( + clickhouseDialect, clickhouseColumnTypes, mssqlDialect, mssqlColumnTypes, + oracleDialect, oracleColumnTypes, duckdbDialect, duckdbColumnTypes, + cassandraDialect, cassandraColumnTypes, mongoCompletions, mongoColumnTypes, + etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes + ) = registryDefaultIngredients() return [ ("MongoDB", PluginMetadataSnapshot( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift new file mode 100644 index 000000000..b6c23d61a --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -0,0 +1,534 @@ +// +// PluginMetadataRegistry+RegistryIngredients.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + // swiftlint:disable function_body_length large_tuple + func registryDefaultIngredients() -> ( + clickhouseDialect: SQLDialectDescriptor, + clickhouseColumnTypes: [String: [String]], + mssqlDialect: SQLDialectDescriptor, + mssqlColumnTypes: [String: [String]], + oracleDialect: SQLDialectDescriptor, + oracleColumnTypes: [String: [String]], + duckdbDialect: SQLDialectDescriptor, + duckdbColumnTypes: [String: [String]], + cassandraDialect: SQLDialectDescriptor, + cassandraColumnTypes: [String: [String]], + mongoCompletions: [CompletionEntry], + mongoColumnTypes: [String: [String]], + etcdCompletions: [CompletionEntry], + redisCompletions: [CompletionEntry], + redisColumnTypes: [String: [String]], + d1Dialect: SQLDialectDescriptor, + d1ColumnTypes: [String: [String]] + ) { + let clickhouseDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", + "UNION", "INTERSECT", "EXCEPT", + "FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS", + "OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC", + "MATERIALIZED", "WITH" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + "NOW", "TODAY", "YESTERDAY", + "CAST", + "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", + "TOSTRING", "TOINT32", "FORMATDATETIME", + "IF", "MULTIIF", + "ARRAYMAP", "ARRAYJOIN", + "MATCH", "CURRENTDATABASE", "VERSION", + "QUANTILE", "TOPK" + ], + dataTypes: [ + "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", + "UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256", + "FLOAT32", "FLOAT64", + "DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256", + "STRING", "FIXEDSTRING", "UUID", + "DATE", "DATE32", "DATETIME", "DATETIME64", + "ARRAY", "TUPLE", "MAP", + "NULLABLE", "LOWCARDINALITY", + "ENUM8", "ENUM16", + "IPV4", "IPV6", + "JSON", "BOOL" + ], + tableOptions: [ + "ENGINE=MergeTree()", "ORDER BY", "PARTITION BY", "SETTINGS" + ], + regexSyntax: .match, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, + paginationStyle: .limit, + requiresBackslashEscaping: true + ) + + let clickhouseColumnTypes: [String: [String]] = [ + "Integer": [ + "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", "UInt256", + "Int8", "Int16", "Int32", "Int64", "Int128", "Int256" + ], + "Float": ["Float32", "Float64", "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256"], + "String": ["String", "FixedString", "Enum8", "Enum16"], + "Date": ["Date", "Date32", "DateTime", "DateTime64"], + "Binary": [], + "Boolean": ["Bool"], + "JSON": ["JSON"], + "UUID": ["UUID"], + "Array": ["Array"], + "Map": ["Map"], + "Tuple": ["Tuple"], + "IP": ["IPv4", "IPv6"], + "Geo": ["Point", "Ring", "Polygon", "MultiPolygon"] + ] + + let mssqlDialect = SQLDialectDescriptor( + identifierQuote: "[", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "TOP", "OFFSET", "FETCH", "NEXT", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", "EXEC", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "IDENTITY", "NOLOCK", "WITH", "ROWCOUNT", "NEWID", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "IIF", + "UNION", "INTERSECT", "EXCEPT", + "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", + "PRINT", "GO", "EXECUTE", + "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "RETURNING", "OUTPUT", "INSERTED", "DELETED" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LEN", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "CHARINDEX", "PATINDEX", + "STUFF", "FORMAT", + "GETDATE", "GETUTCDATE", "SYSDATETIME", "CURRENT_TIMESTAMP", + "DATEADD", "DATEDIFF", "DATENAME", "DATEPART", + "CONVERT", "CAST", + "ROUND", "CEILING", "FLOOR", "ABS", "POWER", "SQRT", "RAND", + "ISNULL", "ISNUMERIC", "ISDATE", "COALESCE", "NEWID", + "OBJECT_ID", "OBJECT_NAME", "SCHEMA_NAME", "DB_NAME", + "SCOPE_IDENTITY", "@@IDENTITY", "@@ROWCOUNT" + ], + dataTypes: [ + "INT", "INTEGER", "TINYINT", "SMALLINT", "BIGINT", + "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", + "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT", + "BINARY", "VARBINARY", "IMAGE", + "DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", + "BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", + "ROWVERSION", "TIMESTAMP", "HIERARCHYID" + ], + tableOptions: [ + "ON", "CLUSTERED", "NONCLUSTERED", "WITH", "TEXTIMAGE_ON" + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, + autoLimitStyle: .top + ) + + let mssqlColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "BIGINT"], + "Float": ["FLOAT", "REAL", "DECIMAL", "NUMERIC", "MONEY", "SMALLMONEY"], + "String": ["CHAR", "VARCHAR", "TEXT", "NCHAR", "NVARCHAR", "NTEXT"], + "Date": ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"], + "Binary": ["BINARY", "VARBINARY", "IMAGE"], + "Boolean": ["BIT"], + "XML": ["XML"], + "UUID": ["UNIQUEIDENTIFIER"], + "Spatial": ["GEOMETRY", "GEOGRAPHY"], + "Other": ["SQL_VARIANT", "TIMESTAMP", "ROWVERSION", "CURSOR", "TABLE", "HIERARCHYID"] + ] + + let oracleDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "FETCH", "FIRST", "ROWS", "ONLY", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "DECODE", + "UNION", "INTERSECT", "MINUS", + "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", + "EXECUTE", "IMMEDIATE", + "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "RETURNING", "CONNECT", "LEVEL", "START", "WITH", "PRIOR", + "ROWNUM", "ROWID", "DUAL", "SYSDATE", "SYSTIMESTAMP" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", + "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", + "INITCAP", "TRANSLATE", + "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", + "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", + "EXTRACT", "TO_DATE", "TO_CHAR", "TO_NUMBER", "TO_TIMESTAMP", + "TRUNC", "ROUND", + "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", "SIGN", + "NVL", "NVL2", "DECODE", "COALESCE", "NULLIF", + "GREATEST", "LEAST", "CAST", + "SYS_GUID", "DBMS_RANDOM.VALUE", "USER", "SYS_CONTEXT" + ], + dataTypes: [ + "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", + "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", + "BLOB", "RAW", "LONG RAW", "BFILE", + "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", + "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" + ], + tableOptions: [ + "TABLESPACE", "PCTFREE", "INITRANS" + ], + regexSyntax: .regexpLike, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY 1", + autoLimitStyle: .fetchFirst + ) + + let oracleColumnTypes: [String: [String]] = [ + "Integer": ["NUMBER", "INTEGER", "INT", "SMALLINT"], + "Float": ["FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION"], + "String": ["VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR", "CLOB", "NCLOB", "LONG"], + "Date": [ + "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND" + ], + "Binary": ["RAW", "LONG RAW", "BLOB", "BFILE"], + "Boolean": [], + "XML": ["XMLTYPE"], + "Spatial": ["SDO_GEOMETRY"], + "Other": ["ROWID", "UROWID"] + ] + + let duckdbDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT", + "QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING", + "INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH", + "EXPORT", "IMPORT", + "WITH", "RECURSIVE", "MATERIALIZED", + "EXPLAIN", "ANALYZE", + "WINDOW", "OVER", "PARTITION" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "LIST_AGG", "STRING_AGG", "ARRAY_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + "EPOCH_MS", + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + "CAST", + "REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON", + "GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST", + "GENERATE_SERIES", "RANGE" + ], + dataTypes: [ + "INTEGER", "BIGINT", "HUGEINT", "UHUGEINT", + "DOUBLE", "FLOAT", "DECIMAL", + "VARCHAR", "TEXT", "BLOB", + "BOOLEAN", + "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL", + "UUID", "JSON", + "LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT" + ], + regexSyntax: .regexpMatches, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + let duckdbColumnTypes: [String: [String]] = [ + "Integer": [ + "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", + "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT" + ], + "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC"], + "String": ["VARCHAR", "TEXT", "CHAR", "BPCHAR"], + "Date": [ + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", + "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "INTERVAL" + ], + "Binary": ["BLOB", "BYTEA", "BIT", "BITSTRING"], + "Boolean": ["BOOLEAN"], + "JSON": ["JSON"], + "UUID": ["UUID"], + "List": ["LIST"], + "Struct": ["STRUCT"], + "Map": ["MAP"], + "Union": ["UNION"], + "Enum": ["ENUM"] + ] + + let cassandraDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "AS", + "ORDER", "BY", "LIMIT", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", + "PRIMARY", "KEY", "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", + "CASE", "WHEN", "THEN", "ELSE", "END", + "KEYSPACE", "USE", "TRUNCATE", "BATCH", "GRANT", "REVOKE", + "CLUSTERING", "PARTITION", "TTL", "WRITETIME", + "ALLOW FILTERING", "IF NOT EXISTS", "IF EXISTS", + "USING TIMESTAMP", "USING TTL", + "MATERIALIZED VIEW", "CONTAINS", "FROZEN", "COUNTER", "TOKEN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", + "NOW", "UUID", "TOTIMESTAMP", "TOKEN", "TTL", "WRITETIME", + "MINTIMEUUID", "MAXTIMEUUID", "TODATE", "TOUNIXTIMESTAMP", + "CAST" + ], + dataTypes: [ + "TEXT", "VARCHAR", "ASCII", + "INT", "BIGINT", "SMALLINT", "TINYINT", "VARINT", + "FLOAT", "DOUBLE", "DECIMAL", + "BOOLEAN", "UUID", "TIMEUUID", + "TIMESTAMP", "DATE", "TIME", + "BLOB", "INET", "COUNTER", + "LIST", "SET", "MAP", "TUPLE", "FROZEN" + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + autoLimitStyle: .limit + ) + + let cassandraColumnTypes: [String: [String]] = [ + "Numeric": [ + "TINYINT", "SMALLINT", "INT", "BIGINT", "VARINT", + "FLOAT", "DOUBLE", "DECIMAL", "COUNTER" + ], + "String": ["TEXT", "VARCHAR", "ASCII"], + "Date": ["TIMESTAMP", "DATE", "TIME"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"], + "Other": ["UUID", "TIMEUUID", "INET", "LIST", "SET", "MAP", "TUPLE", "FROZEN"] + ] + + let mongoCompletions: [CompletionEntry] = [ + CompletionEntry(label: "db.", insertText: "db."), + CompletionEntry(label: "db.runCommand", insertText: "db.runCommand"), + CompletionEntry(label: "db.adminCommand", insertText: "db.adminCommand"), + CompletionEntry(label: "db.createView", insertText: "db.createView"), + CompletionEntry(label: "db.createCollection", insertText: "db.createCollection"), + CompletionEntry(label: "show dbs", insertText: "show dbs"), + CompletionEntry(label: "show collections", insertText: "show collections"), + CompletionEntry(label: ".find", insertText: ".find"), + CompletionEntry(label: ".findOne", insertText: ".findOne"), + CompletionEntry(label: ".aggregate", insertText: ".aggregate"), + CompletionEntry(label: ".insertOne", insertText: ".insertOne"), + CompletionEntry(label: ".insertMany", insertText: ".insertMany"), + CompletionEntry(label: ".updateOne", insertText: ".updateOne"), + CompletionEntry(label: ".updateMany", insertText: ".updateMany"), + CompletionEntry(label: ".deleteOne", insertText: ".deleteOne"), + CompletionEntry(label: ".deleteMany", insertText: ".deleteMany"), + CompletionEntry(label: ".replaceOne", insertText: ".replaceOne"), + CompletionEntry(label: ".findOneAndUpdate", insertText: ".findOneAndUpdate"), + CompletionEntry(label: ".findOneAndReplace", insertText: ".findOneAndReplace"), + CompletionEntry(label: ".findOneAndDelete", insertText: ".findOneAndDelete"), + CompletionEntry(label: ".countDocuments", insertText: ".countDocuments"), + CompletionEntry(label: ".createIndex", insertText: ".createIndex") + ] + + let mongoColumnTypes: [String: [String]] = [ + "String": ["string", "objectId", "regex"], + "Number": ["int", "long", "double", "decimal"], + "Date": ["date", "timestamp"], + "Binary": ["binData"], + "Boolean": ["bool"], + "Array": ["array"], + "Object": ["object"], + "Null": ["null"], + "Other": ["javascript", "minKey", "maxKey"] + ] + + let etcdCompletions: [CompletionEntry] = [ + CompletionEntry(label: "get", insertText: "get"), + CompletionEntry(label: "put", insertText: "put"), + CompletionEntry(label: "del", insertText: "del"), + CompletionEntry(label: "watch", insertText: "watch"), + CompletionEntry(label: "lease grant", insertText: "lease grant"), + CompletionEntry(label: "lease revoke", insertText: "lease revoke"), + CompletionEntry(label: "lease timetolive", insertText: "lease timetolive"), + CompletionEntry(label: "lease list", insertText: "lease list"), + CompletionEntry(label: "lease keep-alive", insertText: "lease keep-alive"), + CompletionEntry(label: "member list", insertText: "member list"), + CompletionEntry(label: "endpoint status", insertText: "endpoint status"), + CompletionEntry(label: "endpoint health", insertText: "endpoint health"), + CompletionEntry(label: "compaction", insertText: "compaction"), + CompletionEntry(label: "auth enable", insertText: "auth enable"), + CompletionEntry(label: "auth disable", insertText: "auth disable"), + CompletionEntry(label: "user add", insertText: "user add"), + CompletionEntry(label: "user delete", insertText: "user delete"), + CompletionEntry(label: "user list", insertText: "user list"), + CompletionEntry(label: "role add", insertText: "role add"), + CompletionEntry(label: "role delete", insertText: "role delete"), + CompletionEntry(label: "role list", insertText: "role list"), + CompletionEntry(label: "user grant-role", insertText: "user grant-role"), + CompletionEntry(label: "user revoke-role", insertText: "user revoke-role"), + CompletionEntry(label: "--prefix", insertText: "--prefix"), + CompletionEntry(label: "--limit", insertText: "--limit="), + CompletionEntry(label: "--keys-only", insertText: "--keys-only"), + CompletionEntry(label: "--lease", insertText: "--lease="), + ] + + let redisCompletions: [CompletionEntry] = [ + CompletionEntry(label: "GET", insertText: "GET"), + CompletionEntry(label: "SET", insertText: "SET"), + CompletionEntry(label: "DEL", insertText: "DEL"), + CompletionEntry(label: "EXISTS", insertText: "EXISTS"), + CompletionEntry(label: "KEYS", insertText: "KEYS"), + CompletionEntry(label: "HGET", insertText: "HGET"), + CompletionEntry(label: "HSET", insertText: "HSET"), + CompletionEntry(label: "HGETALL", insertText: "HGETALL"), + CompletionEntry(label: "HDEL", insertText: "HDEL"), + CompletionEntry(label: "LPUSH", insertText: "LPUSH"), + CompletionEntry(label: "RPUSH", insertText: "RPUSH"), + CompletionEntry(label: "LRANGE", insertText: "LRANGE"), + CompletionEntry(label: "LLEN", insertText: "LLEN"), + CompletionEntry(label: "SADD", insertText: "SADD"), + CompletionEntry(label: "SMEMBERS", insertText: "SMEMBERS"), + CompletionEntry(label: "SREM", insertText: "SREM"), + CompletionEntry(label: "SCARD", insertText: "SCARD"), + CompletionEntry(label: "ZADD", insertText: "ZADD"), + CompletionEntry(label: "ZRANGE", insertText: "ZRANGE"), + CompletionEntry(label: "ZREM", insertText: "ZREM"), + CompletionEntry(label: "ZSCORE", insertText: "ZSCORE"), + CompletionEntry(label: "EXPIRE", insertText: "EXPIRE"), + CompletionEntry(label: "TTL", insertText: "TTL"), + CompletionEntry(label: "PERSIST", insertText: "PERSIST"), + CompletionEntry(label: "TYPE", insertText: "TYPE"), + CompletionEntry(label: "SCAN", insertText: "SCAN"), + CompletionEntry(label: "HSCAN", insertText: "HSCAN"), + CompletionEntry(label: "SSCAN", insertText: "SSCAN"), + CompletionEntry(label: "ZSCAN", insertText: "ZSCAN"), + CompletionEntry(label: "INFO", insertText: "INFO"), + CompletionEntry(label: "DBSIZE", insertText: "DBSIZE"), + CompletionEntry(label: "FLUSHDB", insertText: "FLUSHDB"), + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "INCR", insertText: "INCR"), + CompletionEntry(label: "DECR", insertText: "DECR"), + CompletionEntry(label: "APPEND", insertText: "APPEND"), + CompletionEntry(label: "MGET", insertText: "MGET"), + CompletionEntry(label: "MSET", insertText: "MSET") + ] + + let redisColumnTypes: [String: [String]] = [ + "String": ["string"], + "List": ["list"], + "Set": ["set"], + "Sorted Set": ["zset"], + "Hash": ["hash"], + "Stream": ["stream"], + "HyperLogLog": ["hyperloglog"], + "Bitmap": ["bitmap"], + "Geospatial": ["geo"] + ] + + let d1Dialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ], + tableOptions: ["WITHOUT ROWID", "STRICT"], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit + ) + + let d1ColumnTypes: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], + "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"] + ] + return ( + clickhouseDialect, clickhouseColumnTypes, mssqlDialect, mssqlColumnTypes, + oracleDialect, oracleColumnTypes, duckdbDialect, duckdbColumnTypes, + cassandraDialect, cassandraColumnTypes, mongoCompletions, mongoColumnTypes, + etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes + ) + } + // swiftlint:enable function_body_length large_tuple +} diff --git a/TableProTests/PluginTestSources/BeancountIncludeResolver.swift b/TableProTests/PluginTestSources/BeancountIncludeResolver.swift new file mode 120000 index 000000000..bd3aaa087 --- /dev/null +++ b/TableProTests/PluginTestSources/BeancountIncludeResolver.swift @@ -0,0 +1 @@ +../../Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/BeancountLedgerParser.swift b/TableProTests/PluginTestSources/BeancountLedgerParser.swift deleted file mode 120000 index 80626376b..000000000 --- a/TableProTests/PluginTestSources/BeancountLedgerParser.swift +++ /dev/null @@ -1 +0,0 @@ -../../Plugins/BeancountDriverPlugin/BeancountLedgerParser.swift \ No newline at end of file diff --git a/TableProTests/Plugins/BeancountIncludeResolverTests.swift b/TableProTests/Plugins/BeancountIncludeResolverTests.swift new file mode 100644 index 000000000..288b11785 --- /dev/null +++ b/TableProTests/Plugins/BeancountIncludeResolverTests.swift @@ -0,0 +1,100 @@ +// +// BeancountIncludeResolverTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("Beancount include resolver") +struct BeancountIncludeResolverTests { + @Test("collects the main ledger and every included file") + func resolvesIncludes() throws { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + try "2024-01-02 price USD 1.35 CAD\n" + .write(to: directory.appendingPathComponent("prices.beancount"), atomically: true, encoding: .utf8) + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + include "prices.beancount" + + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) + + #expect(graph.sourceFiles.map(\.lastPathComponent).sorted() == ["main.beancount", "prices.beancount"]) + } + + @Test("expands glob includes and watches their directories") + func resolvesGlobIncludes() throws { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let imports = directory.appendingPathComponent("imports", isDirectory: true) + let nested = imports.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + + try "2024-01-01 open Assets:Bank:Checking USD\n" + .write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) + try "2024-01-01 open Expenses:Food USD\n" + .write(to: nested.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + include "imports/*.beancount" + include "imports/**/*.beancount" + """.write(to: ledger, atomically: true, encoding: .utf8) + + let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) + + #expect(graph.sourceFiles.map(\.lastPathComponent).sorted() == [ + "accounts.beancount", + "expenses.beancount", + "main.beancount" + ]) + #expect(graph.watchedDirectories.map(\.lastPathComponent).contains("imports")) + #expect(graph.watchedDirectories.map(\.lastPathComponent).contains("nested")) + } + + @Test("detects include cycles instead of looping") + func detectsIncludeCycle() throws { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let first = directory.appendingPathComponent("first.beancount") + let second = directory.appendingPathComponent("second.beancount") + try "include \"second.beancount\"\n".write(to: first, atomically: true, encoding: .utf8) + try "include \"first.beancount\"\n".write(to: second, atomically: true, encoding: .utf8) + + #expect(throws: BeancountResolverError.self) { + _ = try BeancountIncludeResolver().resolve(fileURL: first) + } + } + + @Test("ignores filesystem-root glob includes") + func ignoresRootGlobIncludes() throws { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + include "/*.beancount" + + 2024-01-01 open Assets:Bank:Checking USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) + + #expect(graph.sourceFiles.map(\.lastPathComponent) == ["main.beancount"]) + } + + private static func makeTempDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-resolver-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/TableProTests/Plugins/BeancountLedgerParserTests.swift b/TableProTests/Plugins/BeancountLedgerParserTests.swift deleted file mode 100644 index 99207b6b0..000000000 --- a/TableProTests/Plugins/BeancountLedgerParserTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// BeancountLedgerParserTests.swift -// TableProTests -// - -import Foundation -import Testing - -@Suite("Beancount ledger parser") -struct BeancountLedgerParserTests { - @Test("loads transactions, postings, accounts, prices, balances, and includes") - func parsesCoreTablesAndIncludes() throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } - - let included = tempDirectory.appendingPathComponent("prices.beancount") - try """ - 2024-01-02 price USD 1.35 CAD - 2024-01-31 balance Assets:Bank:Checking 900.00 USD - """.write(to: included, atomically: true, encoding: .utf8) - - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - option "title" "Household" - include "prices.beancount" - - 2024-01-01 open Assets:Bank:Checking USD - 2024-01-01 open Expenses:Food USD - - 2024-01-15 * "Grocery Store" "Weekly shop" - Assets:Bank:Checking -100.00 USD - Expenses:Food 100.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let parsed = try BeancountLedgerParser().parse(fileURL: ledger) - - #expect(parsed.accounts.map(\.name).sorted() == ["Assets:Bank:Checking", "Expenses:Food"]) - #expect(parsed.transactions.map(\.payee) == ["Grocery Store"]) - #expect(parsed.transactions.map(\.narration) == ["Weekly shop"]) - #expect(parsed.postings.count == 2) - #expect(parsed.prices.first?.commodity == "USD") - #expect(parsed.prices.first?.currency == "CAD") - #expect(parsed.balances.first?.account == "Assets:Bank:Checking") - #expect(parsed.sourceFiles.map(\.lastPathComponent).sorted() == ["main.beancount", "prices.beancount"]) - } - - @Test("expands glob include paths") - func parsesGlobIncludes() throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } - - let imports = tempDirectory.appendingPathComponent("imports", isDirectory: true) - let nested = imports.appendingPathComponent("nested", isDirectory: true) - try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) - - try """ - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) - - try """ - 2024-01-01 open Expenses:Food USD - """.write(to: nested.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) - - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - include "imports/*.beancount" - include "imports/**/*.beancount" - - 2024-01-15 * "Grocery Store" "Weekly shop" - Assets:Bank:Checking -100.00 USD - Expenses:Food 100.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let parsed = try BeancountLedgerParser().parse(fileURL: ledger) - - #expect(parsed.accounts.map(\.name).sorted() == ["Assets:Bank:Checking", "Expenses:Food"]) - #expect(parsed.transactions.count == 1) - #expect(parsed.sourceFiles.map(\.lastPathComponent).sorted() == [ - "accounts.beancount", - "expenses.beancount", - "main.beancount" - ]) - #expect(parsed.watchedDirectories.map(\.lastPathComponent).contains("imports")) - #expect(parsed.watchedDirectories.map(\.lastPathComponent).contains("nested")) - } - - @Test("ignores transaction metadata lines when parsing postings") - func ignoresMetadataLinesInsideTransactions() throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-parser-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } - - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - 2024-01-15 * "Grocery Store" "Weekly shop" - receipt: "abc.pdf" - Assets:Bank:Checking -100.00 USD - Expenses:Food 100.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let parsed = try BeancountLedgerParser().parse(fileURL: ledger) - - #expect(parsed.postings.map(\.account) == ["Assets:Bank:Checking", "Expenses:Food"]) - } -} diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift index f5648ca77..f945fe066 100644 --- a/TableProTests/Plugins/BeancountPluginDriverTests.swift +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -7,197 +7,255 @@ import Foundation import TableProPluginKit import Testing -@Suite("Beancount plugin driver") +private enum RustledgerLocator { + static let path: String? = resolve() + + static func resolve() -> String? { + var candidates: [String] = [] + if let env = ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"] { + candidates.append(env) + } + if let bundled = Bundle.main.builtInPlugInsURL? + .appendingPathComponent("BeancountDriver.tableplugin/Contents/Resources/rledger").path { + candidates.append(bundled) + } + candidates.append(cachedPath()) + candidates.append(contentsOf: ["/opt/homebrew/bin/rledger", "/usr/local/bin/rledger"]) + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + static func cachedPath(file: StaticString = #filePath) -> String { + #if arch(arm64) + let triple = "aarch64-apple-darwin" + #else + let triple = "x86_64-apple-darwin" + #endif + var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + while directory.path != "/" { + let marker = directory.appendingPathComponent("TablePro.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return directory + .appendingPathComponent("Libs/rustledger/v0.15.0/\(triple)/rledger") + .path + } + directory = directory.deletingLastPathComponent() + } + return "" + } +} + +@Suite( + "Beancount plugin driver", + .serialized, + .enabled(if: RustledgerLocator.path != nil, "rustledger helper unavailable") +) struct BeancountPluginDriverTests { @Test("reloads the SQL projection when an included ledger file changes") func reloadsWhenIncludedFileChanges() async throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-driver-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } - let included = tempDirectory.appendingPathComponent("accounts.beancount") - try """ - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: included, atomically: true, encoding: .utf8) - - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - include "accounts.beancount" - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path - )) - try await driver.connect() - defer { - driver.disconnect() - } + let included = directory.appendingPathComponent("accounts.beancount") + try "2024-01-01 open Assets:Bank:Checking USD\n" + .write(to: included, atomically: true, encoding: .utf8) + + let ledger = directory.appendingPathComponent("main.beancount") + try "include \"accounts.beancount\"\n".write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } - var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) + var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) - try """ - 2024-01-01 open Assets:Bank:Checking USD - 2024-01-02 open Expenses:Food USD - """.write(to: included, atomically: true, encoding: .utf8) + try """ + 2024-01-01 open Assets:Bank:Checking USD + 2024-01-02 open Expenses:Food USD + """.write(to: included, atomically: true, encoding: .utf8) - result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + } } @Test("reloads the SQL projection when a glob include matches a new file") func reloadsWhenGlobIncludeMatchesNewFile() async throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-driver-glob-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } - let imports = tempDirectory.appendingPathComponent("imports", isDirectory: true) - try FileManager.default.createDirectory(at: imports, withIntermediateDirectories: true) - try """ - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) - - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - include "imports/*.beancount" - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path - )) - try await driver.connect() - defer { - driver.disconnect() - } + let imports = directory.appendingPathComponent("imports", isDirectory: true) + try FileManager.default.createDirectory(at: imports, withIntermediateDirectories: true) + try "2024-01-01 open Assets:Bank:Checking USD\n" + .write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) - var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) + let ledger = directory.appendingPathComponent("main.beancount") + try "include \"imports/*.beancount\"\n".write(to: ledger, atomically: true, encoding: .utf8) - try """ - 2024-01-02 open Expenses:Food USD - """.write(to: imports.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } - result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) + + try "2024-01-02 open Expenses:Food USD\n" + .write(to: imports.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) + + result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") + #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) + } } @Test("rejects write queries") func rejectsWriteQueries() async throws { - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-driver-read-only-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDirectory) + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try "2024-01-01 open Assets:Bank:Checking USD\n".write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } + + await #expect(throws: BeancountDriverError.self) { + _ = try await driver.execute(query: "DELETE FROM accounts") + } } + } + + @Test("projects authoritative posting amounts and resolved cost basis") + func projectsAuthoritativeAmounts() async throws { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Cash USD + 2024-01-01 open Assets:Stock HOOL + + 2024-01-05 * "Broker" "Buy stock" + Assets:Stock 10 HOOL {100.00 USD} + Assets:Cash -1,000.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path - )) - try await driver.connect() - defer { - driver.disconnect() + let result = try await driver.execute(query: """ + SELECT account, amount, commodity, cost_number, cost_currency + FROM postings ORDER BY account + """) + let byAccount = Dictionary( + uniqueKeysWithValues: result.rows.compactMap { row -> (String, [PluginCellValue])? in + guard let account = row[0].asText else { return nil } + return (account, row) + } + ) + + let cash = try #require(byAccount["Assets:Cash"]) + #expect(cash[1].asText == "-1000.00") + #expect(cash[2].asText == "USD") + + let stock = try #require(byAccount["Assets:Stock"]) + #expect(stock[1].asText == "10") + #expect(stock[2].asText == "HOOL") + #expect(stock[3].asText == "100.00") + #expect(stock[4].asText == "USD") } + } + + @Test("links postings to a single transaction row") + func linksPostingsToTransaction() async throws { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Cash USD + 2024-01-01 open Expenses:Food USD - await #expect(throws: BeancountDriverError.self) { - _ = try await driver.execute(query: "DELETE FROM accounts") + 2024-01-05 * "Cafe" "Coffee" + Expenses:Food 4.00 USD + Assets:Cash -4.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } + + let transactions = try await driver.execute(query: "SELECT id, payee, narration FROM transactions") + #expect(transactions.rows.count == 1) + #expect(transactions.rows.first?[1].asText == "Cafe") + + let transactionId = try #require(transactions.rows.first?[0].asText) + let postings = try await driver.execute(query: "SELECT transaction_id FROM postings") + #expect(postings.rows.count == 2) + #expect(postings.rows.allSatisfy { $0[0].asText == transactionId }) } } @Test("executes BQL queries through the rustledger helper") func executesBQLQueriesThroughRustledgerHelper() async throws { - let rustledger = try #require(Self.bundledRustledgerPath() ?? Self.installedRustledgerPath()) - let tempDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-driver-bql-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - defer { - unsetenv("TABLEPRO_RUSTLEDGER_BINARY") - try? FileManager.default.removeItem(at: tempDirectory) - } + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } - let ledger = tempDirectory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Bank:Checking USD - 2024-01-01 open Expenses:Food USD - 2024-01-01 open Income:Salary USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - setenv("TABLEPRO_RUSTLEDGER_BINARY", rustledger, 1) - - let driver = BeancountPluginDriver(config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path - )) - try await driver.connect() - defer { - driver.disconnect() - } + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Bank:Checking USD + 2024-01-01 open Expenses:Food USD + 2024-01-01 open Income:Salary USD + """.write(to: ledger, atomically: true, encoding: .utf8) - let result = try await driver.execute(query: "BQL: SELECT account FROM accounts ORDER BY account") + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } - #expect(result.columns == ["account"]) - #expect(result.rows.map { $0.first?.asText } == [ - "Assets:Bank:Checking", - "Expenses:Food", - "Income:Salary" - ]) + let result = try await driver.execute(query: "BQL: SELECT account FROM accounts ORDER BY account") + #expect(result.columns == ["account"]) + #expect(result.rows.map { $0.first?.asText } == [ + "Assets:Bank:Checking", + "Expenses:Food", + "Income:Salary" + ]) - let count = try await driver.fetchRowCount(query: "BQL: SELECT account FROM accounts ORDER BY account") - #expect(count == 3) + let count = try await driver.fetchRowCount(query: "BQL: SELECT account FROM accounts ORDER BY account") + #expect(count == 3) - let page = try await driver.fetchRows( - query: "BQL: SELECT account FROM accounts ORDER BY account", - offset: 1, - limit: 1 - ) - #expect(page.rows.map { $0.first?.asText } == ["Expenses:Food"]) + let page = try await driver.fetchRows( + query: "BQL: SELECT account FROM accounts ORDER BY account", + offset: 1, + limit: 1 + ) + #expect(page.rows.map { $0.first?.asText } == ["Expenses:Food"]) + } } - private static func installedRustledgerPath() -> String? { - let candidates = [ - ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"], - "/opt/homebrew/bin/rledger", - "/usr/local/bin/rledger" - ].compactMap { $0 } + // MARK: - Helpers - return candidates.first { path in - FileManager.default.isExecutableFile(atPath: path) - } + private static func withRustledger(_ body: () async throws -> Void) async throws { + let rledger = try #require(RustledgerLocator.path) + setenv("TABLEPRO_RUSTLEDGER_BINARY", rledger, 1) + defer { unsetenv("TABLEPRO_RUSTLEDGER_BINARY") } + try await body() } - private static func bundledRustledgerPath() -> String? { - guard let path = Bundle.main.builtInPlugInsURL? - .appendingPathComponent("BeancountDriver.tableplugin") - .appendingPathComponent("Contents/Resources/rledger") - .path else { - return nil - } + private static func config(_ ledger: URL) -> DriverConnectionConfig { + DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: ledger.path) + } - return FileManager.default.isExecutableFile(atPath: path) ? path : nil + private static func makeTempDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("beancount-driver-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory } } diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 479cd6fe6..5db2308b9 100644 --- a/docs/databases/beancount.mdx +++ b/docs/databases/beancount.mdx @@ -38,13 +38,15 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. | Table | Contents | |-------|----------| -| `transactions` | Transaction date, flag, payee, narration, source file, and line | -| `postings` | Posting account, amount, commodity, source file, and line | +| `transactions` | Transaction date, flag, payee, and narration | +| `postings` | Posting account, amount, commodity, and resolved cost basis (`cost_number`, `cost_currency`) | | `accounts` | Opened accounts and declared currencies | | `prices` | Price directives | | `balances` | Balance directives | | `source_files` | Parsed ledger and include files | +Amounts are read straight from `rustledger`, so thousands separators, arithmetic, cost (`{}`), and price (`@`/`@@`) annotations are resolved to their booked values. + ## Includes The parser follows Beancount `include` directives. Literal includes and glob patterns such as `include "imports/*.beancount"` and `include "imports/**/*.beancount"` are supported. From 7584140fd948feaa9602c727fff3a4299fc6ed1d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:04:10 -0700 Subject: [PATCH 6/8] Fix TableProCore database type count test --- .../Tests/TableProModelsTests/DatabaseTypeTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index e4f434703..042c3a2d4 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -53,7 +53,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 18) + #expect(DatabaseType.allKnownTypes.count == 19) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) From 1cf531a0a8c106445b30463807edca67dd9f8d43 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:45:37 -0700 Subject: [PATCH 7/8] Fix Beancount rledger integration --- .github/workflows/build-plugin.yml | 2 +- CHANGELOG.md | 2 +- .../BeancountPluginDriver.swift | 137 ++++++++++++++--- Plugins/BeancountDriverPlugin/Info.plist | 6 +- TablePro.xcodeproj/project.pbxproj | 22 --- .../Plugins/BeancountPluginDriverTests.swift | 145 ++++++++++++++---- docs/databases/beancount.mdx | 25 ++- scripts/build-plugin.sh | 2 +- scripts/download-rustledger.sh | 144 ----------------- 9 files changed, 258 insertions(+), 227 deletions(-) delete mode 100755 scripts/download-rustledger.sh diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 9c90ea90b..e2b33d01d 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -160,7 +160,7 @@ jobs: CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; beancount) TARGET="BeancountDriver"; BUNDLE_ID="com.TablePro.BeancountDriver" - DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver with bundled rustledger BQL helper" + DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver using user-provided rledger" DB_TYPE_IDS='["Beancount"]'; ICON="beancount-icon"; BUNDLE_NAME="BeancountDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/beancount" ;; cassandra) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3103f7ada..68ff6494f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, and balances project to SQL tables with amounts booked by rustledger, and BQL runs with a `BQL:` prefix. (#1474) +- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables with amounts booked by user-provided `rledger`, and BQL runs with a `BQL:` prefix. (#1474) ### Fixed diff --git a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift index b82cf5b2f..3f63a8a28 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -13,7 +13,7 @@ enum BeancountDriverError: LocalizedError { case connectionFailed(String) case queryFailed(String) case readOnly - case rustledgerUnavailable + case rustledgerUnavailable(String) var errorDescription: String? { switch self { @@ -25,8 +25,8 @@ enum BeancountDriverError: LocalizedError { return message case .readOnly: return String(localized: "Beancount ledgers are exposed as a read-only SQL database") - case .rustledgerUnavailable: - return String(localized: "The Beancount driver requires its bundled rustledger helper") + case .rustledgerUnavailable(let message): + return message } } } @@ -60,7 +60,11 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + "FROM #postings ORDER BY id" private static let accountsQuery = "SELECT account, open, currencies FROM #accounts ORDER BY account" private static let pricesQuery = "SELECT date, currency, amount FROM #prices ORDER BY date, currency" - private static let balancesQuery = "SELECT date, account, amount FROM #balances ORDER BY date, account" + private static let balancesQuery = + "SELECT account, sum(position) AS balance FROM #postings GROUP BY account ORDER BY account" + private static let balanceAssertionsQuery = "SELECT date, account, amount FROM #balances ORDER BY date, account" + private static let rledgerCapabilityLock = NSLock() + private static var rledgerNoCacheSupport: [String: Bool] = [:] var currentSchema: String? { nil } var serverVersion: String? { "Beancount" } @@ -290,7 +294,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ledgerURL.path } let start = Date() - let output = try Self.runRledger(arguments: ["query", "-f", "json", "--no-errors", "--no-cache", ledgerPath, query]) + let output = try Self.runRledger(arguments: Self.rledgerQueryArguments(ledgerPath: ledgerPath, query: query)) return try Self.decodeRustledgerQueryOutput(output, executionTime: Date().timeIntervalSince(start)) } @@ -421,6 +425,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try loadAccounts(query(ledgerPath: ledgerPath, bql: accountsQuery), into: handle) try loadPrices(query(ledgerPath: ledgerPath, bql: pricesQuery), into: handle) try loadBalances(query(ledgerPath: ledgerPath, bql: balancesQuery), into: handle) + try loadBalanceAssertions(query(ledgerPath: ledgerPath, bql: balanceAssertionsQuery), into: handle) try loadSourceFiles(graph.sourceFiles, into: handle) try exec(handle, "PRAGMA query_only = ON") } catch { @@ -432,7 +437,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private static func query(ledgerPath: String, bql: String) throws -> [[String: Any]] { - let data = try runRledger(arguments: ["query", "-f", "json", "--no-errors", "--no-cache", ledgerPath, bql]) + let data = try runRledger(arguments: rledgerQueryArguments(ledgerPath: ledgerPath, query: bql)) return try decodeRledgerRows(data) } @@ -468,6 +473,12 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { currency TEXT NOT NULL ); CREATE TABLE balances ( + id INTEGER PRIMARY KEY, + account TEXT NOT NULL, + amount TEXT NOT NULL, + commodity TEXT NOT NULL + ); + CREATE TABLE balance_assertions ( id INTEGER PRIMARY KEY, date DATE NOT NULL, account TEXT NOT NULL, @@ -557,15 +568,27 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static func loadBalances(_ rows: [[String: Any]], into db: OpaquePointer) throws { var balanceId = 0 for row in rows { - guard let account = stringValue(row["account"]), - let date = stringValue(row["date"]) else { - continue + guard let account = stringValue(row["account"]) else { continue } + for position in inventoryPositions(row["balance"]) { + balanceId += 1 + try insert(db, sql: """ + INSERT INTO balances (id, account, amount, commodity) + VALUES (?, ?, ?, ?) + """, values: [String(balanceId), account, position.number, position.currency]) } + } + } + + private static func loadBalanceAssertions(_ rows: [[String: Any]], into db: OpaquePointer) throws { + var balanceId = 0 + for row in rows { + guard let account = stringValue(row["account"]), + let date = stringValue(row["date"]) else { continue } let amount = amountFields(row["amount"]) guard let number = amount.number, let commodity = amount.currency else { continue } balanceId += 1 try insert(db, sql: """ - INSERT INTO balances (id, date, account, amount, commodity) + INSERT INTO balance_assertions (id, date, account, amount, commodity) VALUES (?, ?, ?, ?, ?) """, values: [String(balanceId), date, account, number, commodity]) } @@ -579,6 +602,16 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - rustledger Helpers + private static func rledgerQueryArguments(ledgerPath: String, query: String) throws -> [String] { + let rustledgerPath = try rustledgerExecutablePath() + var arguments = ["query", "-f", "json", "--no-errors"] + if rledgerSupportsNoCache(executablePath: rustledgerPath) { + arguments.append("--no-cache") + } + arguments.append(contentsOf: [ledgerPath, query]) + return arguments + } + private static func runRledger(arguments: [String]) throws -> Data { let rustledgerPath = try rustledgerExecutablePath() @@ -621,25 +654,69 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return outputCollector.data } + private static func rledgerSupportsNoCache(executablePath: String) -> Bool { + rledgerCapabilityLock.lock() + if let cached = rledgerNoCacheSupport[executablePath] { + rledgerCapabilityLock.unlock() + return cached + } + rledgerCapabilityLock.unlock() + + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = ["query", "--help"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + let supports: Bool + do { + try process.run() + process.waitUntilExit() + var helpData = stdout.fileHandleForReading.readDataToEndOfFile() + helpData.append(stderr.fileHandleForReading.readDataToEndOfFile()) + let help = String(data: helpData, encoding: .utf8) ?? "" + supports = process.terminationStatus == 0 && help.contains("--no-cache") + } catch { + supports = false + } + + rledgerCapabilityLock.withLock { + rledgerNoCacheSupport[executablePath] = supports + } + return supports + } + private static func rustledgerExecutablePath() throws -> String { let environment = ProcessInfo.processInfo.environment - if let path = environment["TABLEPRO_RUSTLEDGER_BINARY"], - FileManager.default.isExecutableFile(atPath: path) { - return path + if let configured = environment["TABLEPRO_RUSTLEDGER_BINARY"], !configured.isEmpty { + if FileManager.default.isExecutableFile(atPath: configured) { + return configured + } + throw BeancountDriverError.rustledgerUnavailable( + String( + format: String(localized: "TABLEPRO_RUSTLEDGER_BINARY points to a missing or non-executable rledger at %@"), + configured + ) + ) } - let bundleCandidates = [ - Bundle(for: BeancountPluginDriver.self).url(forResource: "rledger", withExtension: nil)?.path, - Bundle.main.builtInPlugInsURL? - .appendingPathComponent("BeancountDriver.tableplugin") - .appendingPathComponent("Contents/Resources/rledger") - .path - ].compactMap { $0 } - if let path = bundleCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { - return path + let pathEntries = (environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin"] + for directory in pathEntries + fallbackDirectories { + let candidate = URL(fileURLWithPath: directory).appendingPathComponent("rledger").path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } } - throw BeancountDriverError.rustledgerUnavailable + throw BeancountDriverError.rustledgerUnavailable( + String(localized: "Beancount support requires rledger. Install rustledger so rledger is on PATH or in a standard Homebrew location, or set TABLEPRO_RUSTLEDGER_BINARY to the executable path.") + ) } private static func parseRledgerJSON(_ data: Data) throws -> (columns: [String]?, rows: [[String: Any]]) { @@ -740,6 +817,20 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (stringValue(dictionary["number"]), stringValue(dictionary["currency"])) } + private static func inventoryPositions(_ value: Any?) -> [(number: String, currency: String)] { + guard let dictionary = value as? [String: Any], + let positions = dictionary["positions"] as? [[String: Any]] else { + return [] + } + return positions.compactMap { position in + guard let number = stringValue(position["number"]), + let currency = stringValue(position["currency"]) else { + return nil + } + return (number: number, currency: currency) + } + } + private static func currencyList(_ value: Any?) -> String? { guard let array = value as? [Any] else { return stringValue(value) } let items = array.compactMap { $0 as? String } diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index c48cad80a..21019f559 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,6 +3,10 @@ TableProPluginKitVersion - 16 + 18 + TableProProvidesDatabaseTypeIds + + Beancount + diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index e86c8ce3e..bc6ce46b8 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1637,7 +1637,6 @@ 5ABC14740000000000000A /* Sources */, 5ABC147400000000000008 /* Frameworks */, 5ABC14740000000000000B /* Resources */, - 5ABC147400000000000012 /* Bundle RustLedger */, ); buildRules = ( ); @@ -2377,27 +2376,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 5ABC147400000000000012 /* Bundle RustLedger */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$(SRCROOT)/scripts/download-rustledger.sh", - ); - name = "Bundle RustLedger"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/rledger", - "$(TARGET_TEMP_DIR)/rustledger-cache", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "TABLEPRO_RUSTLEDGER_CACHE=\"$TARGET_TEMP_DIR/rustledger-cache\" \"$SRCROOT/scripts/download-rustledger.sh\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/rledger\"\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift index f945fe066..069cadbec 100644 --- a/TableProTests/Plugins/BeancountPluginDriverTests.swift +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -15,39 +15,21 @@ private enum RustledgerLocator { if let env = ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"] { candidates.append(env) } - if let bundled = Bundle.main.builtInPlugInsURL? - .appendingPathComponent("BeancountDriver.tableplugin/Contents/Resources/rledger").path { - candidates.append(bundled) - } - candidates.append(cachedPath()) + + let pathCandidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .map { URL(fileURLWithPath: String($0)).appendingPathComponent("rledger").path } + candidates.append(contentsOf: pathCandidates) + candidates.append(contentsOf: ["/opt/homebrew/bin/rledger", "/usr/local/bin/rledger"]) return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } - - static func cachedPath(file: StaticString = #filePath) -> String { - #if arch(arm64) - let triple = "aarch64-apple-darwin" - #else - let triple = "x86_64-apple-darwin" - #endif - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let marker = directory.appendingPathComponent("TablePro.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return directory - .appendingPathComponent("Libs/rustledger/v0.15.0/\(triple)/rledger") - .path - } - directory = directory.deletingLastPathComponent() - } - return "" - } } @Suite( "Beancount plugin driver", .serialized, - .enabled(if: RustledgerLocator.path != nil, "rustledger helper unavailable") + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") ) struct BeancountPluginDriverTests { @Test("reloads the SQL projection when an included ledger file changes") @@ -171,6 +153,75 @@ struct BeancountPluginDriverTests { } } + @Test("projects computed balances from postings") + func projectsComputedBalancesFromPostings() async throws { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Cash USD + 2024-01-01 open Expenses:Food USD + 2024-01-01 open Income:Salary USD + + 2024-01-05 * "Employer" "Pay" + Assets:Cash 10.00 USD + Income:Salary + + 2024-01-06 * "Cafe" "Coffee" + Expenses:Food 3.00 USD + Assets:Cash + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } + + let result = try await driver.execute(query: """ + SELECT account, amount, commodity + FROM balances ORDER BY account, commodity + """) + #expect(result.rows.map { $0.map(\.asText) } == [ + ["Assets:Cash", "7.00", "USD"], + ["Expenses:Food", "3.00", "USD"], + ["Income:Salary", "-10.00", "USD"] + ]) + } + } + + @Test("projects balance assertions separately") + func projectsBalanceAssertionsSeparately() async throws { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Cash USD + 2024-01-01 open Income:Salary USD + + 2024-01-05 * "Employer" "Pay" + Assets:Cash 10.00 USD + Income:Salary + + 2024-01-31 balance Assets:Cash 10.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } + + let result = try await driver.execute(query: """ + SELECT date, account, amount, commodity + FROM balance_assertions + """) + #expect(result.rows.map { $0.map(\.asText) } == [ + ["2024-01-31", "Assets:Cash", "10.00", "USD"] + ]) + } + } + @Test("links postings to a single transaction row") func linksPostingsToTransaction() async throws { try await Self.withRustledger { @@ -202,8 +253,8 @@ struct BeancountPluginDriverTests { } } - @Test("executes BQL queries through the rustledger helper") - func executesBQLQueriesThroughRustledgerHelper() async throws { + @Test("executes BQL queries through the rledger executable") + func executesBQLQueriesThroughRustledgerExecutable() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -239,12 +290,48 @@ struct BeancountPluginDriverTests { } } + @Test("fails clearly when TABLEPRO_RUSTLEDGER_BINARY points at a missing executable") + func failsClearlyWhenConfiguredRustledgerIsMissing() async throws { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try "2024-01-01 open Assets:Bank:Checking USD\n" + .write(to: ledger, atomically: true, encoding: .utf8) + + let missing = directory.appendingPathComponent("missing-rledger").path + try await Self.withRustledgerEnvironment(missing) { + let driver = BeancountPluginDriver(config: Self.config(ledger)) + do { + try await driver.connect() + Issue.record("Expected missing rledger configuration to fail") + } catch let error as BeancountDriverError { + let message = error.errorDescription ?? "" + #expect(message.contains("TABLEPRO_RUSTLEDGER_BINARY")) + #expect(message.contains(missing)) + } catch { + Issue.record("Expected BeancountDriverError, got \(error)") + } + } + } + // MARK: - Helpers private static func withRustledger(_ body: () async throws -> Void) async throws { let rledger = try #require(RustledgerLocator.path) - setenv("TABLEPRO_RUSTLEDGER_BINARY", rledger, 1) - defer { unsetenv("TABLEPRO_RUSTLEDGER_BINARY") } + try await withRustledgerEnvironment(rledger, body) + } + + private static func withRustledgerEnvironment(_ path: String, _ body: () async throws -> Void) async throws { + let previous = ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"] + setenv("TABLEPRO_RUSTLEDGER_BINARY", path, 1) + defer { + if let previous { + setenv("TABLEPRO_RUSTLEDGER_BINARY", previous, 1) + } else { + unsetenv("TABLEPRO_RUSTLEDGER_BINARY") + } + } try await body() } diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 5db2308b9..3578dfcfd 100644 --- a/docs/databases/beancount.mdx +++ b/docs/databases/beancount.mdx @@ -5,9 +5,23 @@ description: Open Beancount ledgers with TablePro # Beancount -TablePro opens `.beancount` ledgers as read-only, file-based connections. If the Beancount plugin is not installed yet, TablePro prompts you to download it before opening the ledger. The driver projects transactions, postings, accounts, prices, balances, and source files into SQL tables for browsing and exports. +TablePro opens `.beancount` ledgers as read-only, file-based connections. If the Beancount plugin is not installed yet, TablePro prompts you to download it before opening the ledger. The driver projects transactions, postings, accounts, prices, computed balances, balance assertions, and source files into SQL tables for browsing and exports. -The plugin also supports BQL queries through its packaged `rustledger` helper. +The plugin also supports BQL queries through `rledger`. Install rustledger separately so the `rledger` executable is available on your `PATH`, or launch TablePro with `TABLEPRO_RUSTLEDGER_BINARY` set to the executable path. + +## rledger requirement + +Beancount support uses the external `rledger` executable to parse ledger data and run BQL. TablePro does not bundle `rledger`. + +```bash +rledger --version +``` + +If TablePro cannot find `rledger`, set the executable path before launching the app: + +```bash +launchctl setenv TABLEPRO_RUSTLEDGER_BINARY /opt/homebrew/bin/rledger +``` ## Connecting to a Beancount ledger @@ -42,10 +56,11 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. | `postings` | Posting account, amount, commodity, and resolved cost basis (`cost_number`, `cost_currency`) | | `accounts` | Opened accounts and declared currencies | | `prices` | Price directives | -| `balances` | Balance directives | +| `balances` | Computed account balances by commodity | +| `balance_assertions` | Beancount balance directives | | `source_files` | Parsed ledger and include files | -Amounts are read straight from `rustledger`, so thousands separators, arithmetic, cost (`{}`), and price (`@`/`@@`) annotations are resolved to their booked values. +Amounts are read straight from `rledger`, so thousands separators, arithmetic, cost (`{}`), and price (`@`/`@@`) annotations are resolved to their booked values. ## Includes @@ -53,7 +68,7 @@ The parser follows Beancount `include` directives. Literal includes and glob pat ## BQL -Prefix a query with `BQL:` to run it through `rustledger`: +Prefix a query with `BQL:` to run it through your configured `rledger` executable: ```sql BQL: SELECT account FROM accounts ORDER BY account diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh index a3088e659..3a7ebcc0c 100755 --- a/scripts/build-plugin.sh +++ b/scripts/build-plugin.sh @@ -125,7 +125,7 @@ build_plugin() { done fi - # Sign executable helper resources such as Beancount's bundled rledger. + # Sign executable helper resources when a plugin ships them. if [ -d "$plugin_bundle/Contents/Resources" ]; then find "$plugin_bundle/Contents/Resources" -type f -perm -111 | sort | while read -r nested; do if file "$nested" | grep -q "Mach-O"; then diff --git a/scripts/download-rustledger.sh b/scripts/download-rustledger.sh deleted file mode 100755 index 2f60340d0..000000000 --- a/scripts/download-rustledger.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Bundle the rustledger CLI helper used by the Beancount driver for BQL queries. -# Usage: scripts/download-rustledger.sh [output-rledger-path] - -VERSION="v0.15.0" -REPO="rustledger/rustledger" -PROJECT_ROOT="${SRCROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" -CACHE_ROOT="${TABLEPRO_RUSTLEDGER_CACHE:-$PROJECT_ROOT/Libs/rustledger}" -OUTPUT="${1:-${TARGET_BUILD_DIR:?}/${UNLOCALIZED_RESOURCES_FOLDER_PATH:?}/rledger}" - -triple_for_arch() { - case "$1" in - arm64|aarch64) echo "aarch64-apple-darwin" ;; - x86_64) echo "x86_64-apple-darwin" ;; - *) return 1 ;; - esac -} - -sha_for_triple() { - case "$1" in - aarch64-apple-darwin) echo "b8f1190898b1e7ed1585ce3833a9a1814b30b5703e75eed7c276dafac00bc00a" ;; - x86_64-apple-darwin) echo "b264a51d792d00d138725d8d7eaa6e5354e7ad54e8e93e9efde535d830e217ff" ;; - *) return 1 ;; - esac -} - -host_triple() { - triple_for_arch "$(uname -m)" -} - -copy_helper() { - local source_path="$1" - local message="$2" - - if [[ ! -x "$source_path" ]]; then - return 1 - fi - - mkdir -p "$(dirname "$OUTPUT")" - cp -f "$source_path" "$OUTPUT" - chmod 755 "$OUTPUT" - echo "$message: $OUTPUT" - return 0 -} - -download_release() { - local triple="$1" - local archive="rustledger-${VERSION}-${triple}.tar.gz" - local sha256 tmpdir archive_path actual_sha extracted_helper - - sha256="$(sha_for_triple "$triple")" - tmpdir="$(mktemp -d)" - trap "rm -rf '$tmpdir'" RETURN - archive_path="$tmpdir/$archive" - - if command -v gh >/dev/null 2>&1; then - gh release download "$VERSION" \ - --repo "$REPO" \ - --pattern "$archive" \ - --dir "$tmpdir" \ - --clobber - else - curl -fSL -o "$archive_path" "https://github.com/$REPO/releases/download/$VERSION/$archive" - fi - - actual_sha="$(shasum -a 256 "$archive_path" | awk '{print $1}')" - if [[ "$actual_sha" != "$sha256" ]]; then - echo "Checksum mismatch for $archive" >&2 - echo "Expected: $sha256" >&2 - echo "Actual: $actual_sha" >&2 - return 1 - fi - - mkdir -p "$tmpdir/extract" "$CACHE_ROOT/$VERSION/$triple" - tar -xzf "$archive_path" -C "$tmpdir/extract" - extracted_helper="$(find "$tmpdir/extract" -type f -name rledger | head -n 1)" - if [[ -z "$extracted_helper" ]]; then - echo "Could not find rledger in $archive" >&2 - return 1 - fi - - cp -f "$extracted_helper" "$CACHE_ROOT/$VERSION/$triple/rledger" - chmod 755 "$CACHE_ROOT/$VERSION/$triple/rledger" -} - -ensure_cached_helper() { - local triple="$1" - local helper="$CACHE_ROOT/$VERSION/$triple/rledger" - - if [[ -x "$helper" ]]; then - echo "$helper" - return 0 - fi - - download_release "$triple" >&2 - echo "$helper" -} - -requested_triples=() -if [[ -n "${ARCHS:-}" ]]; then - for arch in $ARCHS; do - triple="$(triple_for_arch "$arch" 2>/dev/null || true)" - if [[ -n "${triple:-}" && " ${requested_triples[*]} " != *" $triple "* ]]; then - requested_triples+=("$triple") - fi - done -fi -if [[ "${#requested_triples[@]}" -eq 0 ]]; then - requested_triples+=("$(host_triple)") -fi - -if [[ -n "${TABLEPRO_RUSTLEDGER_BINARY:-}" ]]; then - if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then - echo "TABLEPRO_RUSTLEDGER_BINARY is only allowed for local builds; release builds must use the pinned rustledger download." >&2 - exit 1 - fi - copy_helper "$TABLEPRO_RUSTLEDGER_BINARY" "Bundled rustledger helper from TABLEPRO_RUSTLEDGER_BINARY" - exit 0 -fi - -helpers=() -for triple in "${requested_triples[@]}"; do - if helper="$(ensure_cached_helper "$triple")"; then - helpers+=("$helper") - fi -done - -if [[ "${#helpers[@]}" -eq 1 ]]; then - copy_helper "${helpers[0]}" "Bundled rustledger helper" - exit 0 -fi - -if [[ "${#helpers[@]}" -gt 1 && -x "$(command -v lipo)" ]]; then - mkdir -p "$(dirname "$OUTPUT")" - lipo -create "${helpers[@]}" -output "$OUTPUT" - chmod 755 "$OUTPUT" - echo "Bundled universal rustledger helper: $OUTPUT" - exit 0 -fi - -echo "Unable to bundle rustledger from the pinned release. Set TABLEPRO_RUSTLEDGER_BINARY for local builds or retry the release download." >&2 -exit 1 From 1ed881114609d71feb85948cb470a89ed0614e5c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:09:18 -0700 Subject: [PATCH 8/8] Add Python Beancount fallback backend --- .github/workflows/build-plugin.yml | 2 +- CHANGELOG.md | 2 +- .../BeancountPluginDriver.swift | 246 +++++++++++++++++- .../Plugins/BeancountPluginDriverTests.swift | 154 +++++++++-- docs/databases/beancount.mdx | 30 ++- 5 files changed, 400 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index e2b33d01d..e52bc6d80 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -160,7 +160,7 @@ jobs: CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; beancount) TARGET="BeancountDriver"; BUNDLE_ID="com.TablePro.BeancountDriver" - DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver using user-provided rledger" + DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver using user-provided rledger or Python Beancount" DB_TYPE_IDS='["Beancount"]'; ICON="beancount-icon"; BUNDLE_NAME="BeancountDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/beancount" ;; cassandra) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ff6494f..c951f64c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables with amounts booked by user-provided `rledger`, and BQL runs with a `BQL:` prefix. (#1474) +- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474) ### Fixed diff --git a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift index 3f63a8a28..142021819 100644 --- a/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -13,7 +13,7 @@ enum BeancountDriverError: LocalizedError { case connectionFailed(String) case queryFailed(String) case readOnly - case rustledgerUnavailable(String) + case beancountBackendUnavailable(String) var errorDescription: String? { switch self { @@ -25,7 +25,7 @@ enum BeancountDriverError: LocalizedError { return message case .readOnly: return String(localized: "Beancount ledgers are exposed as a read-only SQL database") - case .rustledgerUnavailable(let message): + case .beancountBackendUnavailable(let message): return message } } @@ -47,6 +47,11 @@ private struct BeancountProjection { let signatures: [String: BeancountSourceSignature] } +private enum BeancountBackend { + case rledger + case python(String) +} + final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private let lock = NSLock() @@ -65,6 +70,99 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static let balanceAssertionsQuery = "SELECT date, account, amount FROM #balances ORDER BY date, account" private static let rledgerCapabilityLock = NSLock() private static var rledgerNoCacheSupport: [String: Bool] = [:] + private static let pythonProjectionScript = """ +import json +import sys +from collections import defaultdict +from decimal import Decimal + +from beancount import loader + +def date_value(value): + return value.isoformat() if value is not None else None + +def decimal_value(value): + return str(value) if value is not None else None + +def amount_value(amount): + if amount is None: + return None + return { + "number": decimal_value(getattr(amount, "number", None)), + "currency": getattr(amount, "currency", None), + } + +entries, errors, options_map = loader.load_file(sys.argv[1]) +if errors: + for error in errors: + print(str(error), file=sys.stderr) + sys.exit(1) + +rows = { + "transactions_and_postings": [], + "accounts": [], + "prices": [], + "balances": [], + "balance_assertions": [], +} +balances = defaultdict(Decimal) +transaction_id = 0 + +for entry in entries: + entry_type = type(entry).__name__ + if entry_type == "Transaction": + transaction_id += 1 + for posting in entry.postings: + units = getattr(posting, "units", None) + cost = getattr(posting, "cost", None) + if units is not None and getattr(units, "number", None) is not None and getattr(units, "currency", None): + balances[(posting.account, units.currency)] += units.number + rows["transactions_and_postings"].append({ + "id": transaction_id, + "date": date_value(entry.date), + "flag": str(entry.flag), + "payee": entry.payee, + "narration": entry.narration, + "account": posting.account, + "number": decimal_value(getattr(units, "number", None)) if units is not None else None, + "currency": getattr(units, "currency", None) if units is not None else None, + "cost_number": decimal_value(getattr(cost, "number", None)) if cost is not None else None, + "cost_currency": getattr(cost, "currency", None) if cost is not None else None, + }) + elif entry_type == "Open": + rows["accounts"].append({ + "account": entry.account, + "open": date_value(entry.date), + "currencies": list(entry.currencies or []), + }) + elif entry_type == "Price": + rows["prices"].append({ + "date": date_value(entry.date), + "currency": entry.currency, + "amount": amount_value(entry.amount), + }) + elif entry_type == "Balance": + rows["balance_assertions"].append({ + "date": date_value(entry.date), + "account": entry.account, + "amount": amount_value(entry.amount), + }) + +for (account, currency), number in sorted(balances.items()): + if number == 0: + continue + rows["balances"].append({ + "account": account, + "balance": { + "positions": [{ + "number": decimal_value(number), + "currency": currency, + }] + }, + }) + +print(json.dumps(rows, separators=(",", ":"))) +""" var currentSchema: String? { nil } var serverVersion: String? { "Beancount" } @@ -421,11 +519,21 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { try createSchema(handle) - try loadTransactionsAndPostings(query(ledgerPath: ledgerPath, bql: postingsQuery), into: handle) - try loadAccounts(query(ledgerPath: ledgerPath, bql: accountsQuery), into: handle) - try loadPrices(query(ledgerPath: ledgerPath, bql: pricesQuery), into: handle) - try loadBalances(query(ledgerPath: ledgerPath, bql: balancesQuery), into: handle) - try loadBalanceAssertions(query(ledgerPath: ledgerPath, bql: balanceAssertionsQuery), into: handle) + switch try resolveProjectionBackend() { + case .rledger: + try loadTransactionsAndPostings(query(ledgerPath: ledgerPath, bql: postingsQuery), into: handle) + try loadAccounts(query(ledgerPath: ledgerPath, bql: accountsQuery), into: handle) + try loadPrices(query(ledgerPath: ledgerPath, bql: pricesQuery), into: handle) + try loadBalances(query(ledgerPath: ledgerPath, bql: balancesQuery), into: handle) + try loadBalanceAssertions(query(ledgerPath: ledgerPath, bql: balanceAssertionsQuery), into: handle) + case .python(let executablePath): + let rows = try pythonProjectionRows(ledgerPath: ledgerPath, executablePath: executablePath) + try loadTransactionsAndPostings(rows["transactions_and_postings"] ?? [], into: handle) + try loadAccounts(rows["accounts"] ?? [], into: handle) + try loadPrices(rows["prices"] ?? [], into: handle) + try loadBalances(rows["balances"] ?? [], into: handle) + try loadBalanceAssertions(rows["balance_assertions"] ?? [], into: handle) + } try loadSourceFiles(graph.sourceFiles, into: handle) try exec(handle, "PRAGMA query_only = ON") } catch { @@ -602,6 +710,27 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - rustledger Helpers + private static func resolveProjectionBackend() throws -> BeancountBackend { + let preference = ProcessInfo.processInfo.environment["TABLEPRO_BEANCOUNT_BACKEND"]?.lowercased() + switch preference { + case "rledger", "rustledger": + _ = try rustledgerExecutablePath() + return .rledger + case "python", "beancount": + return .python(try pythonBeancountExecutablePath()) + default: + if try optionalRustledgerExecutablePath() != nil { + return .rledger + } + if let pythonPath = try optionalPythonBeancountExecutablePath() { + return .python(pythonPath) + } + throw BeancountDriverError.beancountBackendUnavailable( + String(localized: "Beancount support requires either rledger or Python Beancount. Install rledger, or install Python Beancount and make python3 available, or set TABLEPRO_RUSTLEDGER_BINARY or TABLEPRO_BEANCOUNT_PYTHON.") + ) + } + } + private static func rledgerQueryArguments(ledgerPath: String, query: String) throws -> [String] { let rustledgerPath = try rustledgerExecutablePath() var arguments = ["query", "-f", "json", "--no-errors"] @@ -614,9 +743,20 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private static func runRledger(arguments: [String]) throws -> Data { let rustledgerPath = try rustledgerExecutablePath() + return try runProcess( + executablePath: rustledgerPath, + arguments: arguments, + failureMessage: String(localized: "rustledger command failed") + ) + } + private static func runProcess( + executablePath: String, + arguments: [String], + failureMessage: String + ) throws -> Data { let process = Process() - process.executableURL = URL(fileURLWithPath: rustledgerPath) + process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments let stdout = Pipe() @@ -648,7 +788,7 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let message, !message.isEmpty { throw BeancountDriverError.queryFailed(message) } - throw BeancountDriverError.queryFailed(String(localized: "rustledger command failed")) + throw BeancountDriverError.queryFailed(failureMessage) } return outputCollector.data @@ -690,12 +830,21 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private static func rustledgerExecutablePath() throws -> String { + if let path = try optionalRustledgerExecutablePath() { + return path + } + throw BeancountDriverError.beancountBackendUnavailable( + String(localized: "BQL queries require rledger. Install rustledger so rledger is on PATH or in a standard Homebrew location, or set TABLEPRO_RUSTLEDGER_BINARY to the executable path.") + ) + } + + private static func optionalRustledgerExecutablePath() throws -> String? { let environment = ProcessInfo.processInfo.environment if let configured = environment["TABLEPRO_RUSTLEDGER_BINARY"], !configured.isEmpty { if FileManager.default.isExecutableFile(atPath: configured) { return configured } - throw BeancountDriverError.rustledgerUnavailable( + throw BeancountDriverError.beancountBackendUnavailable( String( format: String(localized: "TABLEPRO_RUSTLEDGER_BINARY points to a missing or non-executable rledger at %@"), configured @@ -714,9 +863,82 @@ final class BeancountPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - throw BeancountDriverError.rustledgerUnavailable( - String(localized: "Beancount support requires rledger. Install rustledger so rledger is on PATH or in a standard Homebrew location, or set TABLEPRO_RUSTLEDGER_BINARY to the executable path.") + return nil + } + + // MARK: - Python Beancount Helpers + + private static func pythonProjectionRows( + ledgerPath: String, + executablePath: String + ) throws -> [String: [[String: Any]]] { + let output = try runProcess( + executablePath: executablePath, + arguments: ["-c", pythonProjectionScript, ledgerPath], + failureMessage: String(localized: "Python Beancount projection failed") ) + let object = try JSONSerialization.jsonObject(with: output) + guard let dictionary = object as? [String: Any] else { + throw BeancountDriverError.queryFailed(String(localized: "Invalid Python Beancount JSON output")) + } + var rows: [String: [[String: Any]]] = [:] + for (key, value) in dictionary { + rows[key] = value as? [[String: Any]] + } + return rows + } + + private static func pythonBeancountExecutablePath() throws -> String { + if let path = try optionalPythonBeancountExecutablePath() { + return path + } + throw BeancountDriverError.beancountBackendUnavailable( + String(localized: "Python Beancount backend requires python3 with the beancount package installed. Set TABLEPRO_BEANCOUNT_PYTHON to the Python executable if needed.") + ) + } + + private static func optionalPythonBeancountExecutablePath() throws -> String? { + let environment = ProcessInfo.processInfo.environment + if let configured = environment["TABLEPRO_BEANCOUNT_PYTHON"], !configured.isEmpty { + if FileManager.default.isExecutableFile(atPath: configured), pythonSupportsBeancount(configured) { + return configured + } + throw BeancountDriverError.beancountBackendUnavailable( + String( + format: String(localized: "TABLEPRO_BEANCOUNT_PYTHON points to a Python executable that cannot import beancount at %@"), + configured + ) + ) + } + + let pathEntries = (environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + let fallbackCandidates = [ + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3" + ] + let candidates = pathEntries.map { + URL(fileURLWithPath: $0).appendingPathComponent("python3").path + } + fallbackCandidates + + return candidates.first { + FileManager.default.isExecutableFile(atPath: $0) && pythonSupportsBeancount($0) + } + } + + private static func pythonSupportsBeancount(_ executablePath: String) -> Bool { + do { + _ = try runProcess( + executablePath: executablePath, + arguments: ["-c", "import beancount"], + failureMessage: String(localized: "Python cannot import beancount") + ) + return true + } catch { + return false + } } private static func parseRledgerJSON(_ data: Data) throws -> (columns: [String]?, rows: [[String: Any]]) { diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift index 069cadbec..208b89c38 100644 --- a/TableProTests/Plugins/BeancountPluginDriverTests.swift +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -26,13 +26,49 @@ private enum RustledgerLocator { } } +private enum PythonBeancountLocator { + static let path: String? = resolve() + + static func resolve() -> String? { + var candidates: [String] = [] + if let env = ProcessInfo.processInfo.environment["TABLEPRO_BEANCOUNT_PYTHON"] { + candidates.append(env) + } + + let pathCandidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .map { URL(fileURLWithPath: String($0)).appendingPathComponent("python3").path } + candidates.append(contentsOf: pathCandidates) + + candidates.append(contentsOf: ["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3"]) + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) && canImportBeancount($0) } + } + + private static func canImportBeancount(_ executablePath: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = ["-c", "import beancount"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} + @Suite( "Beancount plugin driver", - .serialized, - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + .serialized ) struct BeancountPluginDriverTests { - @Test("reloads the SQL projection when an included ledger file changes") + @Test( + "reloads the SQL projection when an included ledger file changes", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func reloadsWhenIncludedFileChanges() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -62,7 +98,10 @@ struct BeancountPluginDriverTests { } } - @Test("reloads the SQL projection when a glob include matches a new file") + @Test( + "reloads the SQL projection when a glob include matches a new file", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func reloadsWhenGlobIncludeMatchesNewFile() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -91,7 +130,10 @@ struct BeancountPluginDriverTests { } } - @Test("rejects write queries") + @Test( + "rejects write queries", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func rejectsWriteQueries() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -110,7 +152,10 @@ struct BeancountPluginDriverTests { } } - @Test("projects authoritative posting amounts and resolved cost basis") + @Test( + "projects authoritative posting amounts and resolved cost basis", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func projectsAuthoritativeAmounts() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -153,7 +198,10 @@ struct BeancountPluginDriverTests { } } - @Test("projects computed balances from postings") + @Test( + "projects computed balances from postings", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func projectsComputedBalancesFromPostings() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -190,7 +238,10 @@ struct BeancountPluginDriverTests { } } - @Test("projects balance assertions separately") + @Test( + "projects balance assertions separately", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func projectsBalanceAssertionsSeparately() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -222,7 +273,10 @@ struct BeancountPluginDriverTests { } } - @Test("links postings to a single transaction row") + @Test( + "links postings to a single transaction row", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func linksPostingsToTransaction() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -253,7 +307,64 @@ struct BeancountPluginDriverTests { } } - @Test("executes BQL queries through the rledger executable") + @Test( + "opens a ledger through the Python Beancount backend", + .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") + ) + func opensLedgerThroughPythonBeancountBackend() async throws { + let python = try #require(PythonBeancountLocator.path) + try await Self.withEnvironment([ + "TABLEPRO_BEANCOUNT_BACKEND": "python", + "TABLEPRO_BEANCOUNT_PYTHON": python + ]) { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let ledger = directory.appendingPathComponent("main.beancount") + try """ + 2024-01-01 open Assets:Cash USD + 2024-01-01 open Expenses:Food USD + 2024-01-01 open Income:Salary USD + + 2024-01-05 * "Employer" "Pay" + Assets:Cash 10.00 USD + Income:Salary + + 2024-01-06 * "Cafe" "Coffee" + Expenses:Food 3.00 USD + Assets:Cash + + 2024-01-31 balance Assets:Cash 7.00 USD + """.write(to: ledger, atomically: true, encoding: .utf8) + + let driver = BeancountPluginDriver(config: Self.config(ledger)) + try await driver.connect() + defer { driver.disconnect() } + + let balances = try await driver.execute(query: """ + SELECT account, amount, commodity + FROM balances ORDER BY account, commodity + """) + #expect(balances.rows.map { $0.map(\.asText) } == [ + ["Assets:Cash", "7.00", "USD"], + ["Expenses:Food", "3.00", "USD"], + ["Income:Salary", "-10.00", "USD"] + ]) + + let assertions = try await driver.execute(query: """ + SELECT date, account, amount, commodity + FROM balance_assertions + """) + #expect(assertions.rows.map { $0.map(\.asText) } == [ + ["2024-01-31", "Assets:Cash", "7.00", "USD"] + ]) + } + } + + @Test( + "executes BQL queries through the rledger executable", + .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") + ) func executesBQLQueriesThroughRustledgerExecutable() async throws { try await Self.withRustledger { let directory = try Self.makeTempDirectory() @@ -323,13 +434,24 @@ struct BeancountPluginDriverTests { } private static func withRustledgerEnvironment(_ path: String, _ body: () async throws -> Void) async throws { - let previous = ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"] - setenv("TABLEPRO_RUSTLEDGER_BINARY", path, 1) + try await withEnvironment(["TABLEPRO_RUSTLEDGER_BINARY": path], body) + } + + private static func withEnvironment( + _ values: [String: String], + _ body: () async throws -> Void + ) async throws { + let previous = values.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } + for (name, value) in values { + setenv(name, value, 1) + } defer { - if let previous { - setenv("TABLEPRO_RUSTLEDGER_BINARY", previous, 1) - } else { - unsetenv("TABLEPRO_RUSTLEDGER_BINARY") + for (name, previousValue) in previous { + if let previousValue { + setenv(name, previousValue, 1) + } else { + unsetenv(name) + } } } try await body() diff --git a/docs/databases/beancount.mdx b/docs/databases/beancount.mdx index 3578dfcfd..5949f43a7 100644 --- a/docs/databases/beancount.mdx +++ b/docs/databases/beancount.mdx @@ -7,20 +7,42 @@ description: Open Beancount ledgers with TablePro TablePro opens `.beancount` ledgers as read-only, file-based connections. If the Beancount plugin is not installed yet, TablePro prompts you to download it before opening the ledger. The driver projects transactions, postings, accounts, prices, computed balances, balance assertions, and source files into SQL tables for browsing and exports. -The plugin also supports BQL queries through `rledger`. Install rustledger separately so the `rledger` executable is available on your `PATH`, or launch TablePro with `TABLEPRO_RUSTLEDGER_BINARY` set to the executable path. +The plugin uses either `rledger` or Python Beancount to parse ledgers. Install one of those backends separately. When both are available, TablePro uses `rledger`; Python Beancount is used as a fallback for browsing projected SQL tables. -## rledger requirement +The plugin also supports BQL queries through `rledger`. BQL requires the `rledger` executable even when the ledger was opened through Python Beancount. -Beancount support uses the external `rledger` executable to parse ledger data and run BQL. TablePro does not bundle `rledger`. +## Backend requirements + +TablePro does not bundle a Beancount parser. It discovers backends in this order: + +1. `rledger` from `TABLEPRO_RUSTLEDGER_BINARY`, `PATH`, or standard Homebrew locations. +2. Python Beancount from `TABLEPRO_BEANCOUNT_PYTHON`, `PATH`, or standard Python locations. + +Other Beancount-compatible parsers are not discovered unless they expose one of these supported interfaces. + +To verify `rledger`: ```bash rledger --version ``` -If TablePro cannot find `rledger`, set the executable path before launching the app: +To verify Python Beancount: + +```bash +python3 -c "import beancount" +``` + +If TablePro cannot find a supported backend, set the executable path before launching the app: ```bash launchctl setenv TABLEPRO_RUSTLEDGER_BINARY /opt/homebrew/bin/rledger +launchctl setenv TABLEPRO_BEANCOUNT_PYTHON /opt/homebrew/bin/python3 +``` + +To force a backend: + +```bash +launchctl setenv TABLEPRO_BEANCOUNT_BACKEND python ``` ## Connecting to a Beancount ledger