Appearance
10 · 元数据驱动的动态写入/读取(persist-meta 规范)
版本:1.8.0(起,1.8.0 同步演进) 定位:字段元数据(storageType)驱动的执行规范——复杂字段插入(对象/JSON/子表) 与流程回显读取成为通用能力,不再绑定任何框架的低代码设施。 与
DynamicTableWriter(spec 09)的关系:演进而非推翻——现有 SPI 保留;未提供元数据时的 缺省档仍是回落表结构探测(行为与 1.6.x 一致),另许可"未命中即失败"档,两档见 §3。
1. 目标与边界
做(最小闭环)
- 字段元数据(storageType)驱动的动态写入(NORMAL/EXPAND/JSON/ONE2ONE/ONE2MANY)
- 按流程实例的动态读取回显(
readByProcessInstance,与写入共用同一份元数据) - 元数据来源可插拔(SPI),内置 JSON 配置加载器(零依赖)
不做(明确边界)
通用条件分页 / 动态条件语法(m_EQ_xxx)/ 数据权限 / 排序——集成方查询体系(如 mldong tablePage) 的领域,双方共享字段元数据规范保持语义一致。
2. 元数据模型(八语言契约)
落地范围(2026-10 现读):Java/Go/Python/Node/MoonBit/C# 六栈按本节模型落地;PHP 元数据腿未落地 (
IDynamicMetaProvider/MetaTableWriter全仓零命中,读侧仅门面错误文案引用)、 Rust 为异构许可档(以元数据取代表结构探测;未命中即失败档见 §3)——发布形态见 §7。
2.1 JSON 配置字段语义(persist-meta/biz_leave.json,文件名=表名)
顶层:
| 字段 | 类型 | 缺省 | 语义 |
|---|---|---|---|
tableName | string | 必填 | 业务表名(应与此 JSON 文件名一致) |
primaryKey | string | id | 主键列名(ONE2ONE/ONE2MANY 子表外键缺省回落它) |
fields | array | 必填 | 字段定义列表(见下) |
fields[] 元素:
| 字段 | 类型 | 缺省 | 语义 |
|---|---|---|---|
name | string | 必填 | 表单字段名(实例 Variables 中 f_ 去前缀后的名字,如 f_title → title) |
columnName | string | name 转下划线 | 主表列名(如 companyName → company_name) |
storageType | string | number | NORMAL | 存储类型——名称或数字双解析(见下表) |
expandFields | object | 无 | EXPAND 专用:子字段名 → 表列名映射({ "province": "province", "detail": "detail_addr" }) |
targetTable | string | 无 | ONE2ONE/ONE2MANY 专用:子表表名 |
foreignKey | string | 主表 primaryKey 列名 | ONE2ONE/ONE2MANY 专用:子表外键列 |
storageType 取值(对齐 mldong dev_schema_field 1-5,名称/数字双解析):
| 值 | 名称 | 语义 |
|---|---|---|
| 1 | NORMAL | 直写列(默认) |
| 2 | EXPAND | 对象展开为多列(expandFields 定义子字段列映射) |
| 3 | JSON | 对象/数组序列化为 JSON 串写列 |
| 4 | ONE2ONE | 子表单条递归插入(外键 = 主表主键,同事务) |
| 5 | ONE2MANY | 子表多条递归插入(data 值为数组,同事务) |
JSON 配置示例(persist-meta/biz_leave.json):
json
{
"tableName": "biz_leave",
"primaryKey": "id",
"fields": [
{ "name": "companyName", "columnName": "company_name", "storageType": "NORMAL" },
{ "name": "address", "storageType": "EXPAND",
"expandFields": { "province": "province", "city": "city", "detail": "detail_addr" } },
{ "name": "extra", "storageType": "JSON" },
{ "name": "items", "storageType": "ONE2MANY",
"targetTable": "biz_leave_item", "foreignKey": "leave_id" }
]
}3. SPI(集成方只实现一件事:提供元数据)
java
public interface IDynamicMetaProvider {
/** 加载表元数据;未定义返回 null(缺省档:回落表结构探测,全 NORMAL 语义;另许可档见下) */
TableMeta loadTableMeta(String tableName);
}- 内置
JsonMetaProvider:从文件系统/classpath 加载persist-meta/*.json - 写、读共用:写入引擎与回显读取都按同一份元数据执行,storageType 语义两侧一致
- 元数据未命中=两条许可档(audit8 R29/issues/195 定档;旧文本在此只写了回落档一条):
- 回落探测档(缺省):provider 未命中(java 系形状=返回
null)⇒ 引擎回落物理表结构探测、 全列按 NORMAL 处理,行为与 1.6.x 一致。参考实现 java 即本档 (MetaTableWriter.java:18/:47委托基础 writer +JdbcDynamicTableWriter.java:240/243探测缓存)。 - 未命中即失败档(许可):provider 未命中 ⇒ 该次 persist 动作以显式错误结束(rust 形状=
get_table_meta返回None⇒Err("表元数据不存在: <表名>"))。选本档的实现, "表结构探测"这条腿归 provider 自身(rust 集成壳的 provider 直接探information_schema, 表不存在时由它先报错,引擎那道未命中分支在真库路径上打不到)。 - 两档不承诺跨语言统一(本 campaign 又一条明文分叉)。选失败档的两条硬义务: ① 错误可归因——文案必须带表名;② 不得"记
Ok但什么都没写"——不落库必须是显式失败或留可诊断日志, 静默整单不落即红。用例判据=规范 08 场景 45;两档的落点由集成方在装配期自证。
- 回落探测档(缺省):provider 未命中(java 系形状=返回
4. 组件分工(读写职责分离)
写侧(DynamicTableWriter 接口不变)
├── JdbcDynamicTableWriter 现状:表结构探测,全 NORMAL
└── MetaTableWriter 新:元数据驱动(NORMAL/JSON/EXPAND/子表递归)
读侧(流程回显最小闭环)
JdbcTableReader 底层行查询(按列等值,limit)
MetaTableReader readByProcessInstance:storageType 反序列化 + 子表组装5. 写入语义(MetaTableWriter)
| storageType | 写入 |
|---|---|
| NORMAL | 直写(列名匹配按 §5.1:禁分隔符归一、允许折大小写) |
| EXPAND | 对象字段展开为多列(address.province → province 列) |
| JSON | 对象/数组序列化为 JSON 串写列 |
| ONE2ONE | 子表单条递归插入(外键 = 主表主键),同事务 |
| ONE2MANY | 子表多条递归插入(data 值为数组),同事务 |
5.1 元数据腿的列匹配=大小写不敏感的精确列名匹配(audit8 R78,2026-10-16 owner 拍甲;口径同日现读修正)
本档的轴=要不要做「驼峰↔下划线分隔符归一」,不是要不要折大小写。 元数据腿(读侧 MetaTableReader、 写侧 MetaTableWriter、以及"未消费列"判定)按元数据给出的列名找列时:允许只折大小写,禁止做分隔符归一 (即不许把表单键 companyName 认成表列 company_name,也不许反过来)。
现读各栈形状(逐仓 cd 进去 grep,行号本轮复核过):
- 同档=合规:java
jeeflow-persist/.../meta/TableMeta.java:41-44(注释自述「大小写不敏感」)、MetaTableReader.java:125、MetaTableWriter.java:186(三处都是equalsIgnoreCase); gopersist/meta.go:105+persist/meta_writer.go取列腿;pythonjeeflow/meta.py:76(.lower()==.lower())+:344; nodesrc/meta.ts:52/:311/:321(toLowerCase()比)。 - 同档合规(先前记成"更严"是转述失真,本轮读码更正):csharp
MetaTableWriter.cs:154用Equals(columnName, StringComparison.OrdinalIgnoreCase)=折大小写、不归一分隔符,与 java 同档。 ⇒ 六栈(java/go/python/node/csharp+集成层)本就同档,违约面只剩 moon(+rust 待裁,见下)。 - 违约档(本轮代码腿射程):moon
persist/column_names_equal(strict~默认false⇒ 走normalize_column()归一), 且元数据腿五处调用(meta_reader.mbt:82/:95/:149、meta_writer.mbt:69/:71)全不传strict~⇒ 恒宽松; rustjeeflow-persist/src/lib.rs:207-212与 moon 同形(默认档normalize_column), 其strict_columns由with_strict_columns显式开(:361默认false)⇒ 集成方不显式开就走归一档。
三条边界,免得被读宽或读窄:
- 只钉元数据腿。业务表落库那条轴(规范 09 §列匹配 的
1.6.4默认宽松+strictColumnMatch开关)不变, 本档不授权翻转那一档——两轴共用词但不同义,改元数据腿时不许顺手碰writer.go::findColumn/normalizeColumn那条腿。 - 列名对不上 ⇒ 显式失败或可诊断留痕(带表名+列名),不得"匹配到别的列"静默落值。
- 改法两选一、判据同形:moon 要么五处显式传
strict~,要么从元数据腿摘掉宽松支;rust 要么把默认档改判、 要么在元数据腿忽略strict_columns。两种都要有负向用例咬住差异(同一份数据、列名只差分隔符 ⇒ 必判不匹配), 并各配一枚阳性对照(列名只差大小写 ⇒ 仍要取到值,证明没把 java 档整片打死)。
落地(2026-10-16 代码腿,owner 拍甲-1):
- moon=五处显式传
strict=true(meta_reader.mbt:82/:95/:149、meta_writer.mbt:69/:71),默认宽松档原样留给 业务表那条轴;d9b2719。判据persist/r78_meta_column_strict_test.mbt七枚(writer 负向/阳性对照/逐字回归/ reader 两向配对/档级单点/幂等键直通闸——写侧的未消费字段判定改用同一把严格尺子,见下条)。 真库链demo/cmd/t1_mysql首跑曾照出一枚红(F149F 幂等键process_instance_id落 NULL)——那是 §9 自陈 "T1 两腿未做"的账。owner 裁甲′:幂等键由引擎按 java 同形注入 snake 键(moon 侧该腿既有=interceptor.mbt:426-427if data.get("process_instance_id") is None才注入,本轮一字未动; java 侧PersistPostInterceptor.java:237putIfAbsent),不改夹具期望值;本笔补的是写侧直通闸那把尺子 (驼峰键不再被误判成"已消费")⇒ 该真库腿转绿。 - rust=采「元数据腿忽略
strict_columns」那一支,且现读更正一处本条的前提:rust 全联邦里DynamicTableWriter只有测试用的InMemoryTableWriter(真库 writer 由集成侧自写),filter_editable是本栈唯一的列匹配代码点 ⇒ 本栈没有独立的「业务表轴」一条腿,两轴同源。因此钉元数据腿必然撤掉本腿的 宽松档,with_strict_columns随之作废为 no-op(#[deprecated]保留签名不断档),而 1.6.4 那把宽松尺子 仍以公开件(normalize_column/column_names_equal/find_data_key)留给集成方 writer 自取—— 这恰是 java 的分工(元数据腿MetaTableWriter恒精确、基础 writer 才做归一)。 本改撤回了一条既有判据:issues/195 ④ 立的「驼峰表单键按宽松归一落到蛇形列」在 rust 的元数据腿不再成立, 撤回依据=owner 拍甲+上述现读结构,不是实现方自证(判据已按甲重切并配三刀变异,读数见audit8-2026-10/notes/R78.md§12)。
复用现有能力:主键生成(含非自增雪花,生成器返回主键供子表外键)/ 系统字段 / 同链防重 / schema 限定探测。未消费字段(流程上下文 process_instance_id 等)直通。
中途更新(update,1.8.0):按元数据 storageType 组装 SET 列——NORMAL/JSON/EXPAND 参与更新;ONE2ONE/ONE2MANY 子表不参与中途更新(SYNC 任务推进只更新主表行状态, 子表数据变动走重新提交);未消费字段直通。无元数据回落基础 writer。
子表系统用户字段(1.8.0):子表递归插入时继承主表 apply_user_id (拦截器场景 = 流程 operator,putIfAbsent 子表单显式同名字段优先)——fillSystemFields 的用户列默认值可解析到 operator,避免 BIGINT create_user/update_user 列回落 "system" 严格模式报错(与主表行为一致)。
6. 读取语义(MetaTableReader)
java
// 按 relTableName + process_instance_id 回显一条(无分页/无条件)
Map<String, Object> readByProcessInstance(String tableName, Object processInstanceId);| storageType | 读取 |
|---|---|
| NORMAL | 直读列 |
| EXPAND | 反展开为对象(province/city/detail_addr → address.province/...) |
| JSON | 反序列化为对象/数组 |
| ONE2ONE | 按外键=主表主键查子表单条,组装为对象 |
| ONE2MANY | 按外键=主表主键查子表多条,组装为数组 |
- 定位键
process_instance_id(写入幂等键同款) - 未消费列(流程上下文/系统字段)统一小写带出(跨方言一致)
- EXPAND 展开列不重复平铺带出(1.8.0):
province/city/detail_addr已消费为address对象,不再作为顶层平铺键重复出现 - 无元数据回落原始行(列名→值)
7. 版本与发布
| 语言 | 发布形态 | 版本 |
|---|---|---|
| Java | jeeflow-persist 子模块(Maven Central) | 1.8.0 |
| Go | persist/ 子包(go get 同 module) | 1.8.0 |
| Python | jeeflow.meta 模块(PyPI 随主包) | 1.8.0 |
| Node | src/meta.ts(npm 随主包) | 1.8.0 |
| MoonBit | mldong/jeeflow-persist(persist 模块,mooncakes,独立 0.x 线) | 0.1.0 |
| C# | Mldong.Jeeflow.Persist(NuGet,独立 1.0.x 线) | 1.0.0 |
| PHP | 元数据腿未落地(见 §2 注;persist 本体见 09) | — |
| Rust | jeeflow-persist(crates.io,独立 1.0.x 线;异构许可档见 §2 注/§3) | 1.0.0 |