Actual Budget 的 ActualQL 实战示例:从按月查询到 CLI 聚合统计
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
ActualQL 是 Actual Budget(本地优先的个人财务管理应用)内置的声明式查询语言,本文基于官方示例文档(packages/docs/docs/api/actual-ql/examples.md)展开,完整覆盖按月/按年搜索、按商户(payee)与按类别分组汇总、#interest备注模糊匹配等高频场景,并结合 ActualQL 底层编译器(compiler.ts)、查询构造器(query.ts)与 CLI 工具(query.ts)说明每个示例背后的实现原理。读完本文,你将能熟练编写从"取某月全部交易"到"按类别统计某财年支出"的完整 ActualQL 查询,并在终端中用actual query run完成同样任务。
前置知识:ActualQL 查询的基本结构
ActualQL 于 Actual 0.0.129 引入(见 actual-ql/index.md),它替代了此前行为被硬编码在后端的filterTransactions方法,让你可以自由控制排序、字段筛选与金额汇总。一次查询由q()构造、链式调用修饰符、再由runQuery/$query执行:
let { q, runQuery } = require('@actual-app/api'); let { data } = await runQuery( q('transactions') .filter({ 'category.name': 'Food', date: '2021-02-20' }) .select(['id', 'date', 'amount']), );q(table):构造查询,从 query.ts 的q函数返回一个不可变的Query实例,每次链式调用都会产生携带新状态的新实例。.filter(expr):追加过滤条件,内部实现为向filterExpressions数组追加表达式(query.ts)。.select(exprs):指定返回字段;可以传'*'、字段名字符串数组,或混合聚合表达式的对象数组(query.ts)。.calculate(expr):单一聚合快捷方式,等价于把表达式放入select的result字段并把calculation置为true(query.ts)。- 执行结果为
{ data }对象:普通查询时data是记录数组;使用calculate时data直接是聚合值。
金额约定:Actual 中所有金额以"整数分"存储(例如
-5000表示 -$50.00)。因此下面示例中在展示前统一执行了amount / 100的换算。
按月或按年搜索:$transform与日期函数
日期字段在数据库中通常是完整日期(如2021-01-01)。要按月份搜索,仅用$eq: '2021-01'是不够的——需要先把date字段转换为月份粒度,再比较。ActualQL 的解决方案是把转换函数放在$transform键中:
q('transactions') .filter({ date: { $transform: '$month', $eq: '2021-01' } }) .select('*');这条查询返回2021-01月份的全部交易。将$month换成$year即可按年搜索。
编译器中的实现原理
compileOp在处理过滤器对象时,会先把$transform从操作符对象中解构出来,其余键作为比较操作(compiler.ts)。因此{ $transform: '$month', $eq: '2021-01' }等价于"对字段应用$month函数后与2021-01比较"。$transform的值可以是字符串(如'$month'),也可以是一个完整函数表达式对象(如{ $month: '$' },$代表当前字段)——编译时两者都会被改写为对当前字段的调用(compiler.ts)。$month函数内部调用castInput(state, args[0], 'date-month'),将输入强制转换为date-month类型;$year则转换为date-year类型(compiler.ts)。castInput对date类型执行安全的自动降粒度转换(date-month接受date,date-year接受date与date-month),并据此生成对应的 SQL 表达式。- 最终生成的是形如
CAST(date AS month) = '2021-01'的 SQL 片段,由 exec.ts 在 SQLite 上执行。
注意:编译器仅接受字面量日期字符串的自动转换;运行时对非字面量字符串字段的日期转换会直接抛出CompileError(compiler.ts)。
按日期范围过滤:$gte/$lte与$and
ActualQL 支持$eq、$lt、$lte、$gt、$gte、$ne、$oneof、$regex、$like、$notlike等比较操作符。多个条件可用$and组合:
q('transactions').filter({ $and: [ { date: { $gte: '2020-04-06' } }, { date: { $lte: '2021-04-05' } }, ], });其编译结果就是两条 SQL 比较表达式的AND连接(compileConditions以AND拼接,见 compiler.ts)。$gte与$lte分别编译为>=与<=(compiler.ts)。
两种等效的简写也受支持:字段值传入数组时自动按$and组合,例如date: [{ $gte: '2021-01-01' }, { $lte: '2021-12-31' }](详见 actual-ql/index.md);多选日期用$or。
每个商户的总金额(指定时间段分组求和)
以下示例统计 2020-04-06 至 2021-04-05(英国财年)内每个商户的交易总额,按商户名排序输出:
( await $query( $q('transactions') .filter({ $and: [ { date: { $gte: '2020-04-06' } }, { date: { $lte: '2021-04-05' } }, ], }) .groupBy('payee.name') .orderBy('payee.name') .select(['payee.name', { amount: { $sum: '$amount' } }]), ) ).data.map(row => { console.log(`${row['payee.name']}: ${row.amount / 100}`); });关键点拆解
- 点号字段与表连接:
payee.name中的.会让编译器"穿透"到被引用表。transactions.payee是payees表的外键 id,payee.name通过表连接(makePath/resolvePath,见 compiler.ts)直接取得商户名称,从而按名称而非 id 过滤/分组。 - 聚合必须命名:在
select中使用聚合表达式时必须给结果命名(这里命名为amount);不命名会直接报错。$sum: '$amount'中的$amount是对字段的引用。 - 排序:
orderBy('payee.name')默认升序;orderBy也接受对象形式{ 'category.name': 'desc' }或数组做多字段排序。
备注含#interest (P)的全部交易总额
当需要筛选备注中带有特定文本的交易时,使用$like做 SQL 风格的模糊匹配(%为通配符)。文档给出了两种等价写法,结果一致:
写法一:calculate直接返回聚合值
( await $query( $q('transactions') .filter({ $and: [ { date: { $gte: '2020-04-06' } }, { date: { $lte: '2021-04-05' } }, { notes: { $like: '%#interest (P)%' } }, ], }) .calculate({ $sum: '$amount' }), ) ).data / 100;写法二:select命名聚合后取元素
( await $query( $q('transactions') .filter({ $and: [ { date: { $gte: '2020-04-06' } }, { date: { $lte: '2021-04-05' } }, { notes: { $like: '%#interest (P)%' } }, ], }) .select({ total: { $sum: '$amount' } }), ) ).data[0].total / 100;区别在于:calculate把聚合表达式包进{ result: expr }并设置calculation: true(query.ts),执行结果data直接就是数值;而select的data是单元素数组,需要data[0].total取值。calculate的便利性在于无需为聚合命名。
每个类别的总金额(按分组层级排序)
按类别分组时,category.name通过categories表连接取得类别名称,同时还能访问其所属的类别组category.group.name,并按照类别组顺序 + 类别顺序排序,从而得到与预算页面一致的展示层级:
( await $query( $q('transactions') .filter({ $and: [ { date: { $gte: '2020-04-06' } }, { date: { $lte: '2021-04-05' } }, ], }) .groupBy('category.name') .orderBy(['category.group.sort_order', 'category.sort_order']) .select([ 'category.group.name', 'category.name', { amount: { $sum: '$amount' } }, ]), ) ).data.map(row => { console.log( `${row['category.group.name']}/${row['category.name']}: ${ row.amount / 100 }`, ); });输出形如Essentials/Groceries: 245.32。这里体现了多级点号字段的解析能力:category.group.name需要先从transactions连接到categories,再从categories的group_id连接到category_groups,编译器会在路径不存在时抛出Path does not exist的CompileError(compiler.ts)。
在 CLI 中运行相同查询
以上示例均为 JavaScript 写法。若使用@actual-app/cli提供的 CLI 工具,可以用命令行参数表达同样查询。CLI 文档中的映射对照如下:
# 选择特定字段(JS: .select(['date', 'amount', 'payee.name'])) actual query run --table transactions --select "date,amount,payee.name" # 条件过滤(JS: .filter({ amount: { $lt: 0 } })) actual query run --table transactions --filter '{"amount":{"$lt":0}}' # 字段降序(JS: .orderBy([{ date: 'desc' }])) actual query run --table transactions --order-by "date:desc" # 按月搜索(JS: .filter({ date: { $transform: '$month', $eq: '2021-01' } })) actual query run --table transactions --filter '{"date":{"$transform":"$month","$eq":"2021-01"}}' # 按 payee 分组求和 —— 聚合表达式需使用 --file 传入 echo '{"table":"transactions","groupBy":["payee.name"],"select":["payee.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file - # 统计交易数(JS: .calculate({ $count: '*' })) actual query run --table transactions --count # 快捷方式:最近 10 笔交易 actual query run --last 10CLI 参数背后的实现
--select、--filter、--order-by、--group-by、--limit、--offset、--count、--file是query run的主要选项(见 cli.md 中的 Options 表)。--where是--filter的别名,二者不能同时使用。--order-by "date:desc,amount:asc"会被解析为 AQL 的[{ date: 'desc' }, { amount: 'asc' }]形式;不写方向默认升序,方向只接受asc/desc,非法方向会直接报错(query.ts)。--last <n>是一个快捷参数:隐含--table transactions与--order-by date:desc,默认输出列为date, account.name, payee.name, category.name, amount, notes(query.ts)。- 由于
--filter/--select等参数以字符串传递,聚合表达式这类复杂结构统一通过--file(JSON 文件或-从 stdin 读取)构造完整查询;CLI 内部以 API 包 为桥梁,把解析后的查询对象交给 ActualQL 执行器。 - CLI 内置了各表字段元数据(query.ts),
query fields transactions可以直接列出date、amount、payee.name、category.group.name等可用字段及类型,方便构造上述查询。
实战技巧与注意点
- 拆分交易(split transactions)的处理:默认
splits: 'inline'只返回子交易(不返回父交易),求和不会重复计数;grouped则总是返回完整拆分交易并附带subtransactions属性;另有高级选项all以扁平列表同时返回父与子。需要全量精确求和时,也可显式过滤"is_parent": false(详见 actual-ql/index.md 与 cli.md 的 Tips)。 - 未分类交易:没有类别的交易其
category.name为null,按类别过滤或分组时要考虑到这一点(cli.md)。 - 不要用 AQL 字段做日期子字段:
date.month、date.year这类字段在 AQL 中并不存在;按月分组应使用$transform方案,或按日期范围取数后在脚本中自行聚合。 - 金额换算:所有金额单位为整数分,CLI 的
--format table/csv会自动转为小数显示,而 JSON 输出始终为原始分值,脚本消费时记得除以 100。
进一步阅读
- ActualQL 概念与过滤操作符全集:actual-ql/index.md
- 字段引用(
.穿透连接)、排序与聚合函数说明:actual-ql/functions.md - ActualQL 函数与类型参考:api/reference.md
- CLI 完整命令与配置说明:api/cli.md
- 查询构造器实现:loot-core/src/shared/query.ts
- 查询编译器实现:loot-core/src/server/aql/compiler.ts
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考