12. 查询语言 BQL 精通

12.查询语言 BQL 精通

在掌握了 Beancount 的基本记账原理和语法后,我们现在想了解如何从海量的交易数据中提取出想要的信息。这就是 BQL (Beancount Query Language) 的用武之地。BQL 是 Beancount 内置的、类似 SQL 的查询语言,通过 bean-query 工具进行交互。

BQL 的核心是 SELECT 语句,它的基本结构与我们熟悉的 SQL 非常相似,但又针对复式记账的数据结构做了专门的优化。一个最基本的查询长这样:

SELECT <目标列>
FROM <条目筛选表达式>
WHERE <分录筛选表达式>;

这个结构初看可能有点抽象,但它揭示了 BQL 一个极其重要的设计思想:双层筛选。

  1. FROM <条目筛选表达式>:这一部分负责在“交易(Transaction)”的层面上进行筛选。它会遍历你的整个账本,但只保留那些满足条件的完整交易。比如,你可以筛选出“2023年所有的交易”或者“所有带有‘#trip-nyc’标签的交易”。因为复式记账的铁律是每笔交易借贷平衡,所以筛选出的这批交易作为一个整体,其对总资产的影响是零。这对于生成特定时期的资产负债表或分析某次旅行的总开销至关重要。

  2. WHERE <分录筛选表达式>:在 FROM 筛选出一批完整的交易后,WHERE 子句会进一步在“分录(Posting)”的层面上进行筛选。它会从这些交易中,挑出满足条件的具体分录行。比如,你可以筛选出“所有发生在餐厅的支出”,即使这些支出来自不同的交易。

举个例子,如果你想查看 2023 年所有“餐饮”类别的支出,BQL 会这样工作: 首先,FROM year = 2023 筛选出 2023 年的所有完整交易。然后,WHERE account ~ 'Expenses:Food' 从这些交易中,只留下账户名匹配“Expenses:Food”的分录行。

这种双层结构是 BQL 的精髓,它既保证了会计数据的完整性,又提供了极大的筛选灵活性。在 bean-query 的交互式命令行中,你可以直接输入这些语句,系统会立即返回结果。

BQL posting columns

理解了 BQL 的双层筛选模型后,我们来看看 SELECT 和 WHERE 子句中可以使用的“列”。这些列对应着分录(Posting)及其父级交易(Transaction)的属性。

分录列主要描述了每一笔账目的具体细节。常用的分录列包括:

  • account: 分录所属的账户名称,例如 Assets:US:Bank:Checking。
  • position: 分录的头寸(Position),这是一个复合对象,包含了数量和商品(例如 100.00 USD 或 50.00 GOOG {120.00 USD})。在查询中,我们经常需要对它进行求和(sum(position))或提取单位(units(position))。
  • amount: 这是 position 的简化版,通常只包含数值和货币,不包含成本信息。在很多场景下,它和 position 是等价的。
  • cost: 提取分录的成本部分。对于 50.00 GOOG {120.00 USD},cost(position) 会返回 6000.00 USD。
  • price: 如果分录中指定了市价(例如 50.00 GOOG @ 130.00 USD),这个列会返回该价格。
  • balance: 这是一个非常强大的列,它会自动计算并返回该分录入账后,该账户的累计余额。这对于生成银行对账单风格的流水账非常有用。

这些列可以直接在 SELECT 后面指定,也可以在 WHERE 子句中用于过滤。例如,WHERE account = 'Assets:Cash' 就会筛选出所有现金账户的分录。

BQL entry columns

与分录列相对应的是条目(Entry)列,这些列描述的是分录所属的**交易(Transaction)**的属性。它们只能在 FROM 子句或 WHERE 子句中使用,用来筛选交易。

常用的条目列有:

  • date: 交易发生的日期。这是最常用的筛选条件,例如 FROM year = 2023 AND month = 1。
  • flag: 交易的标记,例如 *(已确认)或 !(未确认)。
  • payee: 交易的收款人或付款方。
  • narration: 交易的描述信息。
  • tags: 与交易关联的标签集合,例如 #vacation。可以使用 IN 操作符来检查标签,如 'vacation' IN tags。
  • links: 与交易关联的链接集合,例如 ^receipt-123。
  • type: 条目的类型,例如 Transaction、Balance、Pad 等。这在 FROM 子句中尤其有用,可以用来筛选特定类型的条目。

一个典型的用法是,在 FROM 子句中使用 date 和 tags 来圈定一个数据范围,然后在 WHERE 子句中使用 account 和 position 来分析这个范围内的具体账目。

BQL aggregate functions

当我们需要对数据进行汇总统计时,聚合函数就派上用场了。BQL 提供了一系列标准的聚合函数,它们通常与 GROUP BY 子句配合使用。

  • SUM(column): 对指定列求和。最常用的是 SUM(position),用来计算某个账户或某类账户的总余额。
  • COUNT(column): 计算行数。COUNT(*) 可以统计满足条件的分录数量。
  • MIN(column) / MAX(column): 找出指定列的最小值或最大值。
  • FIRST(column) / LAST(column): 返回结果集中第一行或最后一行的值。

