在这里插入图片描述
在这里插入图片描述

实例:个人记账本(Ledger)|技术:SUM/COUNT 聚合、GROUP BY 统计、日期范围查询

一、从需求到 SQL:聚合查询的思维模型

上一篇文章我们看到了仪表盘页面上那些漂亮的大数字:本月支出、本月收入、总笔数、分类占比。这些数字不是前端算出来的,而是数据库算完、前端直接展示的。为什么要把计算下沉到 SQLite?三个理由:

  1. 性能:几万条流水在内存里遍历累加,遇到大数据量必然卡顿;而 SQLite 的聚合函数走索引扫描,毫秒级完成。
  2. 一致性:求和、分组逻辑集中在 SQL 里,前端多个页面复用同一个 DAO 方法,不会出现「这个页面算的跟那个页面不一样」的 bug。
  3. 简洁:前端只需要 await LedgerDao.summary(...) 拿一个对象,代码量最少。

本篇文章聚焦实例 3 最核心的四种聚合查询:区间汇总(SUM + CASE WHEN)、分类占比(GROUP BY)、月趋势(strftime)、笔数统计(COUNT)。学完这四种,你就掌握了报表类功能 80% 的 SQL 能力。

二、接口设计:三个显式返回类型

在写聚合方法之前,先定义返回类型。这是 ArkTS 的一个重要约束:不能用内联对象字面量作为函数返回类型(编译器报 arkts-no-obj-literals-as-types),必须显式声明 interface:

/** 区间汇总:总收入 / 总支出 / 笔数 */
export interface LedgerSummary {
  income: number;
  expense: number;
  count: number;
}

/** 分类统计条目 */
export interface CategoryStat {
  category: string;
  amount: number;
  count: number;
}

/** 月趋势条目 */
export interface MonthTrend {
  month: string;
  expense: number;
}

这三个接口分别对应页面的三块数据:仪表盘大数字、分类占比条、月趋势(本实例页面暂未展示趋势图,但接口已备好,读者可以自行扩展折线图)。接口用 export 导出,页面才能 import 使用——这是跨文件类型复用的标准姿势。

三、区间汇总:一条 SQL 出三个数

需求:本月总收入、总支出、总笔数。传统做法是三条查询或内存遍历,我们的做法是一条 SQL 全包:

static async summary(context: common.Context, start: number, end: number): Promise<LedgerSummary> {
  const store = await LedgerDao.getStore(context);
  const result = await store.querySql(
    `SELECT
       SUM(CASE WHEN type=1 THEN amount ELSE 0 END) AS income,
       SUM(CASE WHEN type=0 THEN amount ELSE 0 END) AS expense,
       COUNT(*) AS cnt
     FROM ${LedgerDao.TABLE}
     WHERE trade_time BETWEEN ${start} AND ${end}`
  );
  let income = 0, expense = 0, count = 0;
  if (result.goToNextRow()) {
    income = result.getDouble(result.getColumnIndex('income'));
    expense = result.getDouble(result.getColumnIndex('expense'));
    count = result.getLong(result.getColumnIndex('cnt'));
  }
  result.close();
  const s: LedgerSummary = { income: income, expense: expense, count: count };
  return s;
}

这条 SQL 有三个精妙之处:

精妙一:条件聚合 SUM(CASE WHEN type=1 THEN amount ELSE 0 END)。它把「收入求和」和「支出求和」合并进同一句 SQL——对每一行,先判断 type,是 1 就累加到收入,是 0 就累加到支出。数据库只需扫描一遍时间范围内的数据,同时产出两个数字。这正是字段设计时坚持 type 用 0/1 数字的根本原因:如果存的是 ‘支出’/‘收入’ 字符串,CASE WHEN 里就得写中文,既啰嗦又容易出错。

精妙二:COUNT(*) 与 SUM 平级。COUNT(*) 统计行数(即笔数),与两个 SUM 在同一个 SELECT 列表里,一次查询三个结果。COUNT(*)COUNT(column) 的区别值得说一句:COUNT(*) 统计所有行(含 NULL),COUNT(column) 只统计该列非 NULL 的行。流水表的列都有 NOT NULL 约束,两者结果一致,但 COUNT(*) 语义更明确。

精妙三:区间过滤走索引WHERE trade_time BETWEEN ${start} AND ${end} 命中了我们在建表时创建的 idx_ledger_time 索引。start/end 是毫秒时间戳,由页面层 monthRange() 传入,DAO 保持纯粹。

