@pylonts/dsl 1.1.19 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { isDtoField, isDtoMessage } from './dto.js';
1
+ import { isDtoField, isDtoMessage, resolveDtoRefChain } from './dto.js';
2
2
  import { collectEnumRefs } from './dsl.js';
3
3
  function renderString(s) {
4
4
  return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
@@ -16,20 +16,6 @@ function fieldDescription(field) {
16
16
  function dtoFieldDescription(f) {
17
17
  return f.description ?? fieldDescription(f.field);
18
18
  }
19
- /** Resolve a ref chain to its terminal field (the one without .ref).
20
- * Cycles are a DSL definition error — fail loudly at render time. */
21
- function resolveRefChain(f) {
22
- const seen = new Set();
23
- let cur = f;
24
- while (cur.ref !== undefined) {
25
- if (seen.has(cur.ref)) {
26
- throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
27
- }
28
- seen.add(cur.ref);
29
- cur = cur.ref;
30
- }
31
- return cur;
32
- }
33
19
  function renderBasic(field, pattern, defaultValue, resolver, indent = 0, description) {
34
20
  if (pattern !== undefined && field.type !== 'string') {
35
21
  throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
@@ -176,7 +162,7 @@ function renderField(f, indent, resolver) {
176
162
  * chain, inherit the terminal field's type/constraints, keep the referencing
177
163
  * field's own overrides (pattern / default / description). */
178
164
  function renderRefBase(f, indent, resolver) {
179
- const target = resolveRefChain(f);
165
+ const target = resolveDtoRefChain(f);
180
166
  const targetField = target.field;
181
167
  const pattern = f.pattern ?? target.pattern;
182
168
  const defaultValue = f.default ?? target.default;
@@ -187,7 +173,7 @@ function renderRefBase(f, indent, resolver) {
187
173
  * override first, then the chain's DtoField-level optional, then the bare
188
174
  * column optionality. */
189
175
  function renderRefField(f, indent, resolver) {
190
- const target = resolveRefChain(f);
176
+ const target = resolveDtoRefChain(f);
191
177
  const optional = f.optional ?? target.optional ?? target.field.optional ?? false;
192
178
  const base = renderRefBase(f, indent, resolver);
193
179
  return optional ? `Type.Optional(${base})` : base;
@@ -225,7 +211,7 @@ function renderValue(f, indent, resolver) {
225
211
  }
226
212
  function collectEnumImports(f, resolver, out) {
227
213
  if (f.ref !== undefined) {
228
- collectEnumImports(resolveRefChain(f), resolver, out);
214
+ collectEnumImports(resolveDtoRefChain(f), resolver, out);
229
215
  return;
230
216
  }
231
217
  if (f.field.type === 'array') {
@@ -270,14 +256,25 @@ export function collectDtoImports(schema, resolver, out) {
270
256
  for (const f of Object.values(schema.fields))
271
257
  collectEnumImports(f, resolver, out);
272
258
  }
259
+ /** JSON Schema readOnly annotation on a rendered scalar schema: the token
260
+ * owns the field, the client must not send it (the __inject adapter
261
+ * overwrites any client-supplied value anyway). Injection fields are always
262
+ * scalar columns (tables forbid nested columns), so the only object literal
263
+ * in a rendered scalar base is its options block. */
264
+ function withReadOnly(base) {
265
+ const idx = base.lastIndexOf('{');
266
+ if (idx === -1)
267
+ return base.replace(/\(\s*\)$/, '({ readOnly: true })');
268
+ return `${base.slice(0, idx + 1)} readOnly: true,${base.slice(idx + 1)}`;
269
+ }
273
270
  /** Render the server-injection base: token-injected fields as Optional
274
- * properties of a TypeBox object, plus a non-enumerable __inject adapter
275
- * (same mechanism as hand-written bases, see pylon __inject docs) that fills
276
- * each field from the token at runtime. */
271
+ * readOnly properties of a TypeBox object, plus a non-enumerable __inject
272
+ * adapter (same mechanism as hand-written bases, see pylon __inject docs)
273
+ * that fills each field from the token at runtime. */
277
274
  function renderInjectBase(fields, resolver) {
278
275
  const entries = Object.entries(fields).map(([name, f]) => {
279
276
  const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver);
280
- return ` ${name}: Type.Optional(${base})`;
277
+ return ` ${name}: Type.Optional(${withReadOnly(base)})`;
281
278
  });
282
279
  const inner = `Type.Object({\n${entries.join(',\n')}\n})`;
283
280
  const assigns = Object.keys(fields)
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CollectionSchemaBase, Field, SchemaBase } from './dsl.js';
2
2
  import type { DtoField } from './dto.js';
3
+ import type { ExceptionSchema } from './exception.js';
3
4
  import type { FrontAppSchema, ProjectApiSchema } from './project.js';
4
5
  /** A utility method with a full signature. */
5
6
  export interface UtilsMethodSchema extends SchemaBase {
@@ -10,8 +11,14 @@ export interface UtilsMethodSchema extends SchemaBase {
10
11
  args: Record<string, DtoField>;
11
12
  /** Output field — a plain inline Field (boolean for checks, decimal for
12
13
  * computed amounts, ...). The method output is a fresh value, never a
13
- * shared table column. */
14
- result: Field;
14
+ * shared table column. Omit for void methods (pure actions). */
15
+ result?: Field;
16
+ /** Exceptions this method may throw — the failure contract of a defense
17
+ * guard (assert/validate/ensure: void, throws internally). Flows route
18
+ * invoked guards' throws into their escape set automatically, so a
19
+ * guard's throws must be declared by the calling service method (or
20
+ * caught in a TRY). Predicates (can/is/has, boolean) do not throw. */
21
+ throws?: ExceptionSchema[];
15
22
  }
16
23
  /** Method input for defineUtils: type/schema/name are set by the builder. */
17
24
  export type UtilsMethodDef = Omit<UtilsMethodSchema, 'type' | 'schema' | 'name'>;
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertNoInlineContainers } from './dto.js';
1
2
  export function defineUtils(options) {
2
3
  if (options.api !== undefined && options.app !== undefined && !options.api.apps.includes(options.app)) {
3
4
  throw new Error(`utils ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
@@ -12,6 +13,9 @@ export function defineUtils(options) {
12
13
  };
13
14
  for (const key of Object.keys(options.methods)) {
14
15
  const method = options.methods[key];
16
+ // Same rule as DTO fields: args must not nest inline containers — use a
17
+ // DTO field reference (args: { items: SubmitRequest.fields.items }).
18
+ assertNoInlineContainers(options.name, method.args);
15
19
  const methodSchema = {
16
20
  type: 'utilsMethod',
17
21
  name: key,
@@ -19,14 +23,18 @@ export function defineUtils(options) {
19
23
  schema,
20
24
  args: method.args,
21
25
  result: method.result,
26
+ throws: method.throws,
22
27
  };
23
28
  // Args: write back on the DtoField wrapper only (safe: wrappers are
24
- // created per method via dtoField(), never shared). Inline fields get
25
- // their underlying Field written back too; fields wrapping table columns
26
- // (domain rules) keep the shared column instance untouched — its
27
- // name/schema already point to the table.
29
+ // created per method via dtoField(), never shared). Shared instances
30
+ // DTO fields passed by reference (args: { items: OrderSubmitRequest.fields.items })
31
+ // and fields wrapping table columns (domain rules) stay untouched: the
32
+ // DTO owns its field name/schema, the shared column instance keeps its
33
+ // table identity.
28
34
  for (const argKey of Object.keys(methodSchema.args)) {
29
35
  const df = methodSchema.args[argKey];
36
+ if (df.schema !== undefined)
37
+ continue;
30
38
  df.name = argKey;
31
39
  df.schema = schema;
32
40
  if (df.field.schema === undefined) {
@@ -34,9 +42,13 @@ export function defineUtils(options) {
34
42
  df.field.schema = schema;
35
43
  }
36
44
  }
37
- // Result: a plain inline Field — write name/schema back directly.
38
- methodSchema.result.name = key;
39
- methodSchema.result.schema = schema;
45
+ // Result (optional): a plain inline Field — write name/schema back only
46
+ // when it is unowned (schema === undefined); shared instances (table
47
+ // columns, fields already claimed by another container) stay untouched.
48
+ if (methodSchema.result !== undefined && methodSchema.result.schema === undefined) {
49
+ methodSchema.result.name = key;
50
+ methodSchema.result.schema = schema;
51
+ }
40
52
  schema.methods[key] = methodSchema;
41
53
  }
42
54
  return schema;
@@ -0,0 +1,174 @@
1
+ # 聚合 DSL 极简落盘记录
2
+
3
+ > 日期:本次会话
4
+ > 范围:`@pylonts/dsl` 聚合声明层
5
+ > 目标:让“多对一 / 一对多”先落地,扩展表(一对一)只设计不实现。
6
+
7
+ ## 1. 背景
8
+
9
+ 聚合判断标准已收敛为:
10
+
11
+ > 创建 / 保存 / 删除时,是否必须一起做?
12
+ > - 是 → 聚合成员
13
+ > - 否 → 不聚合(引用)
14
+
15
+ 本次落盘只实现最常用的“一对多”聚合成员:
16
+
17
+ ```ts
18
+ defineAggregate({
19
+ root: order,
20
+ members: {
21
+ items: [orderItem],
22
+ },
23
+ });
24
+ ```
25
+
26
+ - 数组成员 `[orderItem]` = 一对多;
27
+ - 非数组成员(扩展表 / 一对一)暂不落代码,保留设计。
28
+
29
+ ## 2. 本次落盘改了什么
30
+
31
+ ### 2.1 源码
32
+
33
+ | 文件 | 改动 |
34
+ |---|---|
35
+ | `dsl/src/aggregate.ts` | 极简化 `defineAggregate` |
36
+ | `dsl/src/repository.ts` | 注释同步为 `FK / extends` |
37
+
38
+ `dsl/src/aggregate.ts` 关键变化:
39
+
40
+ - 去掉 `name` 必填,聚合名自动取 `root.name`;
41
+ - `members` 类型简化为:
42
+
43
+ ```ts
44
+ Record<string, TableSchema | TableSchema[]>
45
+ ```
46
+
47
+ - 删除 `via` / `one` 复杂配置;
48
+ - 数组成员校验:
49
+ - 必须恰好包含一张表;
50
+ - 该表必须有且仅有一个外键指向 root 主键;
51
+ - 多个外键指向 root 时明确报错;
52
+ - 非数组成员(扩展表)暂不支持,明确报错。
53
+
54
+ ### 2.2 测试
55
+
56
+ | 文件 | 改动 |
57
+ |---|---|
58
+ | `dsl/test/aggregate.test.ts` | 改为新极简 API,并增加“非数组成员暂不支持”测试 |
59
+ | `dsl/test/repository.test.ts` | 改为新极简 API |
60
+
61
+ ### 2.3 文档
62
+
63
+ | 文件 | 改动 |
64
+ |---|---|
65
+ | `dsl/docs/aggregate.md` | 示例去掉 `name`,成员表达改为数组/扩展表,同步扩展表规则 |
66
+ | `dsl/docs/table.md` | 新增“扩展表(extends)”设计说明,标注暂不落代码 |
67
+ | `dsl/docs/aggregate-implementation.md` | 本文档 |
68
+
69
+ ## 3. 当前 API 形态
70
+
71
+ ```ts
72
+ import { defineAggregate } from '@pylonts/dsl';
73
+
74
+ const orderAggregate = defineAggregate({
75
+ root: order,
76
+ members: {
77
+ items: [orderItem],
78
+ },
79
+ });
80
+ ```
81
+
82
+ 约束:
83
+
84
+ - `root` 必须有主键;
85
+ - `members` 的数组元素必须是普通表;
86
+ - 数组元素必须有外键指向 `root` 的主键;
87
+ - 如果有多条外键指向 root,必须收敛为一条,否则无法自动推导。
88
+
89
+ ## 4. 如何验证
90
+
91
+ 在仓库根目录或 `dsl/` 目录执行。
92
+
93
+ ### 4.1 运行 DSL 测试
94
+
95
+ ```bash
96
+ # 方式一:workspace
97
+ npm test --workspace @pylonts/dsl
98
+
99
+ # 方式二:进入 dsl 目录
100
+ cd dsl
101
+ npm test
102
+ ```
103
+
104
+ 可只跑聚合相关测试:
105
+
106
+ ```bash
107
+ cd dsl
108
+ npx vitest run test/aggregate.test.ts test/repository.test.ts
109
+ ```
110
+
111
+ 预期结果:
112
+
113
+ - `aggregate.test.ts` 全部通过;
114
+ - `repository.test.ts` 全部通过;
115
+ - 新增的“非数组成员暂不支持”用例通过。
116
+
117
+ ### 4.2 类型检查
118
+
119
+ ```bash
120
+ npm run typecheck --workspace @pylonts/dsl
121
+ ```
122
+
123
+ 或:
124
+
125
+ ```bash
126
+ cd dsl
127
+ npm run typecheck
128
+ ```
129
+
130
+ 预期结果:无 TypeScript 错误。
131
+
132
+ ### 4.3 构建
133
+
134
+ ```bash
135
+ npm run build --workspace @pylonts/dsl
136
+ ```
137
+
138
+ 或:
139
+
140
+ ```bash
141
+ cd dsl
142
+ npm run build
143
+ ```
144
+
145
+ 预期结果:`dist/` 正常生成。
146
+
147
+ ### 4.4 全量回归(如项目要求)
148
+
149
+ 在仓库根按项目现有脚本执行:
150
+
151
+ ```bash
152
+ npm run lint all
153
+ npm run typecheck
154
+ ```
155
+
156
+ 或按仓库实际脚本执行 `api tsc + lint all`。
157
+
158
+ ## 5. 验证重点
159
+
160
+ 1. 新的极简 `defineAggregate` 能正常声明聚合;
161
+ 2. 不再需要传 `name`;
162
+ 3. `members.items` 直接是 `[orderItem]`,不再有 `via` / `one`;
163
+ 4. 没有外键指向 root 时会报错;
164
+ 5. 非数组成员会报“扩展表未实现”;
165
+ 6. 旧测试已全部迁移到新 API。
166
+
167
+ ## 6. 暂未实现(保留设计)
168
+
169
+ - 扩展表 `extends: root`:一对一成员;
170
+ - 非数组成员:当前会抛错;
171
+ - Repository 生成器:仍未实现;
172
+ - flow 调用 repository:仍未实现。
173
+
174
+ 这些内容在 `dsl/docs/aggregate.md` 和 `dsl/docs/table.md` 中保留设计。
package/docs/aggregate.md CHANGED
@@ -24,17 +24,54 @@
24
24
  | 应用服务编排 | service_schema + flow ✅ | 已具备 |
25
25
  | 防腐层 | ThirdServiceSchema 只隔离 | 缺模型映射 |
26
26
 
27
- ## 2. 扩展一:AggregateSchema(核心)
27
+ ## 2. 什么情况下需要聚合?
28
+
29
+ **核心判断标准**:
30
+
31
+ > 创建 / 保存 / 删除时,是否必须一起做?
32
+ > - 是 → 聚合(成员)
33
+ > - 否 → 不聚合(引用)
34
+
35
+ ### 2.1 成员 vs 引用
36
+
37
+ | 维度 | 聚合成员 | 聚合间引用 |
38
+ |------|---------|-----------|
39
+ | 生命周期 | 与根同生共死 | 独立生命周期 |
40
+ | 创建 | 随根一起创建 | 不随根创建 |
41
+ | 保存 | 随根一起保存 | 不随根保存 |
42
+ | 删除 | 随根级联删除 | 不随根删除 |
43
+ | 关联 | 成员表 FK 指向根 / 扩展表 extends 根 | 本表 FK 指向其他聚合根 |
44
+ | 示例 | order_item / order_address 快照 | order.user_id / order.address_id |
45
+
46
+ ### 2.2 案例:地址可复用 vs 地址快照
47
+
48
+ **场景**:一个订单对应一个地址,但地址可以被多个订单复用。
49
+
50
+ - 如果地址可复用 → 不是聚合成员,是引用:
51
+ - 创建订单不会创建地址;
52
+ - 删除订单不会删除地址;
53
+ - 多个订单可以指向同一个地址;
54
+ - 建模为 `references: { address: 'AddressAggregate' }`,外键不需要 unique。
55
+
56
+ - 如果地址是下单时的快照(copy)→ 是聚合成员:
57
+ - 下单时复制一份地址到订单;
58
+ - 之后修改地址簿不影响已下单订单;
59
+ - 删除订单时该快照一起删除;
60
+ - 建模为 `members: { address: orderAddress }`;
61
+ - 此时 `orderAddress` 应为**扩展表**(`extends: order`),主键与根主键同义、类型一致,天然保证一对一。
62
+
63
+ **lint 规则**:声明为一对一成员时,成员表必须是扩展表(`extends` 指向根),主键与根主键同义且类型一致;否则它要么是一对多,要么应改为 references。
64
+
65
+ ## 3. 扩展一:AggregateSchema(核心)
28
66
 
29
67
  **作用**:显式声明"哪些表属于同一个聚合、谁是聚合根、成员如何挂载、跨成员不变式、聚合间引用规则"。这是把"多表一致性从约定变约束"的落点。
30
68
 
31
69
  ```ts
32
70
  defineAggregate({
33
- name: 'Order',
34
71
  root: ordersTable, // 聚合根表
35
- members: { // 成员表("包含几个 table schema" 的声明)
36
- items: { table: orderItemsTable, via: 'order_no' }, // 1:N,外键挂根
37
- address: { table: orderAddressTable, via: 'order_no', one: true }, // 1:1
72
+ members: { // 成员表:数组=一对多,非数组=一对一
73
+ items: [orderItemsTable], // 1:N,外键自动推导
74
+ address: orderAddressTable, // 1:1,扩展表
38
75
  },
39
76
  invariants: [ // 跨成员表不变式,挂聚合上,可被生成代码消费
40
77
  { name: 'total = sum(items.price * items.qty)',
@@ -47,8 +84,8 @@ defineAggregate({
47
84
  | 声明项 | 含义 | 缺了会怎样 |
48
85
  |--------|------|-----------|
49
86
  | `root` | 谁是聚合根 | 分不清一致性入口 |
50
- | `members` | 包含哪几个 table schema | "包含"只是列表,无结构 |
51
- | `members[i].via` | 成员表靠哪个外键挂根 | 工具无法推导 join / 级联关系 |
87
+ | `members` | 包含哪几个 table schema;数组=普通表=1:N,非数组=扩展表=1:1 | 无法推导成员关系 |
88
+ | 成员外键 | TableSchema.foreignKeys / extends 自动推导 | 工具无法推导 join / 级联关系 |
52
89
  | `invariants` | 跨成员一致性规则 | "总额=Σ明细"又回到 flow 里手工写 |
53
90
  | `references` | 聚合间只按 ID 引用 | 无法 lint 跨聚合直接持表引用 |
54
91
 
@@ -60,7 +97,7 @@ defineAggregate({
60
97
 
61
98
  **多表映射三种模式**(聚合↔表):A 单表=单聚合(1:1,几乎透明);B 一聚合=多表(1:N,最常见,Order 案例);C 多聚合共享表(N:1,DDD 不推荐)。
62
99
 
63
- ## 3. 扩展二:RepositorySchema(可推导,也可显式声明)
100
+ ## 4. 扩展二:RepositorySchema(可推导,也可显式声明)
64
101
 
65
102
  **作用**:聚合粒度的存储入口,把"哪些表一起查、怎么拼成聚合"的知识从 service flow 下沉到仓储。调用方只面对领域概念(Order),不面对表。
66
103
 
@@ -76,7 +113,7 @@ defineRepository({
76
113
 
77
114
  **聚合内 join 下沉、聚合间禁止 join**:
78
115
 
79
- - 聚合内:加载整个 Order(根 + 明细 + 地址)由 Repository 内部完成,调用方一行,join 知识声明在 `members[].via`;
116
+ - 聚合内:加载整个 Order(根 + 明细 + 地址)由 Repository 内部完成,调用方一行,join 知识由成员表 `foreignKeys` / `extends` 自动推导;
80
117
  - 聚合间:只按 ID 引用,跨聚合 join 是 DDD 禁止的;展示商品名这类信息走读模型 / 查询服务(CQRS 的 Q 侧),或应用服务分步查 + 内存组装。
81
118
 
82
119
  **分层对照**:
@@ -87,7 +124,7 @@ defineRepository({
87
124
  | Repository | **聚合** | orders + order_items 一个事务 | **本扩展** |
88
125
  | DAO | **表** | 单表一条 SQL | dao_schema ✅ |
89
126
 
90
- ## 4. 扩展三:引入支持 DDD 的 TS 库(选型,待决策)
127
+ ## 5. 扩展三:引入支持 DDD 的 TS 库(选型,待决策)
91
128
 
92
129
  dsl 是**声明期**(`defineAggregate` 声明结构),引入的库是**运行期**(代码跑起来时持久化/发事件)。两者互补,不冲突——声明可翻译为运行期库的配置。
93
130
 
@@ -101,9 +138,9 @@ dsl 是**声明期**(`defineAggregate` 声明结构),引入的库是**运
101
138
 
102
139
  **建议**:运行时持久化参考/选用 **MikroORM**(聚合持久化设计最完整);领域事件参考 **Remesh**。dsl 的 `AggregateSchema` 声明层本身无现成开源,属本项目的增量设计空间。
103
140
 
104
- ## 5. 落地步骤(待办,未开工)
141
+ ## 6. 落地步骤(待办,未开工)
105
142
 
106
- 1. `AggregateSchema` 类型 + `defineAggregate` + 定义期校验(root 必须在其表内、via 外键存在、成员表不能是其他聚合的 root 等);
143
+ 1. `AggregateSchema` 类型 + `defineAggregate` + 定义期校验(root 必须在其表内、成员表必须有外键指向 root 或 extends root、成员表不能是其他聚合的 root 等);
107
144
  2. `RepositorySchema` 类型 + `defineRepository`(或从 aggregate 自动推导生成);
108
145
  3. `gen` 生成 Repository 代码:加载/保存/删除三套骨架 + 同事务包装(`@Trans()` 从声明推导);
109
146
  4. `lint` 聚合边界检查:聚合外禁止直接改成员表、聚合间禁止跨表引用;
package/docs/dto.md CHANGED
@@ -1,74 +1,131 @@
1
- # 定义 DTO(四种方向)
2
-
3
- DTO 描述接口出入参。方向决定可选性规则,由各方向工厂设置;**规则统一只在 `field.optional === undefined` 时生效**(作者已显式设置的跳过):
4
-
5
- | 构建器 | 方向 | 可选性规则 |
6
- |---|---|---|
7
- | `buildInput` | input | 按 DB 列规则:主键 → 必填;可空(未写 `optional` 或 `optional: true`)/ 有默认 → 可选;`optional: false` 无默认 → 必填 |
8
- | `buildOutput` | output | 不设置(保持字段构建器/作者设置) |
9
- | `buildQuery` | query | 全部可选 |
10
- | `buildPk` | pk | 主键字段必填,其他字段可选 |
11
-
12
- ## 从字段集合投影
13
-
14
- `from(source, fields)` 接受两类字段集合源:**表**(`TableSchema`)或**第三方方法消息**(`ThirdMethodSchema`,见 [third-service.md](./third-service.md)),投影字段包装为 DTO 字段,字段实例与源共享,`name/schema` 保持指向源。
15
-
16
- ```ts
17
- import { buildInput, buildQuery, dtoField, from } from '@pylonts/dsl';
18
-
19
- // 输入:新增订单
20
- buildInput('OrderAddRequest', { ...from(order, [order.columns.mer_id, order.columns.amount]) });
21
-
22
- // 输出:订单行
23
- buildOutput('OrderRow', from(order, [order.columns.id, order.columns.order_no]));
24
-
25
- // 查询:分页 + 过滤(query 字段恒为可选,.op() 声明比较操作符)
26
- buildQuery('OrderPageQuery', {
27
- keyword: dtoField(stringField({ maxLength: 32 })).setOperator('like'),
28
- ...from(order, [order.columns.mer_id]),
29
- });
30
-
31
- // 主键:按 id 取详情
32
- buildPk('OrderDetailRequest', from(order, [order.columns.id]));
33
-
34
- // 转发:透传第三方方法消息(wire-format 字段名保持协议原样,不转 camelCase)
35
- buildOutput('BalanceResult', from(queryBalance.results, [queryBalance.results.fields.balance]));
36
- ```
37
-
38
- - **表源**:DTO 字段名转 camelCase(`mer_id` → `merId`),与 DB 列名(snake_case)分离。
39
- - **第三方方法源**:字段名即线格式协议名(`out_trade_no`、`appId`),保持不变。
40
- - `from()` 本身不做任何可选性推断——推断在各方向工厂,且按字段的 schema 判断:共享实体列按列规则推断(Rule A),wire 自有字段保留声明值。
41
-
42
- ## 独立字段
43
-
44
- 不来自表的内联字段直接用 `dtoField(...)` 包装任意字段构建器,可加 `pattern`、`optional`、`operator`、`default`。
45
-
46
- ```ts
47
- dtoField(stringField({ maxLength: 32 })).setOperator('like')
48
- dtoField(intField()).setDefault(0) // TypeBox default 注解
49
- ```
50
-
51
- ## 默认值
52
-
53
- - `setDefault(v)` DTO 层默认值,渲染为 TypeBox `default:` 注解(`Type.String({ default: 'PENDING' })`、`Type.Enum(OrderStatus, { default: OrderStatus.PENDING })`)。
54
- - 枚举默认值必须是该枚举的成员值(value),渲染时解析为成员引用;非成员值直接报错。
55
- - 未显式设置时,fallback 到字段构建器的 `default`(DB 默认值,string),`from()` 提取的字段自动带出。
56
-
57
- ## 继承基础 schema
58
-
59
- ```ts
60
- buildQuery('OrderPageQuery', { ... })
61
- .include({ from: '@pylonts/core', name: 'PageRequest' }); // 渲染 Type.Intersect([PageRequest, ...])
62
- ```
63
-
64
- ## 关键语义
65
-
66
- - **字段两层名**:`DtoField.name` 是接口字段名(DTO map key 反写);`field.name` 是数据库列名(表反写)。
67
- - **可选性优先级**:DTO 层 `optional` 优先于字段层;query 方向所有字段强制可选。
68
- - **HTTP string**:bigint / decimal / date / time 在接口层渲染为 `Type.String()`,保证精度与序列化语义。
69
-
70
- ## 文件组织
71
-
72
- - `dto_schema/{module}/*.dto.ts`:DTO 源文件,一个文件可导出多个 DtoMessage;`module` = `project.config.ts` 的 `apps.name`,另加 `common/` 放跨 app 共享 DTO。
73
- - `pylonts gen dto` 按 module 逐个扫描:`dto_schema/{module}/*.dto.ts` → 生成 `dto/{module}/{Name}.dto.ts`。
1
+ # 定义 DTO(四种方向)
2
+
3
+ DTO 描述接口出入参。方向决定可选性规则,由各方向工厂设置;**规则统一只在 `field.optional === undefined` 时生效**(作者已显式设置的跳过):
4
+
5
+ | 构建器 | 方向 | 可选性规则 |
6
+ |---|---|---|
7
+ | `buildInput` | input | 按 DB 列规则:主键 → 必填;可空(未写 `optional` 或 `optional: true`)/ 有默认 → 可选;`optional: false` 无默认 → 必填 |
8
+ | `buildOutput` | output | 不设置(保持字段构建器/作者设置) |
9
+ | `buildQuery` | query | 全部可选 |
10
+ | `buildPk` | pk | 主键字段必填,其他字段可选 |
11
+
12
+ ## 从字段集合投影
13
+
14
+ `from(source, fields)` 接受两类字段集合源:**表**(`TableSchema`)或**第三方方法消息**(`ThirdMethodSchema`,见 [third-service.md](./third-service.md)),投影字段包装为 DTO 字段,字段实例与源共享,`name/schema` 保持指向源。
15
+
16
+ ```ts
17
+ import { buildInput, buildQuery, dtoField, from } from '@pylonts/dsl';
18
+
19
+ // 输入:新增订单
20
+ buildInput('OrderAddRequest', { ...from(order, [order.columns.mer_id, order.columns.amount]) });
21
+
22
+ // 输出:订单行
23
+ buildOutput('OrderRow', from(order, [order.columns.id, order.columns.order_no]));
24
+
25
+ // 查询:分页 + 过滤(query 字段恒为可选,.op() 声明比较操作符)
26
+ buildQuery('OrderPageQuery', {
27
+ keyword: dtoField(stringField({ maxLength: 32 })).setOperator('like'),
28
+ ...from(order, [order.columns.mer_id]),
29
+ });
30
+
31
+ // 主键:按 id 取详情
32
+ buildPk('OrderDetailRequest', from(order, [order.columns.id]));
33
+
34
+ // 转发:透传第三方方法消息(wire-format 字段名保持协议原样,不转 camelCase)
35
+ buildOutput('BalanceResult', from(queryBalance.results, [queryBalance.results.fields.balance]));
36
+ ```
37
+
38
+ - **表源**:DTO 字段名转 camelCase(`mer_id` → `merId`),与 DB 列名(snake_case)分离。
39
+ - **第三方方法源**:字段名即线格式协议名(`out_trade_no`、`appId`),保持不变。
40
+ - `from()` 本身不做任何可选性推断——推断在各方向工厂,且按字段的 schema 判断:共享实体列按列规则推断(Rule A),wire 自有字段保留声明值。
41
+
42
+ ## 独立字段
43
+
44
+ 不来自表的内联字段直接用 `dtoField(...)` 包装任意字段构建器,可加 `pattern`、`optional`、`operator`、`default`。
45
+
46
+ ```ts
47
+ dtoField(stringField({ maxLength: 32 })).setOperator('like')
48
+ dtoField(intField()).setDefault(0) // TypeBox default 注解
49
+ ```
50
+
51
+ ## 容器字段:禁止内联嵌套(构建期校验)
52
+
53
+ **DTO 不内联**——嵌套结构必须命名引用,构建期(`buildInput/buildOutput/buildQuery/buildPk` `defineUtils` args)机器校验:
54
+
55
+ | 形态 | 允许 | 禁止 |
56
+ |---|---|---|
57
+ | 数组字段 `dtoArrayField` | `items: 命名 DTO`(`items: orderItemDto` → `OrderItemDto[]`)或 `items: 标量字段`(`string[]`) | `items: dtoObjectField(...)` 内联对象元素、`items: dtoArrayField(...)` 内联数组元素 |
58
+ | 对象字段 | 裸 `objectField(...)`(wire 格式嵌套,渲染内联 `Type.Object({...})`) | `dtoObjectField(...)`、`dtoField(dtoObjectField(...))`——DtoField 类容器必须命名引用(数组元素),或改用裸 `objectField` |
59
+
60
+ **理由**:DtoField 类容器(`dtoObjectField` / `dtoArrayField`)内联时无法跨 DTO 复用,生成类型要么靠字符串拼接(`Array<{...}>`),要么塌缩(`any[]`)——命名引用让结构有单一事实来源(DTO 定义处)且生成精确类型。裸 `objectField` / `arrayField` 属于 wire 格式嵌套(随消息直接内联渲染),不在禁用范围。校验错误示例:
61
+
62
+ ```
63
+ [dto] "OrderSubmitRequest" field "items": inline container elements are not allowed — array items must be a named DTO or a scalar field
64
+ ```
65
+
66
+ ```ts
67
+ // 内联对象元素
68
+ items: dtoArrayField({ items: dtoObjectField({ properties: { skuId: ..., qty: ... } }) })
69
+
70
+ // ✓ 命名 DTO 引用(OrderItemDto 单独 buildOutput 声明)
71
+ export const OrderItemDto = buildOutput('OrderItemDto', { skuId: ..., qty: ... });
72
+ items: dtoArrayField({ items: OrderItemDto })
73
+ ```
74
+
75
+ ## 字段引用规格(Reference Spec)
76
+
77
+ `DtoField` 通过 `setRef(other)` 或共享字段实例引用其他字段,表达"本字段来源于 X"。**引用不是任意的**——只有业务契约 DTO(controller/service 消费的 `dto_schema/{app}` 或 `dto_schema/common` DTO)的字段可以作为引用方,且只能引用三类被引用方:
78
+
79
+ | # | 引用 | 语法 | 语义 |
80
+ |---|---|---|---|
81
+ | 1 | **field 引用** | `dtoField(table.columns.x)`(共享实例)或 `setRef` 指向包装表列的 DtoField | 与数据库字段同义(来源:库) |
82
+ | 2 | **token 引用** | `fromToken(token, [...])` 或 `setRef(token 字段)` | 字段来源于登录身份(服务端注入) |
83
+ | 3 | **third 引用** | `setRef(third 消息字段)` | 字段来源于第三方消息(参数或结果) |
84
+
85
+ **方向约束**:
86
+
87
+ - 引用方:业务契约 DTO 的字段;
88
+ - 被引用方:token 字段 / third 消息字段 / 表列 field——**业务 DTO 之间不能互相引用**(convert 的 sources/target 各自独立,映射由搬运生成推导,不靠 DTO 互 ref);
89
+ - third 字段、token 字段不可反向引用业务 DTO;
90
+ - ref 链无环(渲染期强校验,循环引用报错)。
91
+
92
+ **对 convert 自动搬运的意义**:同源判定 = ref 链终端 / 共享 field 实例,且只有规格内合法引用构成可推导搬运——业务 DTO ← third(防腐翻译,`setRef(thirdField)` = "本字段从 third 参数/结果来")、业务 DTO ← entity(共享表列实例)都是可自动生成搬运的映射;越界引用是声明错误。
93
+
94
+ > entity 列是裸 `Field` 实例:DTO 字段 `dtoField(order.columns.x)` 与 entity 列共享同一实例即构成 field 引用。
95
+
96
+ ### token 引用的注入与 readOnly
97
+
98
+ `fromToken(token, fields)` 是 token 引用的标准入口:每个投影字段 `setRef(token 字段)`(复用类型/约束)+ 标记 `injectFrom`(服务端注入)。typebox 生成物中注入字段渲染为 **`Type.Optional(...)` + `readOnly: true` 注解**(JSON Schema annotation)——服务器字段,客户端不得上送:
99
+
100
+ ```ts
101
+ id: Type.Optional(Type.Integer({ readOnly: true })),
102
+ ```
103
+
104
+ 同时生成非枚举 `__inject` 适配器,fastify RPC 层在进 controller 前执行 `body.k = token.k` 从登录身份填充——**客户端即使上送也会被覆盖**(上送无效)。三层闭环:声明 readOnly(不可上送语义)+ Optional(可不传)+ 运行时覆盖(上送无效)。
105
+
106
+ 注意:**只有 `fromToken()` 自动设置 `injectFrom`**;手写 `setRef(token 字段)` 只表达引用关系,不触发注入渲染(如需注入须显式标记或改走 fromToken)。
107
+
108
+ ## 默认值
109
+
110
+ - `setDefault(v)` 设 DTO 层默认值,渲染为 TypeBox `default:` 注解(`Type.String({ default: 'PENDING' })`、`Type.Enum(OrderStatus, { default: OrderStatus.PENDING })`)。
111
+ - 枚举默认值必须是该枚举的成员值(value),渲染时解析为成员引用;非成员值直接报错。
112
+ - 未显式设置时,fallback 到字段构建器的 `default`(DB 默认值,string),`from()` 提取的字段自动带出。
113
+
114
+ ## 继承基础 schema
115
+
116
+ ```ts
117
+ buildQuery('OrderPageQuery', { ... })
118
+ .include({ from: '@pylonts/core', name: 'PageRequest' }); // 渲染 Type.Intersect([PageRequest, ...])
119
+ ```
120
+
121
+ ## 关键语义
122
+
123
+ - **字段两层名**:`DtoField.name` 是接口字段名(DTO map key 反写);`field.name` 是数据库列名(表反写)。
124
+ - **可选性优先级**:DTO 层 `optional` 优先于字段层;query 方向所有字段强制可选。
125
+ - **HTTP 传 string**:bigint / decimal / date / time 在接口层渲染为 `Type.String()`,保证精度与序列化语义。
126
+
127
+ ## 文件组织
128
+
129
+ - `dto_schema/{module}/*.dto.ts`:DTO 源文件,一个文件可导出多个 DtoMessage;`module` = `project.config.ts` 的 `apps.name`,另加 `common/` 放跨 app 共享 DTO。
130
+ - `pylonts gen dto` 按 module 逐个扫描:`dto_schema/{module}/*.dto.ts` → 生成 `dto/{module}/{Name}.dto.ts`。
74
131
  - 枚举字段引用 `enums/` 下生成的枚举源文件,DTO 文件通过 `../../enums/{JsName}.enum` 导入。