Day 5:FormSchema 如何驱动 SQL


昨天把定义文件逐一摊开,FormSchema 是其中的定义中枢。今天问一个具体的问题:一张表单的查询与保存,那几句 SQL 是从哪里来的。

本篇说明:

  1. 添加一张表单要动的六个地方(零行代码)
  2. 查询:JOIN 清单与翻页,都在每次请求当下决定
  3. 保存:字段集合怎么变成 INSERT / UPDATE / DELETE
  4. 它为什么不是 ORM,以及少掉的那些东西为何不是缺漏
  5. 一千份定义与一千个类的差别

一、多一张表单,要动六个地方

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 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

文章摘自:https://www.cnblogs.com/jeff-tsai/p/23503896/day-05-schema-drives-sql