关于 ${start} 直接内联的安全性问题:这里把数字直接拼进 SQL 字符串,而不是用占位符。这是安全的——因为 start/end 来自 Date.getTime(),是我们自己计算出来的整数,不是用户输入,不存在 SQL 注入风险。但任何来自用户输入的值(如分类名、备注)都必须走 ValuesBucketRdbPredicates 的预处理机制,这条红线一定要守住。

读取结果时注意:result.goToNextRow() 移动到第一行——聚合查询永远只返回一行,所以只需判断一次。读完必须 result.close() 释放游标,这是避免资源泄漏的硬性要求。

四、分类占比:GROUP BY 的艺术

需求:钱都花到哪里去了?按分类分组统计支出金额和笔数,按金额降序:

static async categoryStats(context: common.Context, start: number, end: number): Promise<CategoryStat[]> {
  const store = await LedgerDao.getStore(context);
  const result = await store.querySql(
    `SELECT category, SUM(amount) AS amount, COUNT(*) AS cnt
     FROM ${LedgerDao.TABLE}
     WHERE type=0 AND trade_time BETWEEN ${start} AND ${end}
     GROUP BY category
     ORDER BY amount DESC`
  );
  const list: CategoryStat[] = [];
  while (result.goToNextRow()) {
    const item: CategoryStat = {
      category: result.getString(result.getColumnIndex('category')),
      amount: result.getDouble(result.getColumnIndex('amount')),
      count: result.getLong(result.getColumnIndex('cnt')),
    };
    list.push(item);
  }
  result.close();
  return list;
}

GROUP BY 的工作机制:SQLite 先把满足 WHERE 条件的行按 category 值分组——「餐饮」的归一堆,「交通」的归一堆——然后对每组执行 SUM(amount)COUNT(*)。结果是一行一个分类,天然去重。这正是我们 3-1 文章里「category 存文本、不建字典表」决策的回报:查询时零 JOIN,一个 GROUP BY 就得到了分类字典 + 统计。

WHERE type=0 的意义:占比统计只关心支出。如果不过滤,收入(工资、红包)也会被 GROUP BY 进结果,把「钱花到哪」的语义搞混。

ORDER BY amount DESC 的价值:让花钱最多的分类排第一。前端 ForEach 直接按返回顺序渲染,进度条天然「大头在上」,视觉上形成强烈的「钱都花在哪」的提示。把排序做进 SQL,而不是前端再 sort,是数据层的好习惯——SQL 返回即终态,前端零加工。

读取采用 while (result.goToNextRow()) 循环——GROUP BY 可能返回多行,逐行收集成数组。getColumnIndex 按别名取列,代码自文档化。

五、月趋势:strftime 时间格式化

需求:近 N 个月的支出走势,用于趋势折线图。这里遇到一个难题:流水表里存的是毫秒时间戳,怎么按「月」分组?

答案是 SQLite 内置的 strftime 函数:

static async monthlyTrend(context: common.Context, months: number): Promise<MonthTrend[]> {
  const store = await LedgerDao.getStore(context);
  const result = await store.querySql(
    `SELECT strftime('%Y-%m', trade_time/1000, 'unixepoch', 'localtime') AS month,
            SUM(CASE WHEN type=0 THEN amount ELSE 0 END) AS expense
     FROM ${LedgerDao.TABLE}
     GROUP BY month
     ORDER BY month DESC
     LIMIT ${months}`
  );
  const list: MonthTrend[] = [];
  while (result.goToNextRow()) {
    const item: MonthTrend = {
      month: result.getString(result.getColumnIndex('month')),
      expense: result.getDouble(result.getColumnIndex('expense')),
    };
    list.push(item);
  }
  result.close();
  return list;
}

strftime 的参数逐个拆解

参数 含义 为什么需要
'%Y-%m' 输出格式:年-月 得到 ‘2025-06’ 这样的月份标签
trade_time/1000 毫秒 → 秒 SQLite 时间函数接受秒级时间戳
'unixepoch' 输入是 Unix 时间戳 告诉 strftime 如何解释数值
'localtime' 转本地时区 避免 UTC 与东八区的日期偏移

完整效果:1750000000000(毫秒)→ 1750000000(秒)→ '2025-06'。这是本实例最亮眼的技巧——零第三方库,纯 SQLite 自带函数就完成了时间维度分组,如果在前端做,你得把每行的毫秒时间戳转 Date、手动截取年月、再手动分组,代码量和出错概率都翻倍。

