From ca01e1625a87fb16c4b1c480e3c9d8adfe710770 Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Sat, 16 May 2026 22:38:47 +0800 Subject: [PATCH] sources/beancount: Add custom table Expose Beancount ``custom`` directives as a queryable table with ``meta``, ``date``, ``type``, and ``values`` columns, mirroring the existing ``notes`` / ``events`` / ``documents`` tables. ``values`` is a list of typed values as parsed by Beancount. Co-Authored-By: Claude Opus 4.7 --- CHANGES.rst | 4 ++ beanquery/custom_test.py | 87 ++++++++++++++++++++++++++++++++++ beanquery/shell_test.py | 1 + beanquery/sources/beancount.py | 6 +++ 4 files changed, 98 insertions(+) create mode 100644 beanquery/custom_test.py diff --git a/CHANGES.rst b/CHANGES.rst index d9077f6d..4b402cb7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Version 0.1 (unreleased) ------------------------ +- Added the ``custom`` table to query Beancount ``custom`` directives. + It exposes the ``meta``, ``date``, ``type``, and ``values`` columns, + where ``values`` is a list of the directive's typed values. + - The ``HAVING`` clause for aggregate queries is now supported. - The ``empty()`` BQL function to determine whether an Inventory diff --git a/beanquery/custom_test.py b/beanquery/custom_test.py new file mode 100644 index 00000000..ea3ad727 --- /dev/null +++ b/beanquery/custom_test.py @@ -0,0 +1,87 @@ +__license__ = "GNU GPLv2" + +import datetime +import textwrap +import unittest + +from beancount import loader +from beancount.core import amount + +import beanquery + + +def load(source): + entries, errors, options = loader.load_string(textwrap.dedent(source)) + assert not errors, errors + conn = beanquery.connect('beancount:', entries=entries, errors=errors, options=options) + return conn + + +class TestCustomTable(unittest.TestCase): + + def test_columns(self): + conn = load("") + cur = conn.execute('SELECT meta, date, type, values FROM custom') + names = [d[0] for d in cur.description] + self.assertEqual(names, ['meta', 'date', 'type', 'values']) + + def test_wildcard_excludes_meta(self): + conn = load(""" + 2024-01-01 custom "budget" "expenses" 500.00 USD + """) + cur = conn.execute('SELECT * FROM custom') + names = [d[0] for d in cur.description] + self.assertEqual(names, ['date', 'type', 'values']) + + def test_select_rows(self): + conn = load(""" + 2024-01-01 custom "budget" "expenses" "monthly" 500.00 USD + 2024-02-01 custom "budget" "income" 1000.00 USD + 2024-03-01 custom "fiscal-year-end" 2024-12-31 + """) + cur = conn.execute('SELECT date, type, values FROM custom ORDER BY date') + rows = cur.fetchall() + self.assertEqual(len(rows), 3) + + self.assertEqual(rows[0][0], datetime.date(2024, 1, 1)) + self.assertEqual(rows[0][1], 'budget') + self.assertIsInstance(rows[0][2], list) + self.assertEqual([v.value for v in rows[0][2]], + ['expenses', 'monthly', amount.Amount.from_string('500.00 USD')]) + + self.assertEqual(rows[1][1], 'budget') + self.assertEqual(len(rows[1][2]), 2) + + self.assertEqual(rows[2][1], 'fiscal-year-end') + self.assertEqual([v.value for v in rows[2][2]], [datetime.date(2024, 12, 31)]) + + def test_filter_by_type(self): + conn = load(""" + 2024-01-01 custom "budget" "expenses" 500.00 USD + 2024-02-01 custom "note" "hello" + 2024-03-01 custom "budget" "income" 1000.00 USD + """) + cur = conn.execute("SELECT date FROM custom WHERE type = 'budget' ORDER BY date") + rows = cur.fetchall() + self.assertEqual([r[0] for r in rows], + [datetime.date(2024, 1, 1), datetime.date(2024, 3, 1)]) + + def test_meta_access(self): + conn = load(""" + 2024-01-01 custom "budget" "expenses" 500.00 USD + """) + cur = conn.execute('SELECT meta FROM custom') + meta = cur.fetchone()[0] + self.assertIn('filename', meta) + self.assertIn('lineno', meta) + + def test_empty(self): + conn = load(""" + 2024-01-01 open Assets:Cash USD + """) + cur = conn.execute('SELECT * FROM custom') + self.assertEqual(cur.fetchall(), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/beanquery/shell_test.py b/beanquery/shell_test.py index bbb8dfb2..5c68d783 100644 --- a/beanquery/shell_test.py +++ b/beanquery/shell_test.py @@ -304,6 +304,7 @@ def test_tables(self, out, err): accounts balances commodities + custom documents entries events diff --git a/beanquery/sources/beancount.py b/beanquery/sources/beancount.py index e388b6c6..e689421d 100644 --- a/beanquery/sources/beancount.py +++ b/beanquery/sources/beancount.py @@ -203,6 +203,12 @@ class DocumentsTable(Table): columns = _typed_namedtuple_to_columns(datatype) +class CustomTable(Table): + name = 'custom' + datatype = data.Custom + columns = _typed_namedtuple_to_columns(datatype) + + class GetItemColumn(query_compile.EvalColumn): def __init__(self, key, dtype): super().__init__(dtype)