Skip to content

Commit 15b1fee

Browse files
feat (plugin-beancount): Add Beancount driver (#1476)
* Add Beancount driver plugin * Address Beancount driver review feedback * Make Beancount a downloadable plugin * Clarify Beancount plugin install docs * refactor(plugin-beancount): source SQL projection from rledger and drop the hand parser * Fix TableProCore database type count test * Fix Beancount rledger integration * Add Python Beancount fallback backend * refactor(plugin-beancount): offload backend work off the async pool and add CI projection coverage --------- Co-authored-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent d5ba969 commit 15b1fee

31 files changed

Lines changed: 3298 additions & 501 deletions

.github/workflows/build-plugin.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ jobs:
158158
DISPLAY_NAME="DuckDB Driver"; SUMMARY="DuckDB analytical database driver"
159159
DB_TYPE_IDS='["DuckDB"]'; ICON="bird"; BUNDLE_NAME="DuckDBDriver"
160160
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;;
161+
beancount)
162+
TARGET="BeancountDriver"; BUNDLE_ID="com.TablePro.BeancountDriver"
163+
DISPLAY_NAME="Beancount Driver"; SUMMARY="Read-only Beancount ledger driver using user-provided rledger or Python Beancount"
164+
DB_TYPE_IDS='["Beancount"]'; ICON="beancount-icon"; BUNDLE_NAME="BeancountDriver"
165+
CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/beancount" ;;
161166
cassandra)
162167
TARGET="CassandraDriver"; BUNDLE_ID="com.TablePro.CassandraDriver"
163168
DISPLAY_NAME="Cassandra Driver"; SUMMARY="Apache Cassandra and ScyllaDB driver via DataStax C driver"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Beancount ledger support as a downloadable, read-only file-based driver. Transactions, postings (with resolved cost basis), accounts, prices, computed balances, and balance assertions project to SQL tables through user-provided `rledger` or Python Beancount, and BQL runs with a `BQL:` prefix when `rledger` is available. (#1474)
1213
- The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab.
1314

1415
## [0.56.2] - 2026-07-10

Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,15 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
2727
public static let bigquery = DatabaseType(rawValue: "BigQuery")
2828
public static let snowflake = DatabaseType(rawValue: "Snowflake")
2929
public static let libsql = DatabaseType(rawValue: "libSQL")
30+
public static let beancount = DatabaseType(rawValue: "Beancount")
3031
public static let cockroachdb = DatabaseType(rawValue: "CockroachDB")
3132
public static let scylladb = DatabaseType(rawValue: "ScyllaDB")
3233
public static let turso = DatabaseType(rawValue: "Turso")
3334

3435
public static let allKnownTypes: [DatabaseType] = [
3536
.mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb,
3637
.clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift,
37-
.etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql
38+
.etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount
3839
]
3940

4041
/// 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 {
5859
case .bigquery: return "bigquery-icon"
5960
case .snowflake: return "snowflake-icon"
6061
case .libsql: return "libsql-icon"
62+
case .beancount: return "beancount-icon"
6163
default: return "externaldrive"
6264
}
6365
}

Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct DatabaseTypeTests {
1616
#expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1")
1717
#expect(DatabaseType.bigquery.rawValue == "BigQuery")
1818
#expect(DatabaseType.snowflake.rawValue == "Snowflake")
19+
#expect(DatabaseType.beancount.rawValue == "Beancount")
1920
}
2021

