【仓颉语言入门 · 第二篇 第 10 课】
上一课我们学会了 SELECT,把
sys_user里的 9 行用户数据读了个遍。增删改查里,读之外最高频的就是写——往数据库里塞数据。这一课只做三件事:发出第一条 INSERT、用set()给 SQL 里的?占位符填值、把数据库自动编的自增主键拿回来。
目录(第二篇导航)
第一篇《仓颉语言入门》共 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 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、先在客户端里把 INSERT 试明白
数据库开发的好习惯:先把 SQL 在客户端里试对,再搬进代码。第 8 课建的练习表 sys_login_log 已经随练习删掉了,先重建它:
USE tumo_governance;
CREATE TABLE IF NOT EXISTS sys_login_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
login_ip VARCHAR(50),
login_time DATETIME,
success INT NOT NULL DEFAULT 1
);
然后插一条字面量进去:
INSERT INTO sys_login_log (user_id, login_ip, login_time, success)
VALUES (1900010622019957256, '127.0.0.1', '2026-07-30 22:08:44', 1);
用 SELECT 看一眼结果:
SELECT * FROM sys_login_log;
+----+---------------------+-----------+---------------------+---------+
| id | user_id | login_ip | login_time | success |
+----+---------------------+-----------+---------------------+---------+
| 1 | 1900010622019957256 | 127.0.0.1 | 2026-07-30 22:08:44 | 1 |
+----+---------------------+-----------+---------------------+---------+
四个要点(这就是 INSERT 的全部核心语法):
INSERT INTO 表名 (列清单):括号里列顺序你自己定,不一定要和CREATE TABLE里的顺序一致,但后面 VALUES 的值顺序必须和这里一一对应。VALUES (...):每个值按顺序填入列清单,字符串用单引号包住。id没写:因为它是AUTO_INCREMENT,数据库自己编了个1;下一行再插,自动变成2。DEFAULT 1没写:success列有DEFAULT 1,省略它就自动填 1。
试完在客户端里把它删掉,下面用仓颉重新插(不是偷懒,是确保程序跑在干净状态):
DELETE FROM sys_login_log;
注意:这里的
DELETE FROM只删数据,不删表。它让表回到刚建好时的空表状态,下一节的程序才能拿到lastInsertId = 1。
二、仓颉代码:第一条 INSERT(字面量直接写)
沿用 cj_mysql_demo 工程,把 src/main.cj 改成:
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("""
INSERT INTO sys_login_log (user_id, login_ip, login_time, success)
VALUES (1900010622019957256, '127.0.0.1', '2026-07-30 22:08:44', 1)
""")
let result = stmt.update()
println("插入完成,影响行数:${result.rowCount},自增主键:${result.lastInsertId}")
stmt.close()
conn.close()
0
}
运行输出:
插入完成,影响行数:1,自增主键:1
三个要点:
- DML 走
update():INSERT、UPDATE、DELETE 这些改数据的语句和建表一样,用update()发送;返回值是UpdateResult(第 8 课见过)。 rowCount数的是受影响的行:插入 1 行,它就是1;插入 0 行或失败时,异常早就被抛了,到不了println。lastInsertId是自增主键的值:AUTO_INCREMENT列数据库自动编的号,通过UpdateResult.lastInsertId原样拿回——这里返回1,表示数据库刚给这行分配的 id。
这样写没问题,但 SQL 里的值全拼死在字符串里了,换个人名就得改代码重编译。下一节用占位符把它解耦出来。
三、占位符:让 SQL 安全又灵活
真实项目里,插入的数据都是程序运行时才算出来的(表单输入、传感器读数、接口返回值……),不可能提前写死在 SQL 字符串里。直接把值拼进 SQL 字符串不仅麻烦,还有严重的安全隐患(下节课讲 SQL 注入时会细说)。正确做法是占位符:SQL 里写 ?,程序运行时用 set() 把值填进去。
把刚才的 INSERT 改成占位符写法:
let stmt = conn.prepareStatement(
"INSERT INTO sys_login_log (user_id, login_ip, login_time, success) VALUES (?, ?, ?, ?)"
)
stmt.set(1, 1900010622019957256)
stmt.set(2, "127.0.0.1")
stmt.set(3, DateTime.of(year: 2026, month: 7, dayOfMonth: 30, hour: 22, minute: 8, second: 44))
stmt.set(4, 1)
let result = stmt.update()
println("插入完成,rowCount=${result.rowCount},lastInsertId=${result.lastInsertId}")
运行输出:
插入完成,rowCount=1,lastInsertId=2
为什么是 2?因为第一节和第二节已经各插过一行,表没删,id 接着编。如果你在客户端执行了 DELETE FROM sys_login_log;,则会变回 1——lastInsertId 只和当前表里的自增计数器有关。
四个要点:
?按顺序占位:SQL 里写几个?,后面就要set几个值;第一个?对应第 1 个set,第二个对应第 2 个,以此类推。set从 1 开始:和第 9 课的get(1)完全一致,从1计数。写set(0, ...)会抛IllegalArgumentException(等下第五节给你看)。- 类型自动匹配:
set是泛型函数,驱动会按你传入的值类型自动处理——Int64给整型、String给字符串、DateTime给时间列、Int32给普通整数。基本不需要写尖括号,和上一课的get<String>(1)那种显式写法不同。 - 列可以省略,走默认值:
success列有DEFAULT 1,如果你不确定要不要填,可以把它从 SQL 的列清单和VALUES里一起删掉,数据库会自动填 1。试试看:
let stmt = conn.prepareStatement(
"INSERT INTO sys_login_log (user_id, login_ip, login_time) VALUES (?, ?, ?)"
)
stmt.set(1, 1900010644348391560)
stmt.set(2, "192.168.1.23")
stmt.set(3, DateTime.now())
let result = stmt.update()
println("省略 success,rowCount=${result.rowCount},lastInsertId=${result.lastInsertId}")
运行输出:
省略 success,rowCount=1,lastInsertId=3
查询回去验证,这行的
success列确实是1(DEFAULT 生效)。
可空列想主动写 NULL,用 setNull(序号):
let stmt = conn.prepareStatement(
"INSERT INTO sys_login_log (user_id, login_ip, login_time) VALUES (?, ?, ?)"
)
stmt.set(1, 1900010653277109512)
stmt.setNull(2)
stmt.set(3, DateTime.now())
let result = stmt.update()
println("setNull ip,rowCount=${result.rowCount},lastInsertId=${result.lastInsertId}")
运行输出:
setNull ip,rowCount=1,lastInsertId=4
查询确认这行的 login_ip 是 NULL,和空字符串是两回事(第 9 课讲过)。
四、自增主键与 lastInsertId
练习表 5 列,但程序通常只关心 4 列——id 由数据库自动生成,不用管。插入后立刻读 lastInsertId,就能拿到数据库给这行分配的编号,后续用它关联、查询、打印都方便。
连插两条,看 lastInsertId 怎么长:
let stmt = conn.prepareStatement(
"INSERT INTO sys_login_log (user_id, login_ip) VALUES (?, ?)"
)
stmt.set(1, 1900010680132446728)
stmt.set(2, "172.16.0.2")
var result = stmt.update()
println("第一条 lastInsertId=${result.lastInsertId}")
stmt.set(1, 1900010722168889096)
stmt.set(2, "10.1.1.5")
result = stmt.update()
println("第二条 lastInsertId=${result.lastInsertId}")
运行输出(接续前面的 id):
第一条 lastInsertId=5
第二条 lastInsertId=6
同一个 stmt 复用时,每次 update() 后参数会被清空——所以第二条 insert 前必须重新 set,否则参数全变 NULL,往 NOT NULL 列插 NULL 会报错(下一节看真实报错)。
如果只发一条 SQL 同时插多行,lastInsertId 是最后一行的 id:
let stmt = conn.prepareStatement(
"INSERT INTO sys_login_log (user_id, login_ip) VALUES (?, ?), (?, ?)"
)
stmt.set(1, 1900010821105162120)
stmt.set(2, "10.1.1.6")
stmt.set(3, 1900010933547430664)
stmt.set(4, "10.1.1.7")
let result = stmt.update()
println("多行插入 rowCount=${result.rowCount},lastInsertId=${result.lastInsertId}")
运行输出:
多行插入 rowCount=2,lastInsertId=8
两行受影响,lastInsertId 是最后一行的编号(这里是 8)。rowCount 才是你判断"插了几行"的可靠依据。
注意:
lastInsertId只在插入目标表有AUTO_INCREMENT列时才有意义。如果表没有自增列(比如第 9 课的sys_user,它的id是程序自己算的分布式 ID),lastInsertId恒为0。
五、常见报错速览
插入阶段的报错集中在"值没给够"和"序号写错"两类,提前混个脸熟:
序号从 0 开始(和 get 一样,从 1 计数):
IllegalArgumentException: The parameter index must be greater than 0
NOT NULL 列没给值(两幅面孔):
如果列压根没出现在列清单里:
SqlException: Field 'user_id' doesn't have a default value, errorCode: 0
如果列清单里写了,但 set 之后 update 没传值(比如复用 stmt 忘了重新 set):
SqlException: Column 'user_id' cannot be null, errorCode: 0
两种说法不同,根因一样:程序没给值,数据库拒绝写入。解决思路也一样:检查 set 是否覆盖到每个 ?。
同一个 stmt 复用不重新 set:
stmt.set(1, 1001)
stmt.set(2, "ok")
stmt.update() // 第一次 OK
stmt.update() // 第二次:参数已被清空,全变 NULL
第二次执行会报 Column 'user_id' cannot be null。想要复用,每次 update() 前把参数重新 set 一遍。
六、常用 API 速查(插入篇)
| 任务 | 写法 |
|---|---|
| 执行 INSERT | conn.prepareStatement(sql).update() |
| 影响行数 | UpdateResult.rowCount |
| 取自增主键 | UpdateResult.lastInsertId(Int64) |
| 填占位符 | stmt.set(序号, 值),序号从 1 开始 |
| 填 NULL | stmt.setNull(序号) |
| 省略列走 DEFAULT | 列名和 ? 都不写该列 |
| 复用同一个 stmt | 每次 update() 前重新 set |
七、常见问题 FAQ
Q1:set 的序号从 0 开始还是从 1 开始?
从 1 开始,和第 9 课 get<T>(1) 的列序号规则完全一致。写 0 会抛 IllegalArgumentException。多占位符时序号连续递增即可。
Q2:只插入部分列,其余列会怎样? 三种结局:
- 该列有
DEFAULT(如success INT DEFAULT 1)→ 自动填默认值; - 该列允许空(没写
NOT NULL)→ 自动填NULL; - 该列
NOT NULL且没有 DEFAULT → 插入失败,抛SqlException。
Q3:lastInsertId 在表没有自增列时是什么?
恒为 0。它不是"插入后表里最大 id",而是"本次自增值"——没有自增列,就没有值可回传。
Q4:同一个 prepareStatement 能复用吗?
能,但每执行一次 update() 后内部参数会被清空。想再执行一次,必须重新 set 所有占位符。如果复用频繁,注意检查 set 是否遗漏。
八、课后练习
前 3 题必做,第 4 题选做。都在
tumo_governance库的sys_login_log表上操作,如果表已被删除,先用课文的 CREATE TABLE IF NOT EXISTS 重建。
第 1 题(必做)用占位符插入一条完整日志
在 cj_mysql_demo 工程里,用 4 个 ? 占位符插入一行完整数据(user_id 给 1900010933650395016,login_ip 给 "192.168.1.88",login_time 给 DateTime.now(),success 给 0),然后打印 rowCount 和 lastInsertId。
提示:SQL 为 INSERT INTO sys_login_log (user_id, login_ip, login_time, success) VALUES (?, ?, ?, ?);四个 stmt.set(...) 依次填值;success 给 0 表示"登录失败"。
期望输出(你的 lastInsertId 可能和示例不同,取决于表里已有几行,只要非 0 即可):
插入完成,rowCount=1,lastInsertId=9
第 2 题(必做)故意触发序号越界
写一条 INSERT INTO sys_login_log (user_id) VALUES (?),然后执行 stmt.set(0, 1001),运行确认报错,逐字读一遍异常信息。
提示:这道题的锻炼目标是"见过报错就会修"——以后代码里序号写错时,看到 The parameter index must be greater than 0 立刻知道该改序号。
期望输出:
IllegalArgumentException: The parameter index must be greater than 0
第 3 题(必做)验证 DEFAULT 生效
不插入 success 列(SQL 只写 user_id、login_ip、login_time 三列),运行后用 SELECT 查回这行数据,确认 success 的值是 1。
提示:查询部分直接搬第 9 课的写法:stmt.query() + while (result.next()) + get<Int32>(列序号);查 SELECT id, user_id, success FROM sys_login_log ORDER BY id DESC LIMIT 1 只取最新一行。
期望输出(id 号随环境不同):
id=10 uid=1900010933650395016 success=1
第 4 题(选做)插入一条 login_ip 为 NULL 的记录并验证
用 setNull(2) 把第二列(login_ip)置为 NULL,插入后用 SELECT 查回来,打印 id 和 ip,确认 ip 显示为 "NULL"。
提示:login_ip 是可空列,可用 getOrNull<String>(3) 取回,再用 ?? 兜底:let ip = result.getOrNull<String>(3) ?? "NULL";result.get<String>(3) 直取会抛 NoneValueException(回忆第 9 课)。
期望输出(id 号随环境不同):
id=11 ip=NULL
下节预告
插入是把数据写进去,下一件高频操作是把已有的数据改对或删掉。下一课 更新与删除:UPDATE / DELETE——学 WHERE 子句怎么精确命中目标行,避免"一改全表、一删全库"的悲剧;顺便补上 UPDATE ... SET 的占位符用法。
系列说明:本系列基于 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