Actual 的 ActualQL 查询语言完全指南:从基础查询到拆分交易与操作符实战
2026/9/11 22:01:06 网站建设 项目流程

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的所有交易的iddateamount字段。

值得强调的是,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 中可以看到,runQueryaqlQuery都是把查询序列化后通过消息通道发送给核心引擎:

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采用不可变链式风格,每次调用都返回携带新状态的新实例。除了文档提到的filterselectoptions,它还提供了大量可用方法:

方法作用
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的默认初始化:tableOptionsfilterExpressionsselectExpressionsgroupExpressionsorderExpressions默认为空,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-012021-12-31之间。

$and 与 $or:组合多条独立条件

$and$or接收条件数组并合并多个条件。例如获取多个日期的交易:

q('transactions') .filter({ $or: [{ date: '2021-01-01' }, { date: '2021-01-02' }], }) .select('*');

上述查询会返回2021-01-012021-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):idaccountcategoryamount(整数,单位分)、payeenotesdateimported_iderrorimported_payeestarting_balance_flagtransfer_idsort_orderclearedreconciledtombstonescheduleraw_synced_data。日期类字段使用'YYYY-MM-DD'字符串格式。

四、处理拆分交易(Split Transactions)

拆分交易会让聚合与选择变得复杂:当对交易金额求和时,是统计所有子交易,还是只用顶层交易?选择交易时,你想要哪些记录?

transactions表为此提供了两种不同的数据接口,通过options传入splits选项进行配置:

q('transactions').select('*').options({ splits: 'inline' });

inline(默认值)

inline是默认行为,不会返回拆分交易的 "parent" 交易,只返回子交易,结果是一个扁平数组。这样默认求和时就自然忽略了 "parent" 交易,避免重复统计金额。

grouped

grouped总是返回完整的拆分交易(parent + 全部子交易),无论命中筛选条件的是哪一部分。返回的数据是分组的,交易带有一个subtransactions属性列出其子交易。

allnone

文档脚注还提到第三种选项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 目录:

  1. schema(schema/index.ts):定义各表字段、类型、引用关系,以及表视图(tableViews)的构建逻辑——在 schema/index.ts 中可以看到视图构建时会根据tableOptions.splits决定如何拼接拆分交易数据,默认splits'inline'
  2. compiler(compiler.ts):将查询状态(filter、select、group、order 表达式)编译为 SQL 片段,操作符在这里转换为对应的 SQL 运算符;
  3. executors(schema/executors.ts):负责执行编译结果,其中execTransactions根据splits选项分发到execTransactionsBasic(处理all/inline/none)或execTransactionsGrouped(处理grouped,对结果按 parent 分组并附加subtransactions);
  4. exec(exec.ts):调用编译与执行入口compileAndRunAqlQuery/runCompiledAqlQuery,并在 aql/index.ts 中对外暴露aqlQueryaqlCompiledQuery

也就是说,你写的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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询