GROUP BY month 的技巧:这里 GROUP BY 的是 SELECT 里的别名 month(即 strftime 的结果),而不是原始列。SQLite 允许 GROUP BY 引用 SELECT 别名,这比写一遍完整的 strftime 表达式简洁得多。这是 SQL 里「先计算、再分组」的经典模式。

LIMIT ${months}:只取最近 N 个月(如 6)。配合 ORDER BY month DESC(最新的月在前),拿到的是倒序趋势;前端若要画正序折线,reverse() 一下即可。

六、基础查询回顾:与聚合的协作关系

聚合查询不是孤立的,它建立在基础 CRUD 之上。回顾本实例 DAO 的基础查询:

/** 全部流水:时间倒序 */
static async queryAll(context: common.Context): Promise<LedgerRecord[]> {
  const store = await LedgerDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
  predicates.orderByDesc('trade_time');
  const result = await store.query(predicates);
  return LedgerDao.collect(result);
}

/** 按日期范围查询:start/end 毫秒时间戳 */
static async queryByRange(context: common.Context, start: number, end: number): Promise<LedgerRecord[]> {
  const store = await LedgerDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(LedgerDao.TABLE);
  predicates.between('trade_time', start, end).orderByDesc('trade_time');
  const result = await store.query(predicates);
  return LedgerDao.collect(result);
}

注意 queryByRangebetween('trade_time', start, end)——这是 RdbPredicates 的链式 API,等价于 WHERE trade_time BETWEEN start AND end基础查询用 RdbPredicates(防注入),聚合查询用 querySql(字符串 SQL),这是本实例 DAO 的混合策略:条件过滤用谓词对象,聚合计算用原生 SQL(因为 RdbPredicates 不提供 SUM/GROUP BY 能力)。两种方式各司其职。

七、collect 辅助方法:DRY 原则

每个查询方法都要「遍历 ResultSet → 逐行映射 → 关闭游标」,这段样板代码我们用私有方法收敛:

private static collect(result: relationalStore.ResultSet): LedgerRecord[] {
  const list: LedgerRecord[] = [];
  while (result.goToNextRow()) {
    list.push(LedgerDao.rowToRecord(result));
  }
  result.close();
  return list;
}

collect 接收 ResultSet,返回实体数组。rowToRecord 做单行映射。两个私有方法被所有查询复用,避免每个方法重复写 while 循环——这就是 DRY(Don’t Repeat Yourself)原则在 DAO 层的落地。聚合查询因为返回的是聚合结构(非实体),无法复用 collect,所以各自内联循环,这是合理的例外。

八、技术要点对照表

技术点 SQL 实现 页面用途 生产价值
双态汇总 SUM(CASE WHEN type=1...) 收入/支出大数字 一次扫描出两数
总笔数 COUNT(*) 「本月已记 N 笔」 与 SUM 平级共查
分类占比 GROUP BY category 占比进度条 天然去重 + 排序
月趋势 strftime('%Y-%m', ...) 折线图数据源 零依赖时间分组
区间过滤 between('trade_time', a, b) 本月范围 走时间索引
条件过滤 WHERE type=0 只看支出 语义精确

九、性能与安全注意事项

  1. 索引匹配:所有带 trade_time 条件的查询都命中 idx_ledger_time。生产环境可用 EXPLAIN QUERY PLAN 验证:
    EXPLAIN QUERY PLAN SELECT * FROM ledger WHERE trade_time BETWEEN 1 AND 2;
    
    输出应显示使用 idx_ledger_time,若显示全表扫描则索引没建对。
  2. 数字内联安全${start}${end}${months} 内联安全,因为它们来自代码计算;用户输入永不内联
  3. 游标释放:每个 ResultSet 用完必须 close(),聚合查询也不例外(虽然只返回一行)。
  4. getDouble 处理 NULLSUM() 在无匹配行时返回 NULL,getDouble 取到的是 0。我们在读取前先 let income = 0 初始化,即使 goToNextRow() 为 false 也有默认值,页面不会出现 NaN。

十、文章小结

本篇文章把实例 3 的四个聚合查询讲透了:SUM + CASE WHEN 双态汇总、COUNT(*) 笔数统计、GROUP BY 分类占比、strftime 月趋势。核心心法一句话:能下沉到 SQL 的计算绝不放前端——聚合是数据库的本职工作,前端只做展示。

下一篇(3-4)将展示 30 条跨月流水种子数据是怎么写进数据库的,以及仪表盘在真实数据下的效果——那是验证本篇文章所有 SQL 的试金石。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