Actual 的 ActualQL 查询语言完全指南:从基础查询到拆分交易与操作符实战
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
ActualQL 是 Actual(本地优先的个人财务管理应用)在 0.0.129 版本中引入的查询语言,用于替代此前行为固化在服务端的filterTransactions方法,让用户能够以声明式语法自由查询交易、排序、筛选并聚合数据。本文以 ActualQL 官方文档为主线,结合仓库源码(查询构建器、编译器与测试用例)逐层展开,帮助你掌握构建查询、执行查询、操作符筛选以及拆分交易(split transactions)处理的完整实战方案。
一、ActualQL 是什么:从filterTransactions到可组合查询
在 ActualQL 出现之前,Actual 仅提供filterTransactions这类内置方法搜索交易,但其行为完全被硬编码在后端:你无法自定义排序规则、无法针对特定字段精确搜索、也无法直接对金额求和。ActualQL 提供了一个轻量级的查询语法,把上述能力全部开放给调用方。
一个最基础的 ActualQL 查询长这样:
q('transactions') .filter({ 'category.name': 'Food', date: '2021-02-20', }) .select(['id', 'date', 'amount']);该查询会返回2021-02-20当天、类别为Food的所有交易的id、date、amount字段。
值得强调的是,Actual 自身的大部分功能都在使用 ActualQL(文档原话为 "Most of Actual uses ActualQL"),因此通过 API 你能访问到与 Actual 应用内部完全一致的查询能力,而不是一套受限的简化接口。
二、快速上手:构建查询与执行查询
ActualQL 的用法分为两步:先用q()构建查询对象,再用runQuery()执行它。
let { q, runQuery } = require('@actual-app/api'); let { data } = await runQuery(q('transactions').select('*'));执行结果是一个对象,其中data属性保存查询结果。上面的例子中,data是系统中全部交易的数组。
从源码看执行链路
在 packages/api/methods.ts 中可以看到,runQuery与aqlQuery都是把查询序列化后通过消息通道发送给核心引擎:
export function runQuery(query: Query) { return send('api/query', { query: query.serialize() }); } export function aqlQuery(query: Query) { return send('api/query', { query: query.serialize() }); }注意:当前版本中
runQuery已被标记为@deprecated,源码注释建议改用aqlQuery("Please useaqlQueryinstead. This function will be removed in a future release.")。文档示例仍以runQuery演示,两者行为一致,新代码建议直接使用aqlQuery。
在引擎侧,请求最终进入 packages/loot-core/src/server/aql/index.ts 的aqlQuery:它将查询状态交给编译与执行管线compileAndRunAqlQuery,结合 schema 与执行器(schemaExecutors)完成从查询对象到结果集的转换。
q构建器与 Query 类
q是一个工厂函数,返回Query实例,其完整定义位于 packages/loot-core/src/shared/query.ts 与 API 侧的 packages/api/app/query.ts。Query采用不可变链式风格,每次调用都返回携带新状态的新实例。除了文档提到的filter、select、options,它还提供了大量可用方法:
| 方法 | 作用 |
|---|---|
filter(expr) | 追加筛选条件 |
unfilter(keys?) | 按字段名移除指定筛选条件(不传参数则清空全部) |
select(exprs) | 指定返回字段,支持'*'或字段数组 |
calculate(expr) | 执行聚合计算(如求和),结果作为result返回 |
groupBy(exprs) | 按表达式分组 |
orderBy(exprs) | 排序 |
limit(n)/offset(n) | 分页 |
options(opts) | 设置表级选项(如拆分交易处理) |
raw() | 原始模式,跳过字段映射 |
withDead() | 包含已删除(tombstone)记录 |
withoutValidatedRefs() | 关闭引用字段校验 |
serialize() | 序列化为可传输的查询状态 |
在 packages/api/app/query.ts 可以看到QueryState的默认初始化:tableOptions、filterExpressions、selectExpressions、groupExpressions、orderExpressions默认为空,validateRefs默认为true(即默认会校验字段引用是否存在于 schema 中)。
三、搜索交易:filter 与操作符详解
调用filter即对查询施加条件,只有满足所有条件的记录才会被返回。filter 对象的键是字段名,值是条件;默认执行"等于"比较,也支持传入各种操作符。
基础示例:多字段 + 比较操作符
q('transactions') .filter({ 'category.name': 'Food', date: { $gte: '2021-01-01' }, }) .select('*');date: { $gte: '2021-01-01' }表示返回2021-01-01及之后的交易。
完整操作符列表
文档明确列出的可用操作符为:$eq、$lt、$lte、$gt、$gte、$ne、$oneof、$regex、$like、$notlike。
| 操作符 | 含义 |
|---|---|
$eq | 等于(默认) |
$lt/$lte | 小于 / 小于等于 |
$gt/$gte | 大于 / 大于等于 |
$ne | 不等于 |
$oneof | 值属于给定集合中的任意一个 |
$regex | 正则表达式匹配 |
$like | 模糊匹配(SQL LIKE 风格) |
$notlike | 模糊不匹配 |
在编译器 packages/loot-core/src/server/aql/compiler.ts 中可以看到这些操作符的底层 SQL 实现:
$oneof被编译为IN (...)子句,且会自动对 id 集合去重;$like使用UNICODE_LIKE+NORMALISE实现大小写无关的模糊匹配(模式串同样会被规范化);$regex在编译器源码中对应分支名为$regexp,编译为REGEXP(...);$notlike编译为NOT UNICODE_LIKE(...) OR field IS NULL;- 未识别的操作符会抛出
CompileError: Unknown operator。
这意味着文档描述之外,你还拥有正则与 LIKE 通配符等灵活的字符串匹配能力。
数组条件:自动合并为 AND
如果给某个字段传入数组,多个条件会被自动用$and组合:
q('transactions') .filter({ date: [{ $gte: '2021-01-01' }, { $lte: '2021-12-31' }], }) .select('*');这等价于显式使用$and:
q('transactions') .filter({ $and: [{ date: { $gte: '2021-01-01' } }, { date: { $lte: '2021-12-31' } }], }) .select('*');两条查询都限定交易日期在2021-01-01与2021-12-31之间。
$and 与 $or:组合多条独立条件
$and与$or接收条件数组并合并多个条件。例如获取多个日期的交易:
q('transactions') .filter({ $or: [{ date: '2021-01-01' }, { date: '2021-01-02' }], }) .select('*');上述查询会返回2021-01-01或2021-01-02的交易。
字段与点路径(dotted path)
文档示例中的'category.name'是一种点路径字段引用,它沿外键关系穿透到关联表。在 schema 定义 packages/loot-core/src/server/aql/schema/index.ts 中,transactions表的category字段被声明为f('id', { ref: 'categories' }),因此可以直接用'category.name'引用类别的名称字段。
以transactions表为例,其可用字段包括(源码可见于 schema/index.ts):id、account、category、amount(整数,单位分)、payee、notes、date、imported_id、error、imported_payee、starting_balance_flag、transfer_id、sort_order、cleared、reconciled、tombstone、schedule、raw_synced_data。日期类字段使用'YYYY-MM-DD'字符串格式。
四、处理拆分交易(Split Transactions)
拆分交易会让聚合与选择变得复杂:当对交易金额求和时,是统计所有子交易,还是只用顶层交易?选择交易时,你想要哪些记录?
transactions表为此提供了两种不同的数据接口,通过options传入splits选项进行配置:
q('transactions').select('*').options({ splits: 'inline' });inline(默认值)
inline是默认行为,不会返回拆分交易的 "parent" 交易,只返回子交易,结果是一个扁平数组。这样默认求和时就自然忽略了 "parent" 交易,避免重复统计金额。
grouped
grouped总是返回完整的拆分交易(parent + 全部子交易),无论命中筛选条件的是哪一部分。返回的数据是分组的,交易带有一个subtransactions属性列出其子交易。
all与none
文档脚注还提到第三种选项all:以扁平列表同时返回交易与子交易,仅在需要做高级处理时才用。而从源码 packages/loot-core/src/server/aql/schema/executors.ts 看,合法的取值实际有四种:
function isValidSplitsOption(splits: string): splits is SplitsOption { return ['all', 'inline', 'none', 'grouped'].includes(splits); }其中none只返回 parent 交易(不含子交易)。若传入非法值,执行器会抛出Invalid "splits" option for transactions错误(见 executors.ts)。
源码级行为差异
executors.ts 顶部注释给出了一个极具说明性的对比:
// q('transactions').select({ $count: 'id' }) // q('transactions', { splits: "grouped" }).select({ $count: 'id' }) // // The first will return the count of non-split and child // transactions, and the second will return the count of all parent // (or non-split) transactions即:默认模式下计数包含普通交易与子交易;grouped模式下计数只统计 parent(或非拆分)交易。对应的行为测试覆盖在 packages/loot-core/src/server/aql/schema/executors.test.ts 中,包括splits: 'inline'只返回非 parent 交易、splits: 'none'只返回 parent、以及splits: 'grouped'下的聚合查询等场景。
此外,subtransactions是一个特殊字段,只有当表使用splits: grouped选项时才存在(见 schema/index.ts 的注释)。
选择inline还是grouped,本质上是选择"面向金额汇总"还是"面向完整结构"的数据视角——这四种选项给了你处理拆分交易的完全控制权。
五、底层原理:ActualQL 如何编译为 SQL
理解 ActualQL 的工作机制有助于你写出更高效的查询。其核心管线位于 packages/loot-core/src/server/aql 目录:
- schema(schema/index.ts):定义各表字段、类型、引用关系,以及表视图(
tableViews)的构建逻辑——在 schema/index.ts 中可以看到视图构建时会根据tableOptions.splits决定如何拼接拆分交易数据,默认splits为'inline'; - compiler(compiler.ts):将查询状态(filter、select、group、order 表达式)编译为 SQL 片段,操作符在这里转换为对应的 SQL 运算符;
- executors(schema/executors.ts):负责执行编译结果,其中
execTransactions根据splits选项分发到execTransactionsBasic(处理all/inline/none)或execTransactionsGrouped(处理grouped,对结果按 parent 分组并附加subtransactions); - exec(exec.ts):调用编译与执行入口
compileAndRunAqlQuery/runCompiledAqlQuery,并在 aql/index.ts 中对外暴露aqlQuery与aqlCompiledQuery。
也就是说,你写的q('transactions').filter({...}).select(...)会被编译成 SQL 执行,$oneof变成IN、$like变成UNICODE_LIKE、$or变成OR分支等,最终把 SQLite 的查询能力完整暴露给上层调用方。
六、总结与延伸阅读
ActualQL 把 Actual 应用内部的查询能力完整开放给了外部调用者:通过q构建器与链式方法组合筛选、选择、排序、分组、聚合与分页;通过 filter 操作符实现精确到字段的比较、正则与模糊匹配;通过splits选项精确控制拆分交易的返回形态。无论你是在做账单导入脚本、财务报表还是数据迁移,都可以复用 Actual 应用本身同款的能力。
延伸阅读:
- Transaction 字段参考(拆分交易结构说明):查看
transactions表各字段的完整定义与拆分交易创建规则; - API 总览:了解
runQuery/aqlQuery之外的全部 API 方法; - API 查询构建器实现:
Query类各链式方法的源码; - 核心查询引擎:
aqlQuery编译与执行入口; - 拆分交易执行器测试:
splits各选项行为的具体测试用例。
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考