马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:SQLite 嵌入式数据库实战全解 —— uo_sqlite3 直连与 uo_database 两条路线(PB10 基准 · PB12.5 实测)
阅读说明
- 适用版本:PowerBuilder 10 及以上(本文以 PB10 语法为基准写作,PB12.5 实机验证,21 组用例两版读数逐字一致);使用 PBIDEA 组件(websuite.pbl、sql.pbl,PbIdea.dll 运行库)
- 支持数据库:SQLite(本文主角,单文件嵌入式数据库,不需要安装任何数据库服务器);不涉及 SQL Server 等网络数据库
- 操作系统与环境要求:Windows 7 及以上;程序目录下需要 pbidea.dll 和 sqlite3.dll(SQLite 路线的必需件,缺了它两条路线都连不上库)
- 难度系数:★★☆☆☆
- 其它阅读说明:示例不需要预置任何数据,运行时自动生成 .db 文件;读过上篇《PBIDEA:用 uo_database 与 uo_database_pool 做数据库连接与连接池复用》会更顺,但不读也不影响本文
PBIDEA 里的 SQLite 有两条使用路线:一条是 uo_database 把 DBMS 属性设成 SQLite,走完整的数据访问对象(能绑定参数、能读结果集);另一条是 uo_sqlite3 直连对象(只有 open 和 exec,没有读取通道)。两条路线我都在 PB10 和 PB12.5 上跑了 21 组用例,本文把每条 API 的真实行为、包括那些和直觉相反的部分,一条条摆出来。
一、SQLite 是什么,什么时候用它
SQLite 是一个嵌在进程里的数据库:没有服务、没有端口、没有账号,整个库就是磁盘上的一个文件。程序里 open 一下文件名就能建表写数,把这个文件拷到别的机器上,数据也跟着走。
它适合的场景:单机配置存储、日志归档、导入导出的中间文件、演示程序的数据源。它不适合的场景:多用户并发写、需要网络访问的场合——这些还是交给 SQL Server 这类服务端数据库。
在 PBIDEA 里,日常读写推荐走路线 A(uo_database),uo_sqlite3 只在「一次性建库灌数」这种场合有优势,原因见第九节的落盘规则实测。
二、环境准备:sqlite3.dll 是第一个坑
两条路线的底层最终都要落到 sqlite3.dll 上。这个 DLL 在 PBIDEA 的发布包里有(sharedll 目录),部署时必须让它和 pbidea.dll 待在一起,或者放进系统 PATH。
缺了它是什么表现?两条路线完全不一样,这是我实测的第一组读数:
- 路线 A(uo_database):Open() 返回 false,sqldbcode = 3,sqlerrtext 里写着「sqlite3.dll: 找不到指定的模块」——好歹告诉你缺什么。
- 路线 B(uo_sqlite3):open() 静默返回 false,SqlCode = 0,SqlErrText 是空的——什么都不说。
所以用路线 B 时如果 open 老是 false,第一件事不是查代码,是查 sqlite3.dll 在不在。
三、路线 A:用 uo_database 连 SQLite
连接就四个属性,比连 SQL Server 简单得多(不需要 ServerName,不需要 DBParm 里的 Provider):
- // 输入值:库文件名(相对路径,落在程序运行目录;不存在会自动新建)
- string ls_db
- ls_db = 'demo71.db'
- uo_database db
- db = create uo_database
- db.DBMS = 'SQLite' // 固定写 SQLite,注意大小写照抄
- db.Database = ls_db // 库文件路径
- db.LogId = '' // SQLite 没有用户名,留空
- db.LogPass = '' // 没有密码,留空
- db.DBParm = '' // 不需要任何附加参数
- if not db.Open() then
- MessageBox('连接失败', db.sqlerrtext + ' (' + String(db.sqldbcode) + ')')
- return
- end if
- MessageBox('连接成功', 'IsOpen=' + String(db.IsOpen()) + ' IsAlive=' + String(db.IsAlive()))
复制代码
实测读数:Open 返回 true、sqlcode=0、sqldbcode=0、sqlerrtext=success,IsOpen 和 IsAlive 都是 true。还有一个细节:Open 成功的那一刻,磁盘上的库文件就已经创建了(实测文件立刻出现;写了数据之后是 8192 字节,SQLite 的页大小)。
四、建表、ExecuteScaler 与「防炸」写法
建表走 ExecuteSQL,它返回一个 uo_recordset。对 DDL 语句这个返回值没有内容,但不能不接——接了之后判断 IsValid、有效就关掉,这是标准的防炸写法:
- // 接上例的 db。建表:如果表已存在会报错,演示前先换新文件名
- uo_recordset rs
- rs = db.ExecuteSQL('create table emp(id integer primary key, nm varchar(20), sal double)')
- if IsValid(rs) then
- rs.Close()
- destroy rs
- end if
- // 单值查询用 ExecuteScaler,直接拿第一行第一列
- any la_cnt
- la_cnt = db.ExecuteScaler('select count(*) as cnt from emp')
- MessageBox('行数', String(la_cnt)) // 空表读数:0
复制代码
这里有个实测到的错误处理细节,值得单独记:如果 SQL 引用了不存在的表,ExecuteSQL 仍然返回一个 IsValid 为 true 的对象,但它是个空壳——HasResultSet() 返回 false、RowsAffected() 返回 -1,真正的错误在连接对象的三个属性里:sqlcode=-1、sqldbcode=1、sqlerrtext='no such table: xxx'。判成败要同时看 IsValid(rs) 和 db.sqlcode,只看前者会漏。
五、参数化写入:占位符是 :1,不是问号
和 uo_database 连 SQL Server 一样,SQLite 路线的占位符也是 :1、:2 这种形式,写成问号是会被拒的(这是 PBIDEA 数据库对象的统一约定,不是 SQLite 的规矩)。绑定参数从 1 开始数:
- // 接上例。插入一行,包含文本、数值和 NULL
- uo_recordset rs
- string ls_null
- double ld_sal
- rs = db.PrepareSQL('insert into emp(nm, sal, memo) values(:1, :2, :3)')
- rs.BindParam(1, '张三') // 文本直接传
- ld_sal = 3000.5
- rs.BindParam(2, ld_sal) // 数值用 double 传
- SetNull(ls_null)
- rs.BindParam(3, ls_null) // NULL:先 SetNull 再绑定
- if not rs.ExecuteSQL() then
- MessageBox('写入失败', db.sqlerrtext)
- end if
- rs.Close()
- destroy rs
- db.CommitTrans() // ★ 见第七节:不提交会丢数据
复制代码
三个 BindParam 全部返回 true,ExecuteSQL 返回 true,计数读数 1。中文、小数、NULL 三种值我都在两版上做了往返验证,读回什么就是什么:中文原样回来,NULL 读回来还是 NULL(GetString 返回空、JSON 序列化时是 null),double 精度不丢。
六、读取:列是从 1 开始数的
读取的完整链路是 ExecuteSQL 拿结果集、GetFieldCount 数列、GetField 拿列元数据、FetchNext 逐行走、GetString 或 GetNumber 按列名取值。最大的坑在这里:GetField 的下标是 1 基。我第一版探针按 0 基写,GetField(0) 返回了一个空对象,紧接着调 GetName() 直接崩了运行时(Null object reference)——编译期一点征兆都没有。按 1 基改过来之后一切正常:
- // 接上例。读列元数据 + 逐行取值
- uo_recordset rs
- uo_field fld
- long i
- rs = db.ExecuteSQL('select * from emp order by id')
- MessageBox('列数', String(rs.GetFieldCount()))
- i = 1 // ★ 从 1 开始,不是 0
- do while i <= rs.GetFieldCount()
- fld = rs.GetField(i)
- MessageBox('列 ' + String(i), fld.GetName())
- destroy fld
- i = i + 1
- loop
- do while rs.FetchNext()
- MessageBox('员工', rs.GetString('nm') + ' ' + String(rs.GetNumber('sal')))
- loop
- rs.Close()
- destroy rs
复制代码
实测中四列的元数据读数是:id 的 GetType=20、nm 和 memo 的 GetType=6、sal 的 GetType=4。GetType 的类型码是 SQLite 内部映射出来的(整数、文本、浮点各一个码),可以用来区分列的性质;但 GetSize 的读数和建表时写的列宽对不上(varchar(20) 的列 GetSize 得 5),别拿它当列宽用。
另一个好用的东西是 FetchJsons,一次把结果集转成 JSON。它有两个实测规则:
- 必须在新查询上调用。结果集被 FetchNext 循环读完之后再调 FetchJsons,什么都拿不到(实测返回 0)——要重新 ExecuteSQL 一次。
- 无参版把整个结果集打包成数组的第一个元素:返回值是 1(JSON 个数,不是行数),对第一个元素调 ToString() 出来的是一行完整的 JSON 数组串,所有行都在里面,NULL 列显示为 null。
- // FetchJsons 要点演示:必须是刚拿到的结果集
- uo_json ljs[]
- long n
- rs = db.ExecuteSQL('select * from emp order by id') // 新查询
- n = rs.FetchJsons(ljs)
- MessageBox('JSON 个数', String(n)) // 读数:1
- MessageBox('内容', ljs[1].ToString()) // 整个结果集的 JSON 数组串
- rs.Close()
- destroy rs
复制代码
七、事务语义:不 CommitTrans,关连接就是丢
这是全文最重要的一条实测结论。插入数据之后不调用 CommitTrans 直接关连接,重新打开一数——0 行。SQLite 路线没有任何自动提交,每一条 insert 都攥在未提交事务里,close 即作废:
- 插入后不提交、关闭、重开:读数 0 行(两版一致)。
- 插入后 RollbackTrans 再查:0 行。
- 插入后 CommitTrans 再查:1 行。
所以路线 A 的标准节奏是「建表或写入 → CommitTrans → 再查」。查询不需要提交,但任何 DML 之后想让它落盘,CommitTrans 一句都不能省。
八、路线 A 的错误处理小结
把实测到的错误行为归拢成一张表:
| 场景 | 表现 | | 缺 sqlite3.dll | Open=false,sqldbcode=3,sqlerrtext 指名缺模块 | | SQL 引用不存在的表 | ExecuteSQL 返回 IsValid=true 的空壳,HasResultSet=false、RowsAffected=-1;错误在 sqlcode=-1、sqldbcode=1、sqlerrtext | | SQL 语法错误 | PrepareSQL 返回 NULL(IsValid=false),sqlerrtext 带原生文案,如 near "selec": syntax error | | BindParam 位置越界 | 返回 false,不崩 |
九、路线 B:uo_sqlite3 的落盘规则(本篇最重的实测)
uo_sqlite3 全部公开成员就这些:SetDataDirectory、open 四个重载(无参、单参文件名、两参文件名加整数)、close、exec、CommitTrans、RollbackTrans、CreateFunction、ImportCSV,加上两个实例变量 SqlCode、SqlErrText 和三个事务常量。就这些,没有任何能读出数据的方法——exec 对 SELECT 也返回 true,但结果你拿不到。所以路线 B 天生只能干写,不能读。
写也分「真写」和「假写」。我做了 open 返回值与落盘的完整矩阵(每个格子都是独立用例实测,两版一致):
| 调用方式 | 文件不存在时 | 文件已有数据(路线 A 建的) | 文件已有数据(自己 open(文件名, 0) 建的) | | open() 无参 | true,纯内存库 | 不适用 | 不适用 | | open(文件名) 单参 | true,但写入不落盘,文件停在 0 字节 | true | false | | open(文件名, 0) | true,建库并落盘(8192 字节) | false | false(重开也不行) | | open(文件名, 1) 或 (文件名, 2) | false | false | 未测 |
翻译成人话:
- 路线 B 唯一能落盘的姿势:对一个不存在的文件名调 open(文件名, 0),建表、写数、close。这一条链路实测数据完整落盘,之后路线 A 随便读写这个文件(实测读回计数正确)。
- 单参 open 看着成功,建出来的文件永远是 0 字节,数据全在内存里,close 就没了——我在三组独立用例里都复现了这一点。
- 库一旦有数据,uo_sqlite3 自己反而打不开了(两参各种值都 false,单参对自己的产物也是 false)。想继续用这个库,换路线 A。
- 还有一个实例级的暗坑:先调 close 再首调 open 的实例,open 永远返回 false,整个实例报废(实测 close 在前 open 恒 false,重开也救不回来)。新实例永远先 open。
所以路线 B 的正确定位是「一次性建库工具」,示例:
- // 特殊说明:open 第二个参数必须是 0,且目标文件必须不存在,否则返回 false
- string ls_db
- boolean lb
- ls_db = 'archive71.db'
- uo_sqlite3 sq
- sq = create uo_sqlite3
- lb = sq.open(ls_db, 0)
- if not lb then
- // 注意:失败时 SqlCode 可能还是 0、SqlErrText 还是空,别指望错误信息
- MessageBox('建库失败', 'open 返回 false,先检查文件是否已存在')
- return
- end if
- sq.exec('create table log71(id integer, msg varchar(100))')
- sq.exec('insert into log71 values(1, ' + Char(39) + 'boot' + Char(39) + ')')
- sq.close()
- destroy sq
- MessageBox('完成', ls_db + ' 已生成,之后用 uo_database 读写')
复制代码
顺带说 SetDataDirectory:它改变 open 里相对路径的基准目录。实测 SetDataDirectory('sub71') 之后 open('z.db'),文件落在 sub71 子目录下(程序目录没有、子目录有);SetDataDirectory('') 之后恢复到程序运行目录。想让 .db 都进数据子目录,用它就行。
十、ImportCSV 与 CreateFunction:两个「看起来能用」的方法
这两个方法我专门做了验证,结论都比较扫兴,如实记录:
ImportCSV(内容, 表名) 静默不可用。 我试了四种形态:带表头的 CRLF 内容、LF 内容、无表头内容、CSV 文件路径,四种全部返回 false,而且 SqlCode=0、SqlErrText 依然是初始化的「ok」——一个字的错误原因都不给。导入之后用路线 A 去查,表都不存在。在这版组件上别指望它,CSV 入库请走路线 A 的参数化插入自己拼。
CreateFunction(函数名) 返回 true,注册动作本身成功。但注册了 PB 函数之后,在 SQL 里调用它是否真的会回调 PB 侧,路线 B 没有任何读取通道可以取证(exec 的 SELECT 拿不到结果),这一段我标注为未实测、仅静态核对,不作为核心示例。
十一、路线 B 的错误通道:SqlCode 和 SqlErrText
路线 B 唯一的错误信息来源是 SqlCode 和 SqlErrText 两个实例变量。实测表现:
- 语法错误:exec 返回 false,SqlCode=1,SqlErrText 是 SQLite 原生文案(如 near "selec": syntax error),定位很准。
- 缺表:SqlCode=1,SqlErrText 形如 no such table: xxx。
- 未 open 就 exec:返回 false,但 SqlCode=0、SqlErrText 空——干失败,不说话。
- open 失败的各种情况:同样 SqlCode=0、SqlErrText 空。
规律很明显:凡是「SQLite 引擎层」的错误都有清楚的文案;凡是「组件层」的失败(没 open、open 不上、ImportCSV 被拒)一律静默。排错时先分清楚错误出在哪一层。
十二、PB12.5 差异
21 组用例在 PB10 与 PB12.5 上各跑一遍,读数逐字一致:连接属性、建表、参数化、NULL 往返、事务语义、GetField 1 基、FetchJsons 打包规则、路线 B 的整个落盘矩阵、ImportCSV 的静默失败,全部无差异。本文所有结论两版通用。
十三、坑清单汇总
- 缺 sqlite3.dll:路线 A 报 sqldbcode=3 指名道姓,路线 B 静默 false 连错误都不给——先查 DLL 再查代码。
- 路线 A 连 SQLite 不要 ServerName、不要 DBParm,LogId、LogPass 留空即可。
- Open 成功即建文件;写入后 8192 字节是正常页大小。
- DDL 的 ExecuteSQL 返回值要接住、判 IsValid 后关闭,别裸调。
- 引用缺表时 ExecuteSQL 返回的是「IsValid 的空壳」,判错要加看 db.sqlcode。
- 占位符是 :1、:2,不是问号;BindParam 从 1 开始。
- NULL 绑定:SetNull 之后 BindParam,读回仍可判 IsNull。
- GetField 下标 1 基,用 0 号下标会拿到空对象,紧接着 GetName 即崩运行时,编译不报错。
- GetSize 的读数和列宽对不上,别当列宽用。
- FetchJsons 必须在新查询上调,读完的结果集再调拿不到东西;无参版整个结果集打包在第一个元素里,返回值是 JSON 个数不是行数。
- ★ 不 CommitTrans 就关连接,数据全丢;SQLite 路线没有自动提交。
- ★ 路线 B 单参、无参 open 都不落盘;唯一落盘姿势是 open(不存在的文件名, 0) 一次成型。
- ★ 有数据的库 uo_sqlite3 自己打不开(连自己建的也是),继续用请换路线 A。
- ★ 先 close 后 open 的 uo_sqlite3 实例报废,open 恒 false。
- ImportCSV 四种形态全部静默 false 且不报错,这版别用。
- 路线 B 的 SqlCode、SqlErrText 只管引擎层错误,组件层的失败一律静默。
十四、两条路线怎么选
一句话:读写都走路线 A,路线 B 只用来一次性建库灌数。路线 A 有参数绑定、有结果集、有 JSON 输出、错误信息完整;路线 B 的价值只剩「open 一个新文件名、exec 灌一批数据、close」,比如初始化一个归档库。反过来,如果你只需要一个内存库做临时计算(open() 无参那种),路线 B 反而方便——反正不落盘,用完就没了。
完整示例(连接、建表、参数化、提交、查询)打包在附件的 PBL 里,PB10 与 PB12.5 双版本编译验证过,见文后回帖。
十五、实机验证情况
- PB10:22 个对象导入 0 错误;全量编译 0 错误;21 组用例运行全部 PASS。
- PB12.5:同上,且与 PB10 读数逐字一致。
- 未实测段落:CreateFunction 注册后的实际回调行为(路线 B 无读取通道无法取证);单参 open 打开路线 A 所建库之后的写入是否落盘(该组合行为不稳定,不建议依赖)。其余结论均为双轨实测。
|