

昨天把定义文件逐一摊开,FormSchema 是其中的定义中枢。今天问一个具体的问题:一张表单的查询与保存,那几句 SQL 是从哪里来的。
本篇说明:
- 添加一张表单要动的六个地方(零行代码)
- 查询:JOIN 清单与翻页,都在每次请求当下决定
- 保存:字段集合怎么变成 INSERT / UPDATE / DELETE
- 它为什么不是 ORM,以及少掉的那些东西为何不是缺漏
- 一千份定义与一千个类的差别
一、多一张表单,要动六个地方
Day 1 数的是「既有表单加一个字段」。这里数的是整张表单都是新的:
| 要动什么 | 动作 |
|---|---|
FormSchema |
添加一份:字段、关联、明细、规则 |
TableSchema |
每张物理数据表添加一份:字段、长度、索引 |
FormLayout |
添加一份:这些字段在界面上怎么排 |
DbCategorySettings |
加一行:这张表归哪一类、由哪一个数据库承载 |
ProgramSettings |
加一行:ProgId 与显示名称 |
MenuSettings |
加一项:用户从哪里点进来 |
三份新文件、三行添加、零行代码,主表配了明细就多一份 TableSchema。真正要从头写的只有 FormSchema,另外两份可以从它推导出来,再各自调整。案例八张表单里有七张就是这样长出来的。
六个地方没有一个是重述,各自回答不同问题:这张表单有什么、物理存储长什么样、界面上怎么排、归哪个数据库、谁来运行、从哪里点进去。
既然一行代码都没写,按下查询、按下保存跑的是什么?同一份 FormSchema 同时撑起两件事,用到的部分完全不同:
- 查询用的是关联声明,组出 JOIN
- 保存用的是字段集合,组出 INSERT / UPDATE / DELETE
二、查询:JOIN 是查出来的,不是写出来的
Day 3 讲过关联声明在表单之间,那份声明没有出现表名。框架拿它做的事很短:
RelationProgId(程序标识码)
→ 取那支程序的 FormSchema
→ 取出主表
→ 取出物理数据表名
→ JOIN 接在该表的 sys_rowid 上
多绕这一层的回报有两个。
一个在写的时候:声明的是「订单指向客户」,不是「ft_order 的某一列指到 ft_customer 的主键」。关联定在数据表之间时,写的人得先把业务上那个关系翻成存储层的样子;定在表单之间,写下来的就是脑子里的那件事,中间少一次翻译。
另一个在改的时候:物理表名只写在那张表单自己的结构里,别的表单指过来写的是 ProgId。客户那张表要改名,改的是客户那一份定义,所有指向它的表单都不必动。表名若逐处硬编码,这件事就是一次全局搜索,而且没有人能担保搜干净了。
JOIN 清单是每次现算的
订单清单显示六个字段,框架组出来的查询是:
SELECT A.[sys_id], A.[order_date],
B.[sys_name] AS [ref_customer_name], C.[sys_name] AS [ref_employee_name],
A.[status], A.[total_amount]
FROM [ft_order] A
LEFT JOIN [ft_customer] B ON A.[customer_rowid] = B.[sys_rowid]
LEFT JOIN [st_employee] C ON A.[employee_rowid] = C.[sys_rowid]
订单主表声明了三处关联,这一句只 JOIN 两张表。清单没有要显示货运商,那张表就不会进来。
规则只有一条:
框架先把这次查询会碰到的字段收成一份清单,来源有三个(要捞出来的字段、条件里出现的字段、排序用的字段);清单里出现了哪些关联列,就 JOIN 哪几张表。
所以「用到」不等于「显示」:
- 拿客户名称过滤清单 → 客户表为了 WHERE 被 JOIN 进来,即使显示列没有它
- 开单笔界面 → 三个 JOIN 都在,因为三列都要显示
- 只挑主表自己的字段 → 一个 JOIN 都没有
条件的值一律走参数,不进 SQL 字符串。
没有任何地方存着「订单要 JOIN 哪几张表」这个答案。代价是把「这句 SQL 长什么样」从构建期移到每一次请求,每次查询多一段组语句的工作。它是这条路线的固定开销,不是可以优化掉的东西。
同一段声明在界面那一侧也在做事:字段设了关联,界面上那一格就是开窗选取的编辑器,从来源那张表单挑一笔带回来。JOIN 读的声明和编辑器读的声明,是同一段。
显示列没有落在数据表里
上面那句 SQL 的 ref_customer_name 是一个别名,订单物理表只有 customer_rowid。
- 前端开窗挑完会把代号与名称写进手上那份数据,只为了界面立刻看得到
- 保存时这几列不进数据库,下次读取由服务器 JOIN 重新带出来
- 权威值永远在查询那一侧
于是不会有「客户改了名字,旧订单显示的还是旧名字」。代价是每次显示都得 JOIN 一次。
哪一种对,看这个字段要的是「当时的值」还是「现在的值」:显示用的名称要现在的,成交当下的单价要当时的,而后者本来就该是订单自己的字段。这条界线得由框架先画好,否则每张表单各自决定,同一个系统里会出现两种行为。
单阶声明,多阶运行
员工表单除了带出部门名称,还带出主管姓名。它的关联列指向部门,其中一条映射的来源列写的是 ref_manager_name,而那一列在部门表单上也是一个关联列(指向员工,谁是经理)。于是要往下再走一层:
FROM [st_employee] A
LEFT JOIN [st_department] B ON A.[dept_rowid] = B.[sys_rowid]
LEFT JOIN [st_employee] C ON B.[manager_rowid] = C.[sys_rowid]
st_employee 出现两次,A 是员工本人,C 是他的主管。
每张表单只声明自己直接指向的那一层,而那一层就是业务上原本的说法,写定义的人不必是程序开发人员也读得懂。视野固定是一层,框架沿着表单链递归展开,SQL 深度不设限。这在上千张表单的系统里是决定性的:关联会织成一张很大的图,但没有人需要在脑中装下它。
部门那一侧改了自己的关联,员工这张表单一个字都不必改,下一次查询自己就多接或少接一层。声明的人只描述业务上的那一层关系,接得多深、接几张表,是框架的事。
环状关联在这里不是问题
Day 3 留的那个环(部门 → 员工 → 部门),上面那段 SQL 已经走完了。
它不构成问题,因为查询是每次现算的。环在这里最多只剩一件事要决定:这一次要 JOIN 到第几层,而层数由请求的字段决定,不由模型的形状决定。没有人要主管姓名的时候,那一层根本不会出现。
换成构建期产生强类型类,这个环会变成类型彼此引用、初始化顺序怎么排的问题。同一个环,在运行时衍生的世界里只是一次查询的深度,在构建期产生的世界里是一个得先解掉的结构问题。
翻页也是每次现算的
翻到第几页、一页几笔、要不要算总笔数,都由调用端送上来,写在同一个对象上:
var result = bo.GetList(new GetListArgs
{
SelectFields = "sys_id,order_date,ref_customer_name",
SortFields = [new SortField("order_date", SortDirection.Desc)],
Paging = new PagingOptions { Page = 2, PageSize = 20 },
});
框架会先把这几个值整理过再用。页次与页大小夹在合理范围内,回来的 result.Paging 是真正生效的那一组。总笔数默认不算,多捞一笔就知道后面还有没有,要精确数字才多下一句 COUNT。分页一定要有排序,没指定就补上主表的流水号列,连那一列都没有才要求调用端自己讲清楚。
三、保存:字段集合就是增修删的语句
框架拿主表(以及每张明细)的字段集合现场生出一份数据表描述,增修删三句都由它产出。订单的 UPDATE:
Update [ft_order] Set
[sys_id]=@sys_id, [order_date]=@order_date, [customer_rowid]=@customer_rowid, ...
Where [sys_rowid]=@sys_rowid
这句 UPDATE 里有四件事:
- 流水号那一列不在里面,由数据库自己配
- 显示列也不在,前一节说过它们没有物理字段
- 条件固定是
sys_rowid,DELETE 同样如此 - 变更整行所有字段,不是只挑改动过的那几个
第四件用明细来看最清楚。一张单子有十行明细,用户改了一行、删掉一行,送出去的是整份数据,真正下到数据库的只有两句,其余八行连碰都不会碰。而那句 UPDATE 会把改过那一行的字段全部写一次,不是只写变动的那一列。
主表明细一起送进来时,所有变更包在同一个 transaction,而保存与删除的顺序刚好相反:
- 保存:主表先、明细后
- 删除:明细先、主表后
框架不需要知道这张单据在业务上是什么,只需要知道哪一张是主表、哪些是明细、明细靠哪一列挂回主表。
还有一件事,名字很容易误导:这三句都是从 FormSchema 生出来的,不是从 TableSchema 读出来的。TableSchema 管的是物理数据表怎么建、长度与索引怎么调、数据库怎么跟着定义升级,那是明天的题目。
四、它为什么不是 ORM
这一节不是在说 ORM 不好,也不是在比两者的优缺点。ORM 在它被设计的场景里运作良好,问题出在数量(Day 1 讲过)。
差别不在功能多寡,在映射的两端各是什么:
| ORM | 这套机制 | |
|---|---|---|
| 应用那一侧 | 类与属性 | 一份运行时的结构描述 |
| 数据库那一侧 | 数据表与字段 | 数据表与字段 |
起点不同,长出来的东西自然不同。少掉的有三样。
没有实体类
查询结果不会被还原成对象,数据以行的形式带着结构走。既然没有类,就没有「多一张表要多一个类」,Day 1 那个乘以一千的问题在这一层直接不成立。代价是编译期类型检查跟着没有了(Day 9 谈怎么补)。
没有以属性表达的关联,所以没有延迟加载
ORM 里「订单的客户的业务员」这样一路走下去的写法在这里没有对应物。要哪些字段在查询当下就得讲明,于是不会有「不小心多碰了一个属性、每一行各打一次数据库」的意外。反过来,临时想看一个没声明过的字段,得先去定义里加一条映射。
变更追踪没有不见,它换了位置
ORM 靠一个上下文对象握着加载过的实体,保存时比对哪些属性变了。这里不必比对:每一行自己就记着它是添加、改过、删掉还是没动过,改过的字段连旧值都留在上面。状态记在行上、没有记到字段,上一节那句 UPDATE 才会写整行。
这三件不是做一半,是这套机制里本来就没有它们的位置。另外还有几条限制,性质不一样:JOIN 一律是单字段等值、接的一定是物理数据表,子查询与非等值接法都不在里面。这些不是做不到,是这条路只管制式表单那一段。报表、统计、批量导入走另一轨,SQL 自己写(Day 11 展开)。
五、一千份定义与一千个类
上千张业务表在对象映射模式下就是上千个类,外加各自的 DTO、映射设置与 Repository。这些要编译、要维护、要随数据库结构同步,而同步没有工具能完全代劳,因为它牵涉到「这个字段在业务上是什么」。
同样上千张表,在 Definition-Driven 下是上千份同构的数据。差别不在数量,在谁能整批处理它们。
一份定义是结构化数据,直接后果有三个:
- 批量脚本扫得动:「所有金额字段的小数位数是不是都设对了」是一次全局搜索加一次判断
- 工具产得出来:
TableSchema与FormLayout都是从FormSchema推导出来的,那是一段转换程序,不是一次代码产生再加一次编译 - AI 接得上:要它照既有十份的体例补出第十一份,它读的是一份已经把关联与规则声明好的文档,不是一堆要从代码推论意图的类
检查的时机也可以往前挪。一千份定义一样会写错(指到不存在的表单、映射到不存在的字段、规则里打错字段名),差别在这些是构建期用程序就扫得出来的错,而框架也确实做了一部分(Day 9 会谈)。
一千份错的定义跟一千个错的类一样糟,定义不会因为它是数据就自动正确。换到的是修正的方式:改一份数据,而不是改一段代码再走一次编译、打包与部署。
定义也没有离开原本那套工程管理。它们跟代码放在同一个源代码树里,一样进版本控制、一样看得到每次改了哪几行、一样要在合并之前经过审阅。改的方式变了,管的方式没有。
还有一项不在成本表上:一千份定义的体例是同一套。看得懂一张表单怎么声明关联就看得懂全部,接手的人不必为了每个模块各摸一遍当年那位工程师的习惯。在维护期十年二十年的系统上,人换过几轮、模块也加过几批,这一份工每次都得再付一次,维护成本就是这样叠上去的。
六、回到订单那张表单
案例里最复杂的订单,在定义上是一份 Order.FormSchema.xml:主表配一张明细、主表三处关联、明细每行一处、三条验证规则与一个计算列(这两样怎么求值是 Day 8 的题目)。多重关联、主表明细、明细逐行开窗选取,全部声明出来,没有一行 UI 或 CRUD 代码。
而它同时是那张需要写程序的表单。那个 Repository 存在的理由只有两句 SQL(读数据库里的订单状态、查本月最大单号),声明表达不了就自己写。制式的那一段由定义驱动,剩下那两句不必因为框架而绕路,继承一个 Repository、补上去就是了。
一张表单能不能不写程序,不取决于框架有多聪明,取决于它有没有把「每一张表单都一样的那部分」认出来、收敛成同一套机制。查询要 JOIN 哪些表、保存要送哪几句,正好都落在那一部分里面。
明天谈 TableSchema:FormSchema 怎么变成它,以及物理数据库怎么安全地跟着定义升级。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计
文章摘自:https://www.cnblogs.com/jeff-tsai/p/23503896/day-05-schema-drives-sql