2122
@Test("pluginTypeId maps multi-type databases")
@@ -52,11 +53,12 @@ struct DatabaseTypeTests {
5253

5354
@Test("allKnownTypes contains all expected types")
5455
func allKnownTypesComplete() {
55-
#expect(DatabaseType.allKnownTypes.count == 18)
56+
#expect(DatabaseType.allKnownTypes.count == 19)
5657
#expect(DatabaseType.allKnownTypes.contains(.mysql))
5758
#expect(DatabaseType.allKnownTypes.contains(.bigquery))
5859
#expect(DatabaseType.allKnownTypes.contains(.snowflake))
5960
#expect(DatabaseType.allKnownTypes.contains(.libsql))
61+
#expect(DatabaseType.allKnownTypes.contains(.beancount))
6062
}
6163

6264
@Test("Hashable conformance")
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
//
2+
// BeancountIncludeResolver.swift
3+
// BeancountDriverPlugin
4+
//
5+
6+
import Foundation
7+
8+
struct BeancountSourceGraph: Sendable {
9+
let sourceFiles: [URL]
10+
let watchedDirectories: [URL]
11+
}
12+
13+
enum BeancountResolverError: LocalizedError {
14+
case includeCycle(String)
15+
case unreadable(URL, Error)
16+
17+
var errorDescription: String? {
18+
switch self {
19+
case .includeCycle(let path):
20+
return String(format: String(localized: "Beancount include cycle detected at %@"), path)
21+
case .unreadable(let url, let error):
22+
return String(format: String(localized: "Could not read %@: %@"), url.path, error.localizedDescription)
23+
}
24+
}
25+
}
26+
27+
final class BeancountIncludeResolver {
28+
private var visited: Set<URL> = []
29+
private var activeStack: Set<URL> = []
30+
private var sourceFiles: [URL] = []
31+
private var watchedDirectories: Set<URL> = []
32+
33+
func resolve(fileURL: URL) throws -> BeancountSourceGraph {
34+
visited.removeAll()
35+
activeStack.removeAll()
36+
sourceFiles.removeAll()
37+
watchedDirectories.removeAll()
38+
39+
try resolveFile(fileURL.standardizedFileURL)
40+
41+
return BeancountSourceGraph(
42+
sourceFiles: sourceFiles,
43+
watchedDirectories: watchedDirectories.sorted { $0.path < $1.path }
44+
)
45+
}
46+
47+
private func resolveFile(_ url: URL) throws {
48+
let normalized = url.standardizedFileURL
49+
if activeStack.contains(normalized) {
50+
throw BeancountResolverError.includeCycle(normalized.path)
51+
}
52+
guard !visited.contains(normalized) else { return }
53+
54+
activeStack.insert(normalized)
55+
defer { activeStack.remove(normalized) }
56+
57+
let contents: String
58+
do {
59+
contents = try String(contentsOf: normalized, encoding: .utf8)
60+
} catch {
61+
throw BeancountResolverError.unreadable(normalized, error)
62+
}
63+
64+
visited.insert(normalized)
65+
sourceFiles.append(normalized)
66+
67+
for rawLine in contents.components(separatedBy: .newlines) {
68+
let trimmed = stripComment(rawLine).trimmingCharacters(in: .whitespaces)
69+
guard trimmed.hasPrefix("include "), let includePath = quotedString(in: trimmed) else { continue }
70+
let includeURLs = try resolveIncludeURLs(
71+
includePath,
72+
relativeTo: normalized.deletingLastPathComponent()
73+
)
74+
for includeURL in includeURLs {
75+
try resolveFile(includeURL)
76+
}
77+
}
78+
}
79+
80+
private func resolveIncludeURLs(_ includePath: String, relativeTo directory: URL) throws -> [URL] {
81+
guard containsGlobPattern(includePath) else {
82+
return [resolveIncludeURL(includePath, relativeTo: directory)]
83+
}
84+
85+
let patternURL = resolveIncludeURL(includePath, relativeTo: directory)
86+
let patternPath = patternURL.path
87+
let searchRoot = globSearchRoot(for: patternPath)
88+
guard searchRoot.path != "/" else { return [] }
89+
90+
let fileManager = FileManager.default
91+
guard fileManager.fileExists(atPath: searchRoot.path) else {
92+
watchedDirectories.insert(existingWatchDirectory(for: searchRoot))
93+
return []
94+
}
95+
watchedDirectories.insert(searchRoot)
96+
97+
let regex = try NSRegularExpression(pattern: globRegex(for: patternPath))
98+
let enumerator = fileManager.enumerator(
99+
at: searchRoot,
100+
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
101+
options: [.skipsHiddenFiles]
102+
)
103+
104+
var matches: [URL] = []
105+
while let candidate = enumerator?.nextObject() as? URL {
106+
let values = try? candidate.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
107+
if values?.isDirectory == true {
108+
watchedDirectories.insert(candidate.standardizedFileURL)
109+
continue
110+
}
111+
guard values?.isRegularFile == true else { continue }
112+
113+
let path = candidate.standardizedFileURL.path
114+
let range = NSRange(location: 0, length: (path as NSString).length)
115+
if regex.firstMatch(in: path, range: range) != nil {
116+
matches.append(candidate.standardizedFileURL)
117+
}
118+
}
119+
120+
return matches.sorted { $0.path < $1.path }
121+
}
122+
123+
private func resolveIncludeURL(_ includePath: String, relativeTo directory: URL) -> URL {
124+
if includePath.hasPrefix("/") {
125+
return URL(fileURLWithPath: includePath).standardizedFileURL
126+
}
127+
return directory.appendingPathComponent(includePath).standardizedFileURL
128+
}
129+
130+
private func containsGlobPattern(_ path: String) -> Bool {
131+
path.contains("*") || path.contains("?") || path.contains("[")
132+
}
133+
134+
private func globSearchRoot(for patternPath: String) -> URL {
135+
let components = (patternPath as NSString).pathComponents
136+
let prefix = components.prefix { !containsGlobPattern($0) }
137+
let rootPath = NSString.path(withComponents: Array(prefix))
138+
return URL(fileURLWithPath: rootPath.isEmpty ? "/" : rootPath).standardizedFileURL
139+
}
140+
141+
private func existingWatchDirectory(for missingDirectory: URL) -> URL {
142+
var candidate = missingDirectory.standardizedFileURL
143+
let fileManager = FileManager.default
144+
while candidate.path != "/" {
145+
var isDirectory: ObjCBool = false
146+
if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory),
147+
isDirectory.boolValue {
148+
return candidate
149+
}
150+
candidate.deleteLastPathComponent()
151+
}
152+
return URL(fileURLWithPath: "/")
153+
}
154+
155+
private func globRegex(for patternPath: String) -> String {
156+
let characters = Array(patternPath)
157+
var regex = "^"
158+
var index = 0
159+
160+
while index < characters.count {
161+
let character = characters[index]
162+
if character == "*" {
163+
let nextIndex = index + 1
164+
if nextIndex < characters.count, characters[nextIndex] == "*" {
165+
let slashIndex = index + 2
166+
if slashIndex < characters.count, characters[slashIndex] == "/" {
167+
regex += "(?:.*/)?"
168+
index += 3
169+
} else {
170+
regex += ".*"
171+
index += 2
172+
}
173+
} else {
174+
regex += "[^/]*"
175+
index += 1
176+
}
177+
} else if character == "?" {
178+
regex += "[^/]"
179+
index += 1
180+
} else if character == "[" {
181+
let start = index
182+
index += 1
183+
while index < characters.count, characters[index] != "]" {
184+
index += 1
185+
}
186+
if index < characters.count {
187+
regex += String(characters[start...index])
188+
index += 1
189+
} else {
190+
regex += NSRegularExpression.escapedPattern(for: String(character))
191+
}
192+
} else {
193+
regex += NSRegularExpression.escapedPattern(for: String(character))
194+
index += 1
195+
}
196+
}
197+
198+
return regex + "$"
199+
}
200+
201+
private func quotedString(in line: String) -> String? {
202+
var inQuote = false
203+
var isEscaped = false
204+
var current = ""
205+
206+
for character in line {
207+
if isEscaped {
208+
current.append(character)
209+
isEscaped = false
210+
continue
211+
}
212+
if character == "\\" {
213+
isEscaped = true
214+
continue
215+
}
216+
if character == "\"" {
217+
if inQuote {
218+
return current
219+
}
220+
inQuote = true
221+
continue
222+
}
223+
if inQuote {
224+
current.append(character)
225+
}
226+
}
227+
228+
return nil
229+
}
230+
231+
private func stripComment(_ line: String) -> String {
232+
var inQuote = false
233+
var isEscaped = false
234+
var result = ""
235+
for character in line {
236+
if isEscaped {
237+
result.append(character)
238+
isEscaped = false
239+
continue
240+
}
241+
if character == "\\" {
242+
result.append(character)
243+
isEscaped = true
244+
continue
245+
}
246+
if character == "\"" {
247+
inQuote.toggle()
248+
}
249+
if character == ";" && !inQuote {
250+
break
251+
}
252+
result.append(character)
253+
}
254+
return result
255+
}
256+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// BeancountPlugin.swift
3+
// BeancountDriverPlugin
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin {
10+
static let pluginName = "Beancount Driver"
11+
static let pluginVersion = "1.0.0"
12+
static let pluginDescription = "Read-only Beancount ledger support"
13+
static let capabilities: [PluginCapability] = [.databaseDriver]
14+
15+
static let databaseTypeId = "Beancount"
16+
static let databaseDisplayName = "Beancount"
17+
static let iconName = "beancount-icon"
18+
static let defaultPort = 0
19+
20+
static let isDownloadable = true
21+
static let pathFieldRole: PathFieldRole = .filePath
22+
static let requiresAuthentication = false
23+
static let supportsSSH = false
24+
static let supportsSSL = false
25+
static let connectionMode: ConnectionMode = .fileBased
26+
static let urlSchemes: [String] = ["beancount"]
27+
static let fileExtensions: [String] = ["beancount"]
28+
static let brandColorHex = "#3F7D20"
29+
static let supportsForeignKeys = false
30+
static let supportsSchemaEditing = false
31+
static let supportsDatabaseSwitching = false
32+
static let supportsSchemaSwitching = false
33+
static let supportsImport = false
34+
static let supportsHealthMonitor = false
35+
static let databaseGroupingStrategy: GroupingStrategy = .flat
36+
static let tableEntityName = "Ledger Tables"
37+
static let columnTypesByCategory: [String: [String]] = [
38+
"Integer": ["INTEGER"],
39+
"String": ["TEXT"],
40+
"Date": ["DATE"]
41+
]
42+
static let immutableColumns: [String] = [
43+
"id", "transaction_id", "date", "flag", "payee", "narration",
44+
"account", "amount", "commodity", "cost_number", "cost_currency",
45+
"currency", "currencies", "name", "open_date", "path"
46+
]
47+
48+
static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
49+
identifierQuote: "\"",
50+
keywords: [
51+
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER",
52+
"ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS",
53+
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET",
54+
"WITH", "RECURSIVE", "UNION", "INTERSECT", "EXCEPT",
55+
"CASE", "WHEN", "THEN", "ELSE", "END", "NULL", "IS",
56+
"ASC", "DESC", "DISTINCT"
57+
],
58+
functions: [
59+
"COUNT", "SUM", "AVG", "MAX", "MIN",
60+
"COALESCE", "NULLIF", "ROUND", "ABS",
61+
"DATE", "STRFTIME", "SUBSTR", "LOWER", "UPPER"
62+
],
63+
dataTypes: ["INTEGER", "TEXT", "DATE"],
64+
regexSyntax: .unsupported,
65+
booleanLiteralStyle: .numeric,
66+
likeEscapeStyle: .explicit,
67+
paginationStyle: .limit
68+
)
69+
70+
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
71+
BeancountPluginDriver(config: config)
72+
}
73+
}

0 commit comments

Comments
 (0)