diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 95206552d..e01d0975b 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/.gitignore b/.gitignore index 00fa0894c..488c3b6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,7 @@ Libs/*.a Libs/.downloaded Libs/dylibs/ Libs/ios/ +Libs/rustledger/ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) @@ -158,3 +159,9 @@ fix-1322-plugin-abi-and-registry-overhaul.diff .docs/ Local.xcconfig /plans/reports + +# Local planning and assistant history +.specstory/ +.planning/ +.plans/ +planning/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a03713b8a..a49f7c4b9 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 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 - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 2b0843f95..37c0e0392 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -27,6 +27,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let bigquery = DatabaseType(rawValue: "BigQuery") public static let snowflake = DatabaseType(rawValue: "Snowflake") public static let libsql = DatabaseType(rawValue: "libSQL") + public static let beancount = DatabaseType(rawValue: "Beancount") public static let cockroachdb = DatabaseType(rawValue: "CockroachDB") public static let scylladb = DatabaseType(rawValue: "ScyllaDB") public static let turso = DatabaseType(rawValue: "Turso") @@ -34,7 +35,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, - .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql + .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount ] /// Icon name for this database type — asset catalog name (e.g. "mysql-icon") or SF Symbol fallback @@ -58,6 +59,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .bigquery: return "bigquery-icon" case .snowflake: return "snowflake-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 41b749052..e4f434703 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -16,6 +16,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1") #expect(DatabaseType.bigquery.rawValue == "BigQuery") #expect(DatabaseType.snowflake.rawValue == "Snowflake") + #expect(DatabaseType.beancount.rawValue == "Beancount") } @Test("pluginTypeId maps multi-type databases") @@ -57,6 +58,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) #expect(DatabaseType.allKnownTypes.contains(.libsql)) + #expect(DatabaseType.allKnownTypes.contains(.beancount)) } @Test("Hashable conformance") 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/BeancountPlugin.swift b/Plugins/BeancountDriverPlugin/BeancountPlugin.swift new file mode 100644 index 000000000..dab793c19 --- /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"] + ] + static let immutableColumns: [String] = [ + "id", "transaction_id", "date", "flag", "payee", "narration", + "account", "amount", "commodity", "cost_number", "cost_currency", + "currency", "currencies", "name", "open_date", "path" + ] + + 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"], + 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..fc927708f --- /dev/null +++ b/Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift @@ -0,0 +1,864 @@ +// +// BeancountPluginDriver.swift +// BeancountDriverPlugin +// + +import Dispatch +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 String(localized: "Not connected to Beancount ledger") + case .connectionFailed(let message): + return String(format: String(localized: "Failed to open Beancount ledger: %@"), message) + case .queryFailed(let message): + 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") + } + } +} + +extension BeancountDriverError: PluginDriverError { + var pluginErrorMessage: String { errorDescription ?? "Beancount driver error" } +} + +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 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 } + 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( + String(format: String(localized: "File does not exist at %@"), path) + ) + } + + let projection = try Self.buildProjection(ledgerURL: fileURL) + + lock.withLock { + db = projection.handle + ledgerURL = fileURL + watchedURLs = projection.watchedURLs + sourceSignatures = projection.signatures + } + } + + func disconnect() { + lock.withLock { + if db != nil { + sqlite3_close(db) + db = nil + } + ledgerURL = nil + watchedURLs = [] + 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) + } + return try executeSQLite(query: query, parameters: []) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + if Self.extractBQLQuery(from: query) != nil { + throw BeancountDriverError.queryFailed( + String(localized: "BQL queries do not support SQL parameters") + ) + } + return try executeSQLite(query: query, parameters: parameters) + } + + 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 } + return count + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + 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] { + 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( + String(format: String(localized: "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 + 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) + } + } + } + } + + // 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 start = Date() + let output = try Self.runRledger(arguments: ["query", "-f", "json", "--no-errors", ledgerPath, query]) + return try Self.decodeRustledgerQueryOutput(output, executionTime: Date().timeIntervalSince(start)) + } + + // 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 { + 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.. 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.. 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 + + var handle: OpaquePointer? + guard sqlite3_open(":memory:", &handle) == SQLITE_OK, let handle else { + throw BeancountDriverError.connectionFailed( + String(localized: "Could not initialize SQL projection") + ) + } + + 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 loadSourceFiles(graph.sourceFiles, into: handle) + try exec(handle, "PRAGMA query_only = ON") + } catch { + sqlite3_close(handle) + throw error + } + + 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"]) + ]) + } + } + + 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"]) + ]) + } + } + + 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 + } + 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]) + } + } + + 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]) + } + } + + 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]) + } + } + + // 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() + } + readers.enter() + DispatchQueue.global(qos: .userInitiated).async { + errorCollector.set(stderr.fileHandleForReading.readDataToEndOfFile()) + readers.leave() + } + + 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 { + 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 + } + + 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 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 + 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) + } + + // MARK: - Value Decoding + + 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 + } + } + + 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 + } + } + + 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 + } + 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() + 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 { + 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]) -> [String: BeancountSourceSignature] { + 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 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() + 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 667f113cb..fa5dcbe36 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..939ab0e69 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 | Tích hợp sẵn | | Cassandra / ScyllaDB | Plugin | | Etcd | Plugin | | Cloudflare D1 | Plugin | diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1d69cb2f5..d5a243ae3 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 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 */; }; 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 */; }; @@ -167,6 +168,13 @@ remoteGlobalIDString = 5A869000000000000; remoteInfo = DuckDBDriver; }; + 5ABC14740000000000000F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A860000000000000; + remoteInfo = TableProPluginKit; + }; 5A86A000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -341,6 +349,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; }; @@ -494,6 +503,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 = ( @@ -701,6 +717,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 = ( @@ -913,6 +937,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC147400000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5ABC147400000000000001 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000300000000 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1077,6 +1109,7 @@ 5A867000500000000 /* Plugins/RedisDriverPlugin */, 5A868000500000000 /* Plugins/PostgreSQLDriverPlugin */, 5A869000500000000 /* Plugins/DuckDBDriverPlugin */, + 5ABC147400000000000007 /* Plugins/BeancountDriverPlugin */, 5A87A000500000000 /* Plugins/CassandraDriverPlugin */, 5A86A000500000000 /* Plugins/CSVExportPlugin */, 5ABBED7C2FB55E1400A78382 /* Plugins/CSVInspectorPlugin */, @@ -1111,6 +1144,7 @@ 5A867000100000000 /* RedisDriver.tableplugin */, 5A868000100000000 /* PostgreSQLDriver.tableplugin */, 5A869000100000000 /* DuckDBDriver.tableplugin */, + 5ABC147400000000000003 /* BeancountDriver.tableplugin */, 5A87A000100000000 /* CassandraDriver.tableplugin */, 5A86A000100000000 /* CSVExport.tableplugin */, 5A86B000100000000 /* JSONExport.tableplugin */, @@ -1524,6 +1558,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" */; @@ -1911,6 +1967,9 @@ 5A869000000000000 = { CreatedOnToolsVersion = 26.2; }; + 5ABC147400000000000005 = { + CreatedOnToolsVersion = 26.5; + }; 5A86A000000000000 = { CreatedOnToolsVersion = 26.2; }; @@ -1980,6 +2039,7 @@ 5A867000000000000 /* RedisDriver */, 5A868000000000000 /* PostgreSQLDriver */, 5A869000000000000 /* DuckDBDriver */, + 5ABC147400000000000005 /* BeancountDriver */, 5A87A000000000000 /* CassandraDriver */, 5A86A000000000000 /* CSVExport */, 5A86B000000000000 /* JSONExport */, @@ -2095,6 +2155,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC14740000000000000B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000400000000 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2209,6 +2276,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 */ 329606C9C47DD1FA4F8F5350 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -2325,6 +2416,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABC14740000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A86A000200000000 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2511,6 +2609,11 @@ target = 5A869000000000000 /* DuckDBDriver */; targetProxy = 5A869000B00000000 /* PBXContainerItemProxy */; }; + 5ABC147400000000000010 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A860000000000000 /* TableProPluginKit */; + targetProxy = 5ABC14740000000000000F /* PBXContainerItemProxy */; + }; 5A86A000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A86A000000000000 /* CSVExport */; @@ -3660,6 +3763,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 = { @@ -4585,6 +4738,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/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 59ad30091..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( @@ -855,6 +365,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+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/TablePro/Info.plist b/TablePro/Info.plist index 277bba8e2..e45120e3e 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -105,6 +105,22 @@ org.duckdb.duckdb-database + + CFBundleTypeName + Beancount Ledger + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + CFBundleTypeExtensions + + beancount + + LSItemContentTypes + + com.tablepro.beancount + + CFBundleTypeExtensions @@ -203,6 +219,24 @@ UTExportedTypeDeclarations + + 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/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index fe59110e8..005fa7059 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 { @@ -184,6 +185,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 215b8cde2..0e74cf594 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -60,7 +60,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/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/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/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/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift new file mode 100644 index 000000000..f945fe066 --- /dev/null +++ b/TableProTests/Plugins/BeancountPluginDriverTests.swift @@ -0,0 +1,261 @@ +// +// BeancountPluginDriverTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +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 { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + 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"]) + + 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("reloads the SQL projection when a glob include matches a new file") + func reloadsWhenGlobIncludeMatchesNewFile() async throws { + try await Self.withRustledger { + let directory = try Self.makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + 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) + + let ledger = directory.appendingPathComponent("main.beancount") + try "include \"imports/*.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"]) + + 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 { + 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 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 + + 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 { + 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 + 2024-01-01 open Expenses:Food USD + 2024-01-01 open Income:Salary 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: "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 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"]) + } + } + + // 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 body() + } + + private static func config(_ ledger: URL) -> DriverConnectionConfig { + DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: ledger.path) + } + + 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 new file mode 100644 index 000000000..5db2308b9 --- /dev/null +++ b/docs/databases/beancount.mdx @@ -0,0 +1,68 @@ +--- +title: Beancount +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. + +The plugin also supports BQL queries through its packaged `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, 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. + +## 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 9171f1e86..793507d39 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 3ad35ee40..7e60250a0 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) | 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 new file mode 100755 index 000000000..2f60340d0 --- /dev/null +++ b/scripts/download-rustledger.sh @@ -0,0 +1,144 @@ +#!/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 diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh index 639526311..6b56d5d3c 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