【仓颉语言入门 · 第二篇 第 9 课】
上一课我们建表又删表,折腾的都是结构;而
sys_user里躺着的 9 行真实用户数据,一直等着被读。增删改查里,查 是最高频的一个,也是唯一"有去有回"的操作------发一句 SELECT 出去,服务器会回一张结果表回来。这一课就学会三件事:发出第一条 SELECT、用next()把数据一行行搬进仓颉程序、处理好"数据库里的 NULL 到了仓颉怎么办"。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~15 | 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 16~22 | 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池 |
| 四、实战项目篇 | 23~26 | HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历(本文)
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合
- 对象池与资源复用
- 实战三:HTTP 服务端基础
- 实战四:HTTP 进阶与 JSON 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、先在客户端里把 SQL 查明白
数据库开发有个好习惯:先把 SQL 在客户端里试对,再搬进代码。打开 MySQL 命令行客户端,三条查询快速过一遍。
第一条,查出所有用户的登录名和昵称------SELECT 后面写列名,FROM 后面写表名:
sql
USE tumo_governance;
SELECT user_name, nick_name FROM sys_user;
text
+-----------+--------------+
| user_name | nick_name |
+-----------+--------------+
| admin | 管理员 |
| zhangsan | 张三 |
| test | 测试用户 |
| lisi | 里斯 |
| wangwu | 王五 |
| sunqi | 孙七 |
| liang | 李昂 |
| hbwh | 湖北武汉 |
| zhangsan1 | 张三 |
+-----------+--------------+
第二条,加 WHERE 只看男性用户(sex = 1)------9 行变 8 行,test 被过滤掉了:
sql
SELECT user_name, sex FROM sys_user WHERE sex = 1;
text
+-----------+-----+
| user_name | sex |
+-----------+-----+
| admin | 1 |
| zhangsan | 1 |
| lisi | 1 |
| wangwu | 1 |
| sunqi | 1 |
| liang | 1 |
| hbwh | 1 |
| zhangsan1 | 1 |
+-----------+-----+
第三条,ORDER BY ... DESC 倒序排 + LIMIT 3 取前三名,看看"最近登录排行榜"的榜头:
sql
SELECT user_name, login_time FROM sys_user
WHERE login_time IS NOT NULL
ORDER BY login_time DESC
LIMIT 3;
text
+-----------+---------------------+
| user_name | login_time |
+-----------+---------------------+
| hbwh | 2026-07-30 22:08:44 |
| zhangsan1 | 2026-07-30 21:59:18 |
| admin | 2026-07-30 21:36:59 |
+-----------+---------------------+
注意 login_time 允许为空,没登录过的用户这一列是 NULL,所以用 WHERE login_time IS NOT NULL 先把他们请出排行榜(判断"是不是 NULL"要用 IS / IS NOT,不能写 = NULL)。
SELECT 的语法当然远不止这些,但本课只需要这三板斧:挑列、过滤、排序限量。SQL 试对了,下面把它们原样搬进仓颉。
二、仓颉代码:第一条 SELECT
沿用 cj_mysql_demo 工程,把 src/main.cj 改成:
cangjie
package cj_mysql_demo
import std.database.sql.*
import mariadb.cdbc;
main(): Int64 {
let driver = DriverManager.getDriver("mariadb").getOrThrow()
let ds = driver.open("mariadb://127.0.0.1:3306", [
("username", "root"),
("password", "wangpeng"),
("database", "tumo_governance")
])
let conn = ds.connect()
let stmt = conn.prepareStatement("SELECT user_name, nick_name FROM sys_user")
let result = stmt.query()
while (result.next()) {
let name = result.get<String>(1)
let nick = result.get<String>(2)
println("${name}(${nick})")
}
result.close()
stmt.close()
conn.close()
0
}
运行输出:
text
admin(管理员)
zhangsan(张三)
test(测试用户)
lisi(里斯)
wangwu(王五)
sunqi(孙七)
liang(李昂)
hbwh(湖北武汉)
zhangsan1(张三)
和客户端里看到的一模一样,只是现在数据进了我们的程序。四个要点:
query()与update()的分工 (第 8 课埋的伏笔):update()发给"没有结果表"的语句(DDL、INSERT 等),query()发给 SELECT------它返回一个QueryResult结果集,服务器查出来的那张表就在里面。- 结果集是单向游标 :
result刚拿到时停在第一行之前 ,next()让游标前进一行------有行就返回true,没行了就返回false。所以while (result.next())是标准遍历写法:循环体每执行一次,游标正好停在一行数据上;最后一行读完,next()返回false,循环自然结束。 get<T>(序号)按列取数 :序号是 SELECT 后面列的顺序,从 1 开始 (user_name是 1,nick_name是 2);尖括号里的T是"这一列在仓颉里算什么类型",下一节细讲。- 三个 close 一个不能少:结果集、语句、连接,用完从小到大依次还回去。
小提示 :编译时的
warning: unused import 'mariadb.cdbc'老规矩,忽略即可(驱动自注册,第 7 课 FAQ 讲过)。
三、按列取数:类型要对上
get<T> 的 T 不是随便写的------MySQL 列类型和仓颉类型有对应关系,第 8 课的类型表现在派上用场:
| MySQL 类型 | 仓颉类型(get 的泛型参数) |
|---|---|
INT |
Int32 |
BIGINT |
Int64 |
VARCHAR(n) |
String |
DATETIME / TIMESTAMP |
DateTime |
DECIMAL(m,n) |
Decimal |
对照 sys_user 的结构:id 是 bigint → Int64,user_name 是 varchar → String,sex 是 int → Int32。写个按类型取数的例子(WHERE 子句直接搬第一节试好的):
cangjie
let stmt = conn.prepareStatement("SELECT id, user_name, sex FROM sys_user WHERE sex = 1")
let result = stmt.query()
while (result.next()) {
let id = result.get<Int64>(1)
let name = result.get<String>(2)
let sex = result.get<Int32>(3)
println("id=${id} 姓名=${name} 性别=${sex}")
}
result.close()
stmt.close()
text
id=1900010622019957256 姓名=admin 性别=1
id=1900010644348391560 姓名=zhangsan 性别=1
id=1900010653277109512 姓名=lisi 性别=1
id=1900010680132446728 姓名=wangwu 性别=1
id=1900010722168889096 姓名=sunqi 性别=1
id=1900010821105162120 姓名=liang 性别=1
id=1900010933547430664 姓名=hbwh 性别=1
id=1900010933650395016 姓名=zhangsan1 性别=1
19 位的分布式 ID 原样取出,Int64 装得下。取错类型会怎样?两种结局:
get<String>最宽容 :就算列是bigint,驱动也会帮你转成字符串,get<String>(1)能拿到"1900010622019957256"。拿不准时先按 String 取来看看,是排障小技巧。- 数值类型量级不够会溢出 :
id用get<Int32>(1)取,程序当场抛OverflowException: The result would be greater than Int32.Max.------19 位的数塞不进 32 位。所以主键 ID 这类大数,老老实实用Int64。
还有一个高频场景:只想要一个数字,比如用户总数。COUNT(*) 的结果当作 Int64 取即可;聚合查询保证只返回一行,用 if 代替 while 更贴切:
cangjie
let stmt = conn.prepareStatement("SELECT COUNT(*) FROM sys_user")
let result = stmt.query()
if (result.next()) {
println("用户总数:${result.get<Int64>(1)}")
}
result.close()
stmt.close()
text
用户总数:9
四、数据库的 NULL:到了仓颉就是 Option
前三节取的列都有值,现在捅一下马蜂窝。avatar(头像)列允许为空,9 个用户里 8 个是 NULL------用 get<String> 直取看看:
cangjie
let stmt = conn.prepareStatement("SELECT user_name, avatar FROM sys_user")
let result = stmt.query()
while (result.next()) {
let name = result.get<String>(1)
let avatar = result.get<String>(2)
println("${name} 的头像:${avatar}")
}
result.close()
stmt.close()
text
admin 的头像:
An exception has occurred:
NoneValueException
at mariadb.cdbc::QueryResultImpl::get<...>(Int64)(...)
at cj_mysql_demo::main()(...)
第一行 admin 打印完(他的头像存的是空字符串 ,所以冒号后面什么都没有,但不算错),游标走到第二行 zhangsan------他的 avatar 是 NULL,get 当场抛 NoneValueException。
这正是第 9 课(第一篇)Option 的现实意义:数据库的 NULL 在仓颉里没有对应的基本值,驱动把它映射成 Option 的 None 。取可空列要用 getOrNull<T>,返回 ?T,再配 ?? 给个兜底值:
cangjie
while (result.next()) {
let name = result.get<String>(1)
let avatar = result.getOrNull<String>(2) ?? "(未设置)"
println("${name} 的头像:${avatar}")
}
text
admin 的头像:
zhangsan 的头像:(未设置)
test 的头像:(未设置)
lisi 的头像:(未设置)
wangwu 的头像:(未设置)
sunqi 的头像:(未设置)
liang 的头像:(未设置)
hbwh 的头像:(未设置)
zhangsan1 的头像:(未设置)
9 行全须全尾地出来了。注意第一行暴露了一个重要事实:NULL 和空字符串是两回事 ------admin 的头像是空字符串(get 正常取出 ""),其余 8 人才是真正的 NULL(走了 ?? 兜底)。数据库里这两者互不替代,程序里也走的是两条不同的代码路径。
经验法则一句话:DESC 表结构时 Null 列是 YES 的,一律 getOrNull;是 NO 的,才能放心 get。
五、结果集的"说明书":columnInfos
QueryResult 还随身带着每列的元数据,存在 columnInfos 属性里------每列一个 ColumnInfo,能读出列名(name)、仓颉侧类型名(typeName)、是否可空(nullable)等:
cangjie
let stmt = conn.prepareStatement("SELECT id, user_name, sex, login_time FROM sys_user")
let result = stmt.query()
for (info in result.columnInfos) {
println("列 ${info.name}:类型 ${info.typeName},允许空 ${info.nullable}")
}
result.close()
stmt.close()
text
列 id:类型 Int64,允许空 false
列 user_name:类型 String,允许空 true
列 sex:类型 Int32,允许空 false
列 login_time:类型 DateTime,允许空 true
这份"说明书"和第三节的对照表完全吻合。眼下它最大的用处是自查 :拿不准某列该用什么类型取,先把 columnInfos 打出来看一眼。等第 14 课手写 DAO 做通用映射时,它会成为主力工具。
六、常见报错速览
查询阶段的报错就这几类,提前混个脸熟:
表名写错 (和客户端报错同款,SqlException):
text
An exception has occurred:
SqlException: Table 'tumo_governance.sys_user_zzz' doesn't exist, errorCode: 0
列名写错:
text
An exception has occurred:
SqlException: Unknown column 'user_namee' in 'field list', errorCode: 0
列序号从 1 开始,写 0 会被拒:
text
An exception has occurred:
IllegalArgumentException: The index must be greater than 0
结果集 close 之后还取数:
text
An exception has occurred:
SqlException: The database Connection has been closed, errorCode: 0
前两类是 SQL 本身的问题(回客户端把语句试对),后两类是代码流程的问题(序号、close 顺序)。加上第四节的 NoneValueException 和第三节的 OverflowException,查询的坑基本齐了。
七、常用 API 速查(查询篇)
| 任务 | 写法 |
|---|---|
| 执行 SELECT | conn.prepareStatement(sql).query() |
| 前进到下一行 | result.next()(返回 false 表示读完) |
| 取非空列 | result.get<T>(列序号),序号从 1 开始 |
| 取可空列 | result.getOrNull<T>(列序号) ?? 默认值 |
| 查列元数据 | result.columnInfos(name / typeName / nullable) |
| 关闭结果集 | result.close() |
| 类型对照 | INT→Int32、BIGINT→Int64、VARCHAR→String、DATETIME/TIMESTAMP→DateTime |
八、常见问题 FAQ
Q1:SELECT * 一把梭不行吗?为什么课文坚持写列名? get 靠列序号 取数,而 * 的列序是跟着表结构走的------哪天表里加一列,你程序里所有序号集体错位,还未必报错。写明列名,顺序由你的 SELECT 说了算;再说只取需要的列,网络传输也省。客户端里随手 * 无所谓,进代码的 SQL 都该写列名。
Q2:next() 遍历到一半能倒回去、或者再遍历一遍吗? 不能。结果集是单向游标 ,只进不退。想再用一遍就重新 query() 一次;如果数据要反复用,常见做法是遍历的同时存进 ArrayList。
Q3:getOrNull 取出来的时间直接打印,为什么套着一层 Some(...)? 比如 Some(2026-07-30T21:36:59+08:00)------这是 Option 默认的打印格式,提醒你"这是个可能有也可能没有的值"。业务输出里别直接打印 Option,先 ?? 兜底或 isSome() 分支拆开再打印(第一篇第 9 课的 Option 操作,忘了可以回去翻翻)。里面时间的 T 和 +08:00 是 DateTime 的标准格式(ISO 8601,带时区),想换格式用第 2 课学的 DateTime 格式化。
Q4:查询结果一行都没有时,程序会怎样? 什么都不发生------第一次 next() 就返回 false,循环体一次都不执行,也不抛异常。"查到 0 行"不是错误,是正常结果,需要特殊提示的话自己在循环后判断。
九、课后练习
前 3 题必做,第 4 题选做。都在
tumo_governance库里操作,只读sys_user,一个字符都不许改。
第 1 题(必做)查出男性用户的邮箱
在 cj_mysql_demo 工程里查询 sys_user 中 sex = 1 的用户,逐行打印登录名和邮箱。
提示:SQL 为 SELECT user_name, email FROM sys_user WHERE sex = 1;两列都是 varchar,用 get<String>(1)、get<String>(2) 取。
期望输出:
text
admin 的邮箱:178421@163.com
zhangsan 的邮箱:178451@163.com
lisi 的邮箱:145165@163.com
wangwu 的邮箱:26151645@163.com
sunqi 的邮箱:178465@163.com
liang 的邮箱:123456@163.com
hbwh 的邮箱:178421@163.com
zhangsan1 的邮箱:145563.@com
第 2 题(必做)统计用户总数
用 SELECT COUNT(*) FROM sys_user 统计并打印用户总数。
提示:聚合查询只有一行,用 if (result.next());COUNT(*) 的结果用 get<Int64>(1) 取。
期望输出:
text
用户总数:9
第 3 题(必做)亲手踩一次 NoneValueException,再修好它
分两步:① 查询 user_name 和 login_time,循环里用 get<String>(2) 直取登录时间并打印,运行确认程序打印出 admin、zhangsan 两行后抛 NoneValueException;② 把直取改成下面的分支写法,确认 9 行全部打印。
提示:login_time 是时间类型,可空列用 getOrNull<DateTime>(2) 取(需要 import std.time.*);Option 用 isSome() 判断有值、getOrThrow() 取出。
cangjie
let t = result.getOrNull<DateTime>(2)
if (t.isSome()) {
println("${name} 最近登录:${t.getOrThrow()}")
} else {
println("${name} 从未登录")
}
期望输出(顺序即表中行的顺序):
text
admin 最近登录:2026-07-30T21:36:59+08:00
zhangsan 最近登录:2026-07-05T18:32:05+08:00
test 从未登录
lisi 从未登录
wangwu 最近登录:2026-07-30T18:00:44+08:00
sunqi 最近登录:2026-07-20T17:38:04+08:00
liang 最近登录:2026-07-20T17:39:07+08:00
hbwh 最近登录:2026-07-30T22:08:44+08:00
zhangsan1 最近登录:2026-07-30T21:59:18+08:00
第 4 题(选做)最近登录排行榜
打印"最近登录排行榜":只统计登录过的用户,按 login_time 从近到远排序,逐行输出名次。
提示:SQL 就用第一节客户端里试过的 WHERE login_time IS NOT NULL ORDER BY login_time DESC(去掉 LIMIT 取全部);另开一个 var rank = 1 计数器,每行打印后 rank++;login_time 已被 WHERE 排除空值,可以安全 get<DateTime>(2)。
期望输出:
text
第1名 hbwh(2026-07-30T22:08:44+08:00)
第2名 zhangsan1(2026-07-30T21:59:18+08:00)
第3名 admin(2026-07-30T21:36:59+08:00)
第4名 wangwu(2026-07-30T18:00:44+08:00)
第5名 liang(2026-07-20T17:39:07+08:00)
第6名 sunqi(2026-07-20T17:38:04+08:00)
第7名 zhangsan(2026-07-05T18:32:05+08:00)
下节预告
查了半天,sys_user 还是雷打不动的 9 行------我们只敢读,不敢写。下一课 插入:INSERT、占位符与自增主键 ------往自己建的练习表里写入第一行数据,学 set() 占位符参数怎么传、UpdateResult.lastInsertId 怎么把数据库自动编的主键拿回来。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 5.7 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信 直接咨询(请备注"仓颉课程"): 
离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE ------ CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载 :gitcode.com/wp_upala/ci...
- 打开页面后进入 发行版(Releases) ,两种包任选其一:
- 安装版 :下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable) :下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版 :下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:cangjie-lang.cn