30 原始 SQL 查询与聚合统计:querySql 实战

30 原始 SQL 查询与聚合统计:querySql 实战

前言

图:30 原始 SQL 查询与聚合统计:querySql 实战 运行效果截图(HarmonyOS NEXT)

虽然 RdbPredicates 覆盖了大部分查询场景,但当需要聚合统计 (COUNT、SUM、AVG)、GROUP BY 分组SQL 函数JOIN 多表关联 时,直接使用原始 SQL 更为简单高效。鸿蒙 RDB 提供了 store.querySql(sql, bindArgs) 方法支持原始 SQL 执行。

本文以"鹿鹿·笔迹心理分析"项目中 HandwritingDao 和 ReportDao 的多个聚合查询方法为例,深入解析 querySql 的使用模式。

鸿蒙官方·RdbStore SQL 执行:developer.huawei.com

项目源码仓库:harmony-app GitHub

图:querySql 的四大使用场景------聚合统计、GROUP BY、JOIN、SQL 函数
#mermaid-svg-87tX4km5ZIzn7xHR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-87tX4km5ZIzn7xHR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-87tX4km5ZIzn7xHR .error-icon{fill:#552222;}#mermaid-svg-87tX4km5ZIzn7xHR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-87tX4km5ZIzn7xHR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-87tX4km5ZIzn7xHR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-87tX4km5ZIzn7xHR .marker.cross{stroke:#333333;}#mermaid-svg-87tX4km5ZIzn7xHR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-87tX4km5ZIzn7xHR p{margin:0;}#mermaid-svg-87tX4km5ZIzn7xHR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-87tX4km5ZIzn7xHR .cluster-label text{fill:#333;}#mermaid-svg-87tX4km5ZIzn7xHR .cluster-label span{color:#333;}#mermaid-svg-87tX4km5ZIzn7xHR .cluster-label span p{background-color:transparent;}#mermaid-svg-87tX4km5ZIzn7xHR .label text,#mermaid-svg-87tX4km5ZIzn7xHR span{fill:#333;color:#333;}#mermaid-svg-87tX4km5ZIzn7xHR .node rect,#mermaid-svg-87tX4km5ZIzn7xHR .node circle,#mermaid-svg-87tX4km5ZIzn7xHR .node ellipse,#mermaid-svg-87tX4km5ZIzn7xHR .node polygon,#mermaid-svg-87tX4km5ZIzn7xHR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-87tX4km5ZIzn7xHR .rough-node .label text,#mermaid-svg-87tX4km5ZIzn7xHR .node .label text,#mermaid-svg-87tX4km5ZIzn7xHR .image-shape .label,#mermaid-svg-87tX4km5ZIzn7xHR .icon-shape .label{text-anchor:middle;}#mermaid-svg-87tX4km5ZIzn7xHR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-87tX4km5ZIzn7xHR .rough-node .label,#mermaid-svg-87tX4km5ZIzn7xHR .node .label,#mermaid-svg-87tX4km5ZIzn7xHR .image-shape .label,#mermaid-svg-87tX4km5ZIzn7xHR .icon-shape .label{text-align:center;}#mermaid-svg-87tX4km5ZIzn7xHR .node.clickable{cursor:pointer;}#mermaid-svg-87tX4km5ZIzn7xHR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-87tX4km5ZIzn7xHR .arrowheadPath{fill:#333333;}#mermaid-svg-87tX4km5ZIzn7xHR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-87tX4km5ZIzn7xHR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-87tX4km5ZIzn7xHR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-87tX4km5ZIzn7xHR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-87tX4km5ZIzn7xHR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-87tX4km5ZIzn7xHR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-87tX4km5ZIzn7xHR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-87tX4km5ZIzn7xHR .cluster text{fill:#333;}#mermaid-svg-87tX4km5ZIzn7xHR .cluster span{color:#333;}#mermaid-svg-87tX4km5ZIzn7xHR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-87tX4km5ZIzn7xHR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-87tX4km5ZIzn7xHR rect.text{fill:none;stroke-width:0;}#mermaid-svg-87tX4km5ZIzn7xHR .icon-shape,#mermaid-svg-87tX4km5ZIzn7xHR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-87tX4km5ZIzn7xHR .icon-shape p,#mermaid-svg-87tX4km5ZIzn7xHR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-87tX4km5ZIzn7xHR .icon-shape .label rect,#mermaid-svg-87tX4km5ZIzn7xHR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-87tX4km5ZIzn7xHR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-87tX4km5ZIzn7xHR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-87tX4km5ZIzn7xHR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} store.querySql
聚合统计

