在嵌入式应用或小型管理系统中,业务表单的字段经常随需求变动。如果使用固定的表结构,每次加一个输入框就要改库表,非常麻烦。SQLite作为文件型数据库,本身不支持复杂的在线变更机制,因此更需要一种灵活的存储思路来承载动态表单。

一、为什么固定表结构不适合动态表单
假设我们有一个客户信息收集表单,最初只要姓名和电话。建表语句可能是create table form_a(name text, phone text)。过了两周,产品经理想要增加性别、年龄、籍贯,就得执行alter table form_a add column gender text等语句。在SQLite里,ALTER TABLE虽然可用,但字段一多,历史数据迁移、索引重建和代码层实体类修改都会带来额外工作量。
更麻烦的是,有些表单是用户自己设计的,比如企业内部流程系统允许管理员拖拽生成表单。这时候字段名、类型、是否必填全都不确定,写死表结构根本不可行。我们需要把“表单长什么样”和“表单数据是什么”分开存储,这正是动态表单字段存储要解决的问题。
二、基于EAV模型的表设计
EAV全称Entity-Attribute-Value,即实体、属性、值。在SQLite中,我们通常用三张表实现:一张存表单实例(实体),一张存字段定义(属性元数据),一张存具体值(值)。为了简洁,也可以合并实体与表单类型。下面给出一个最小可用的 schema。
-- 表单定义表:描述有多少个表单模板 create table form_meta ( id integer primary key, form_name text not null ); -- 字段定义表:每个表单包含哪些字段 create table field_meta ( id integer primary key, form_id integer not null, field_key text not null, field_label text not null, field_type text not null, is_required integer default 0, foreign key(form_id) references form_meta(id) ); -- 数据值表:一行就是一个字段的具体值 create table form_value ( id integer primary key, form_id integer not null, instance_id integer not null, field_id integer not null, field_value text, foreign key(form_id) references form_meta(id), foreign key(field_id) references field_meta(id) );
上面的field_type可以是text、number、date等,用来在应用层做校验。instance_id标识同一次提交的表单记录,比如用户张三填了一次,所有字段值的instance_id相同。这样新增字段时,只需往field_meta插一条记录,再在form_value里写对应值,完全不用改表。
这种设计的优势是极度灵活,缺点是正查(按字段名取数)需要行转列。不过SQLite支持窗口函数和CTE,处理起来并不难。另外,值统一存成text,应用读取时要按field_type转换,这一点在代码层封装好即可。
三、写入动态表单数据示例
假设表单id为1,包含姓名(field_id=1)、电话(field_id=2)。用户提交时,后端收到一个键值对,循环插入即可。下面用Python伪代码展示写入逻辑。
import sqlite3
def save_form(conn, form_id, fields_dict):
cur = conn.cursor()
# 先拿一个实例号
cur.execute("select coalesce(max(instance_id),0)+1 from form_value where form_id=?", (form_id,))
instance_id = cur.fetchone()[0]
for field_key, val in fields_dict.items():
# 根据key找field_id
cur.execute("select id from field_meta where form_id=? and field_key=?", (form_id, field_key))
row = cur.fetchone()
if not row:
raise ValueError("字段不存在: " + field_key)
field_id = row[0]
cur.execute(
"insert into form_value(form_id,instance_id,field_id,field_value) values(?,?,?,?)",
(form_id, instance_id, field_id, str(val))
)
conn.commit()
return instance_id
# 调用示例
conn = sqlite3.connect("demo.db")
save_form(conn, 1, {"name": "李四", "phone": "13800000000"})
这段代码先生成实例编号,再遍历传入的字典,把每个字段转成一行记录。由于字段定义与数据分离,哪怕明天加了个“邮箱”字段,只要field_meta里有,上层传参加一对键值就能存,不用碰表结构。
需要注意,写入前应校验is_required和field_type,比如必填字段为空要拦截,数字类型要用int()或float()试转。这些校验建议放在应用服务层,数据库只做最终落地。
四、查询时如何行转列
存的时候是一行一个值,读的时候业务通常要宽表形式。可以利用SQLite的CASE WHEN做透视。下面按instance_id聚合,把姓名和电话翻成列。
select instance_id, max(case when fm.field_key='name' then fv.field_value end) as name, max(case when fm.field_key='phone' then fv.field_value end) as phone from form_value fv join field_meta fm on fv.field_id = fm.id where fv.form_id = 1 group by fv.instance_id;
如果字段很多,手写CASE太累,可以在应用里先查field_meta列表,再拼动态SQL。SQLite的group by配合max聚合在EAV查询里是常见套路,性能在单表几万行内完全够用。
对于超大数据量,可以考虑额外写一张物化视图表,定时把热门表单展平,但这已超出轻量场景。多数SQLite动态表单项目,靠上面的查询就能满足日常。
五、动态表单的优缺点与适用边界
使用SQLite加EAV来做动态表单,最大好处是零停机加字段、代码改动小,非常适合单机工具、移动端本地库、中小型后台。它把“结构”变成“数据”,让非开发人员也能通过界面配表单。
但也要清楚,EAV不是银弹。因为所有值都是text,没法用数据库层约束类型;联表多,复杂报表查询写起来绕;索引只能建在field_id、instance_id上,精细查询不如宽表。所以如果表单极其固定、并发很高,还是普通表更合适。理解边界,才能把SQLite动态表单用在正确的地方。