一个简单的聚合查询看起来是这样的:

SELECT account, SUM(position)
FROM year = 2023
WHERE account ~ '^Expenses'
GROUP BY account;

这个查询会计算 2023 年所有以 "Expenses" 开头的账户的总支出,并按账户名分组显示。

除了这些标准函数,BQL 还提供了一些针对会计场景的特殊函数,它们是处理头寸和库存的关键:

  • UNITS(position_or_inventory): 提取头寸或库存的“单位”部分,即不考虑成本的数量。例如,UNITS(50.00 GOOG {120.00 USD}) 返回 50.00 GOOG。
  • COST(position_or_inventory): 提取头寸或库存的“成本”部分。例如,COST(50.00 GOOG {120.00 USD}) 返回 6000.00 USD。
  • WEIGHT(position_or_inventory): 返回用于交易平衡的“权重”量。对于带价格转换的分录,它与成本有所不同。
  • VALUE(inventory): 计算库存的市场价值,这需要依赖最新的价格信息。

这些函数让我们能够深入分析复杂的持仓情况,比如计算投资组合的总成本、总市值等。

BQL WHERE filtering

WHERE 子句是精细化筛选数据的核心。它使用一系列操作符来构建筛选条件。

  • 比较操作符: = (等于), != (不等于), <, <=, >, >=。
  • 逻辑操作符: AND, OR, NOT。
  • 集合操作符: IN,用于检查一个值是否存在于一个集合中,例如 'vacation' IN tags。
  • 正则表达式匹配: ~。这是一个非常强大的工具,用于模糊匹配字符串。例如,account ~ '.*:Food' 会匹配所有以 :Food 结尾的账户。

一个复杂的 WHERE 子句示例:

WHERE account ~ '^Assets:US:Bank' AND date >= 2023-01-01 AND NOT payee = 'Some Store'

这个条件筛选出:账户名以 Assets:US:Bank 开头,日期在 2023 年 1 月 1 日之后,且收款人不是 "Some Store" 的所有分录。

BQL result ordering

查询结果的默认顺序是按照交易日期和它们在文件中的出现顺序排列的。但你可以使用 ORDER BY 子句来改变这个顺序。

  • ORDER BY column1, column2, ...: 按一个或多个列进行升序(默认)排序。
  • ORDER BY column DESC: 使用 DESC 关键字进行降序排序。

例如,要查看最近发生的几笔大额支出,可以这样写:

SELECT date, payee, position
WHERE account ~ 'Expenses:Food'
ORDER BY date DESC, position ASC;

这个查询会先按日期降序排列(最新的在最前),对于同一天的交易,再按金额升序排列。

BQL statement operators

BQL 还提供了一些特殊的“语句操作符”,它们不是标准 SQL 的一部分,而是为了方便生成会计报表而设计的。这些操作符作用于 FROM 子句,用于对整个数据集进行转换,以模拟会计期间的开启和关闭。

  • OPEN ON <date>: 这个操作符会模拟在指定日期“开启”一个会计期间。它会将该日期之前的所有余额汇总,并创建一笔“期初余额”交易,将其计入 Equity:Opening Balances 账户。同时,它会将之前的损益(Income 和 Expenses)清零,转入 Equity:Previous Earnings。这使得你可以专注于分析指定日期之后的交易。
  • CLOSE ON <date>: 这个操作符会模拟在指定日期“关闭”一个会计期间。它会截断该日期之后的所有交易,让你可以生成截止到该日期的报表。
  • CLEAR: 这个操作符通常与 CLOSE 结合使用,它会将损益账户的余额清零,转入 Equity:Earnings:Current 账户。这是生成标准资产负债表的关键步骤。

这些操作符的组合使用,可以让你轻松地生成任意时间段的损益表和资产负债表。例如,要生成 2023 年的损益表,你可以这样写:

SELECT account, sum(position)
FROM OPEN ON 2023-01-01 CLOSE ON 2024-01-01
WHERE account ~ "Income|Expenses"
GROUP BY 1;

BQL EXPLAIN debugging

当你编写了一个复杂的 BQL 查询,但它没有按预期工作时,EXPLAIN 关键字就是你的调试利器。

在任何 SELECT、JOURNAL、BALANCES 或 PRINT 语句前加上 EXPLAIN,BQL 引擎不会执行该查询,而是会向你展示它如何解析和编译你的语句。它会显示生成的抽象语法树(AST)以及最终执行的内部指令。

这对于理解查询的执行逻辑、排查语法错误或性能问题非常有帮助。例如:

EXPLAIN SELECT account, sum(position) FROM year = 2023 GROUP BY account;

运行这个命令,你会看到 BQL 将 year = 2023 转换成了具体的日期范围,并展示了 GROUP BY 的实现方式。通过查看 EXPLAIN 的输出,你可以验证你的筛选条件是否被正确应用,以及聚合操作是否按预期分组。

掌握了 BQL,你就拥有了从 Beancount 账本中自由提取信息的能力,无论是简单的流水查询,还是复杂的财务分析,都能游刃有余。它是连接数据录入与财务洞察之间的桥梁。