COUNT / SUM / AVG
分组查询

GROUP BY + HAVING
多表联接

LEFT JOIN / INNER JOIN
SQL 函数

strftime / substr / COALESCE
参数化绑定

防止 SQL 注入

一、querySql 基础

1.1 方法签名

typescript 复制代码
querySql(sql: string, bindArgs?: Array<number | string | boolean>): Promise<ResultSet>
参数 类型 说明
sql string 完整的 SQL 查询语句
bindArgs `Array<number string

1.2 最简单的查询

typescript 复制代码
// SELECT * + 全部返回
const rs = await store.querySql(`SELECT COUNT(*) AS cnt FROM handwriting`)

// 参数化查询(使用 ? 占位符)
const rs = await store.querySql(
  'SELECT COUNT(*) AS cnt FROM handwriting WHERE archive_id = ?',
  [archiveId]
)

安全提示 :始终使用 ? 参数化绑定,不要拼接字符串。querySql 的第二个参数 bindArgs 会自动处理转义,防止 SQL 注入。

二、COUNT 计数查询

2.1 简单计数

typescript 复制代码
// HandwritingDao.countAll
static async countAll(): Promise<number> {
  const store = HandwritingDao.getStore()
  const rs = await store.querySql(`SELECT COUNT(*) AS cnt FROM ${TABLE}`)
  let cnt = 0
  if (rs.goToFirstRow()) {
    cnt = rs.getLong(rs.getColumnIndex('cnt'))
  }
  rs.close()
  return cnt
}

2.2 带条件的计数

typescript 复制代码
// HandwritingDao.countByArchive
static async countByArchive(archiveId: number): Promise<number> {
  const store = HandwritingDao.getStore()
  const rs = await store.querySql(
    `SELECT COUNT(*) AS cnt FROM ${TABLE} WHERE archive_id = ?`,
    [archiveId]
  )
  let cnt = 0
  if (rs.goToFirstRow()) {
    cnt = rs.getLong(rs.getColumnIndex('cnt'))
  }
  rs.close()
  return cnt
}

2.3 去重计数

typescript 复制代码
// HandwritingDao.countDistinctActiveDays
// 统计有记录的不同天数
static async countDistinctActiveDays(): Promise<number> {
  const store = HandwritingDao.getStore()
  const rs = await store.querySql(
    `SELECT COUNT(DISTINCT (created_at / 86400000)) AS cnt FROM ${TABLE}`
  )
  let cnt = 0
  if (rs.goToFirstRow()) {
    cnt = rs.getLong(rs.getColumnIndex('cnt'))
  }
  rs.close()
  return cnt
}

去重技巧created_at 是毫秒时间戳,除以 86400000(一天的毫秒数)后,同一天的所有记录映射为相同的整数,COUNT(DISTINCT ...) 汇总出实际有记录的天数

三、LIMIT 限制

3.1 带 LIMIT 的查询

typescript 复制代码
// HandwritingDao.listRecent
static async listRecent(limit: number): Promise<HandwritingEntity[]> {
  const store = HandwritingDao.getStore()
  const rs = await store.querySql(
    `SELECT * FROM ${TABLE} ORDER BY created_at DESC LIMIT ?`,
    [limit]      // limit 作为参数化值
  )
  const result: HandwritingEntity[] = []
  while (rs.goToNextRow()) {
    result.push(HandwritingDao.parseRow(rs))
  }
  rs.close()
  return result
}

SQL 语句生成示例:

typescript 复制代码
// 如果 limit = 5
// → SELECT * FROM handwriting ORDER BY created_at DESC LIMIT 5
// 返回最近 5 条笔迹记录

3.2 带条件 + 排序 + LIMIT

typescript 复制代码
// ReportDao.findLatestByArchive
static async findLatestByArchive(archiveId: number): Promise<ReportEntity | null> {
  const store = ReportDao.getStore()
  const rs = await store.querySql(
    `SELECT * FROM ${TABLE} WHERE archive_id = ?
     ORDER BY created_at DESC LIMIT 1`,
    [archiveId]
  )
  let result: ReportEntity | null = null
  if (rs.goToFirstRow()) {
    result = ReportDao.parseRow(rs)
  }
  rs.close()
  return result
}

四、聚合查询汇总

4.1 项目中使用的聚合 SQL

DAO SQL 用途 参数
HandwritingDao COUNT(*) FROM ${TABLE} 总笔迹数
HandwritingDao COUNT(*) FROM ${TABLE} WHERE archive_id = ? 某档案笔迹数 archiveId
HandwritingDao COUNT(DISTINCT (created_at / 86400000)) 活跃天数
ReportDao COUNT(*) FROM ${TABLE} 总报告数
ReportDao SELECT * ... WHERE archive_id = ? ORDER BY created_at DESC LIMIT 1 最新报告 archiveId

4.2 扩展:更多聚合模式

typescript 复制代码
// 场景 1:AVG 平均能量值(未来可能需要)
const rs = await store.querySql(
  'SELECT AVG(energy_score) AS avg_energy FROM handwriting WHERE archive_id = ?',
  [archiveId]
)

// 场景 2:GROUP BY 分组统计
const rs = await store.querySql(
  'SELECT source, COUNT(*) AS cnt FROM handwriting GROUP BY source'
)

// 场景 3:MAX/MIN 数值范围
const rs = await store.querySql(
  'SELECT MAX(word_count) AS max_words, MIN(word_count) AS min_words FROM handwriting'
)

// 场景 4:JOIN 两表(笔迹+报告)
const rs = await store.querySql(
  `SELECT h.id, h.ocr_text, r.personality_type, r.radar_json
   FROM handwriting h
   JOIN report r ON h.id = r.handwriting_id
   WHERE h.archive_id = ?
   ORDER BY h.created_at DESC`,
  [archiveId]
)

五、querySql vs query(Predicates) 对比

5.1 选择策略

场景 使用 理由
等值查询 + 排序 RdbPredicates 类型安全,可读性好
聚合函数 querySql RdbPredicates 不支持
LIMIT 限制 querySql RdbPredicates.limit() 可用
JOIN 多表 querySql 唯一选择
简单查询 RdbPredicates 代码更简洁

5.2 同一个查询的两种写法

typescript 复制代码
// 写法 A:RdbPredicates(推荐)
const predicates = new relationalStore.RdbPredicates(TABLE)
predicates.equalTo('archive_id', archiveId)
predicates.orderByDesc('created_at')
const rs = await store.query(predicates, COLS)

// 写法 B:querySql(等效)
const rs = await store.querySql(
  `SELECT * FROM ${TABLE} WHERE archive_id = ? ORDER BY created_at DESC`,
  [archiveId]
)

5.3 代码量对比

查询类型 RdbPredicates(行数) querySql(行数)
简单等值 3 行 1 行(但 SQL 字符串长)
排序+条件 4 行 1 行(SQL 字符串更长)
COUNT ❌ 不支持 ✅ 1 行
JOIN ❌ 不支持 ✅ 1 行

六、SQL 安全性

6.1 参数化绑定

typescript 复制代码
// ✅ 安全:参数化绑定
await store.querySql(
  'SELECT * FROM handwriting WHERE archive_id = ?',
  [archiveId]
)

// ❌ 危险:字符串拼接
await store.querySql(
  `SELECT * FROM handwriting WHERE archive_id = ${archiveId}`  // 注入风险
)

6.2 表名与列名拼接注意事项

typescript 复制代码
// ⚠️ 表名和列名不能参数化,需要直接拼接
const TABLE = 'handwriting'
const sql = `SELECT * FROM ${TABLE} ORDER BY created_at DESC LIMIT ?`

// ⚠️ 但是值必须参数化
// ✅ `${TABLE}`(表名固定,安全)
// ✅ `?`(值可变,参数化安全)
// ❌ `${archiveId}`(用户输入,危险)

七、querySql 返回结果的解析

7.1 聚合结果的解析

typescript 复制代码
// 聚合查询的 ResultSet 解析
const rs = await store.querySql(
  'SELECT COUNT(*) AS cnt FROM handwriting'
)

// 聚合结果只有 1 行
let cnt = 0
if (rs.goToFirstRow()) {
  cnt = rs.getLong(rs.getColumnIndex('cnt'))  // 通过别名获取
}
rs.close()
return cnt

7.2 聚合字段的命名

typescript 复制代码
// 聚合字段推荐使用 AS 别名
// 便于在代码中通过 getColumnIndex('alias') 读取
'SELECT COUNT(*) AS cnt ...'          → getColumnIndex('cnt')
'SELECT AVG(energy_score) AS avg ...' → getColumnIndex('avg')
'SELECT MAX(word_count) AS max ...'   → getColumnIndex('max')

八、模板字符串的合理使用

typescript 复制代码
// ✅ 在 DAO 中使用 ${TABLE} 常量(项目做法)
const TABLE = 'handwriting'
const rs = await store.querySql(
  `SELECT COUNT(*) AS cnt FROM ${TABLE}`
)

// ⚠️ 注意:${TABLE} 是常量,不是用户输入,所以安全
// 如果表名需要动态传入,应做好白名单校验

总结

本文完整解析了鸿蒙 RDB 中原始 SQL 查询的使用方法:

  1. querySql(sql, args):支持完整的 SQL 语法,参数化绑定防注入
  2. COUNT 计数COUNT(*)、条件计数、COUNT(DISTINCT ...) 去重
  3. LIMIT 限制 :配合 ORDER BY DESC 获取最新 N 条
  4. 聚合扩展:AVG、GROUP BY、MAX/MIN、JOIN 的 SQL 模板
  5. 安全原则 :值用 ? 参数化,表名/列名用常量
  6. 与 RdbPredicates 互补:简单查询用 Predicates,复杂统计用 querySql

下一篇文章将深入 数据更新与级联操作------ArchiveDao 中 delete 时的级联清理。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:


七、高级 SQL 技巧:窗口函数与 CTE

鸿蒙 RDB 基于 SQLite 3.35+,支持 窗口函数公共表表达式(CTE),适用于更复杂的统计场景。

7.1 窗口函数:排名统计

sql 复制代码
-- 按日期对情绪分数排名(使用窗口函数)
SELECT 
  created_at,
  score,
  RANK() OVER (ORDER BY score DESC) as score_rank
FROM handwriting
WHERE user_id = ?
typescript 复制代码
const sql = `
  SELECT created_at, score,
         RANK() OVER (ORDER BY score DESC) as score_rank
  FROM handwriting WHERE user_id = ?`
const rs = await store.querySql(sql, [userId])

7.2 CTE:递归层级查询

sql 复制代码
-- 使用 CTE 简化复杂统计
WITH monthly_stats AS (
  SELECT strftime('%Y-%m', created_at) as month,
         COUNT(*) as total,
         AVG(score) as avg_score
  FROM handwriting
  WHERE user_id = ?
  GROUP BY month
)
SELECT * FROM monthly_stats ORDER BY month DESC

7.3 JSON 字段的 SQL 查询

对于存储为 JSON 的字段,可以使用 SQLite 的 json_extract 函数:

typescript 复制代码
// 查询 keywords JSON 数组中包含特定关键词的记录
const sql = `
  SELECT * FROM report 
  WHERE json_extract(keywords, '$[0]') = ? 
     OR json_extract(keywords, '$[1]') = ?`
const rs = await store.querySql(sql, [keyword, keyword])

7.4 SQL 注入防护原则

操作 安全做法 危险做法
参数绑定 querySql(sql, [param]) \WHERE id = ${id}``
动态表名 使用白名单校验 直接拼接用户输入
LIKE 通配 LIKE ? + '%' + val + '%' LIKE '%${val}%'

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

相关推荐
砍材农夫2 小时前
spring|spring event|Spring内置事件驱动编程模型
java·数据库·spring
星释11 小时前
鸿蒙智能体开发实战:23.常见问题
服务器·华为·ai·harmonyos·鸿蒙·智能体
子非鱼942711 小时前
HarmonyOS律愈实战09:HarmonyOS Form播放器卡片实现
华为·harmonyos
tiancaijiben11 小时前
阿里云SSL证书自动续签与部署完全指南:从托管服务到开源工具全方案解析
数据库
全堆鸿蒙11 小时前
25 数据库设计与表结构规划:5 张表的设计思路
数据库·华为·cocoa·harmonyos
笃行35011 小时前
【金仓数据库产品体验官】_坏得起_实测:备份恢复、PITR 时间穿越与主备切换——KingbaseES V9R3C18 深度体验(三)
数据库
listening77712 小时前
HarmonyOS 6.1 全场景开发终篇:从单设备到万物互联的实战复盘与架构思考
华为·架构·harmonyos
喜欢的名字被抢了13 小时前
Python实战:SQLAlchemy ORM与FastAPI项目集成
开发语言·python·sql·教程·fastapi
笃行35013 小时前
从兼容到进阶:驱动直连、窗口函数与动态 SQL 实测——KingbaseES V9R3C18 深度体验(二)
数据库