@pylonts/dsl 1.1.13 → 1.1.15

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.
package/dist/dto.d.ts CHANGED
@@ -47,6 +47,8 @@ export declare class DtoField implements SchemaBase {
47
47
  operator?: Operator;
48
48
  /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
49
49
  default?: unknown;
50
+ /** Optional reference to another DtoField — this field reuses the referenced field's type/constraints */
51
+ ref?: DtoField;
50
52
  constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef);
51
53
  setPattern(value: string): this;
52
54
  setDescription(value: string): this;
@@ -56,6 +58,8 @@ export declare class DtoField implements SchemaBase {
56
58
  setOptional(value: boolean): this;
57
59
  /** Set a default value — emitted as a TypeBox schema default annotation */
58
60
  setDefault(value: unknown): this;
61
+ /** Reference another DtoField — this field reuses the referenced field's type/constraints */
62
+ setRef(value: DtoField): this;
59
63
  /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
60
64
  setOperator(value: Operator): this;
61
65
  /** optional 优先于 field.optional */
package/dist/dto.js CHANGED
@@ -14,6 +14,8 @@ export class DtoField {
14
14
  operator;
15
15
  /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
16
16
  default;
17
+ /** Optional reference to another DtoField — this field reuses the referenced field's type/constraints */
18
+ ref;
17
19
  constructor(field) {
18
20
  this.name = '';
19
21
  this.field = field;
@@ -42,6 +44,11 @@ export class DtoField {
42
44
  this.default = value;
43
45
  return this;
44
46
  }
47
+ /** Reference another DtoField — this field reuses the referenced field's type/constraints */
48
+ setRef(value) {
49
+ this.ref = value;
50
+ return this;
51
+ }
45
52
  /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
46
53
  setOperator(value) {
47
54
  this.operator = value;
@@ -2,121 +2,151 @@
2
2
 
3
3
  第三方服务适配器契约(如微信支付 tenpay、短信、文件存储)。`defineThirdService` 声明适配器类契约:构造函数配置 + 方法列表。
4
4
 
5
+ > 完整对接流程(前置准备 → 契约化 → gen third → 填充 client → 沙箱验证)见 [methodology/third-party-integration.md](../../docs/methodology/third-party-integration.md)。
6
+
7
+ 与两个相近概念区分:
8
+
9
+ - `ServiceSchema`(service_schema/)——后端业务服务;
10
+ - `ThirdApiSchema`(project.config.ts `thirdApis`)——项目拓扑:第三方系统的目录归属,如 `wx/`、`ble/`。
11
+
12
+ `ThirdServiceSchema.schema` 引用 `ThirdApiSchema` 实例(拓扑引用)。第三方方法实现在外部系统,仅声明契约、不建模内部流程。
13
+
14
+ ## 定义
15
+
5
16
  ```ts
6
- import { defineFieldRule, defineThirdService } from '@pylonts/dsl';
17
+ import { buildInput, buildOutput, CodeException, defineThirdService, dtoField, intField, IOException, stringField } from '@pylonts/dsl';
7
18
  import { wx } from '../project.config';
8
19
 
9
- const balance = intField({ optional: false, label: '余额(分)' });
10
-
11
- // Rule = name + two named ends. One rule per semantic (defining fenYuan twice throws).
12
- const fenYuan = defineFieldRule({
13
- name: 'fenYuan',
14
- ends: { fen: {}, yuan: {} },
15
- });
20
+ const totalFee = intField({ optional: false, label: '金额(分)' });
16
21
 
17
22
  export const wxPayService = defineThirdService({
18
23
  schema: wx,
19
24
  name: 'WxPayService',
20
25
  description: '微信支付服务(tenpay APIv2)',
21
- methods: [
22
- {
23
- name: 'queryBalance',
24
- args: { name: 'QueryBalanceArgs', fields: { openid: user.columns.openid } },
25
- results: {
26
- name: 'QueryBalanceResult',
27
- fields: {
28
- balance,
29
- },
30
- refs: [
31
- { field: balance, ref: order.columns.amount, convert: { rule: fenYuan, end: fenYuan.ends.fen } },
32
- ],
33
- },
26
+ methods: {
27
+ getPayParams: {
28
+ args: buildInput('PayParams', {
29
+ out_trade_no: dtoField(stringField({ maxLength: 32, optional: false, label: '订单号' })),
30
+ total_fee: dtoField(totalFee),
31
+ // Same fact and same type as the entity column — shared instance.
32
+ openid: dtoField(user.columns.openid),
33
+ }),
34
+ results: buildOutput('PayParamsResult', {
35
+ appId: dtoField(stringField({ optional: false, label: 'appId' })),
36
+ paySign: dtoField(stringField({ optional: false, label: '签名' })),
37
+ }),
38
+ throws: [CodeException, IOException],
39
+ description: '获取支付参数',
34
40
  },
35
- ],
41
+ },
36
42
  });
37
43
  ```
38
44
 
39
- ## ThirdMethodSchema:方法的 args / results
40
-
41
- 每个方法的 `args` / `results` 各是一个 `ThirdMethodSchema`——**字段集合容器**,与 `TableSchema.columns` 同构,但字段是线格式(wire-format)字段。结构上它是 `DtoMessage` 的字段集合对应物。
45
+ - `methods` **map**:key 即方法名(构建器写回 `method.name`),value 为 `ThirdServiceMethodDef`。
46
+ - 每个方法的 `args` / `results` 各是一个 `DtoMessage`,用 `buildInput` / `buildOutput` 构建。`buildInput` 构建 `args`(输入消息),`buildOutput` 构建 `results`(输出消息)——与业务 DTO 同一 POJO 聚合,字段以 `dtoField(field)` 包装,map key 即线格式(wire-format)字段名,**原样保留协议拼写**(`out_trade_no`、`appId`,不做 camelCase)。
47
+ - `throws`(**必填**):每个方法必须声明 **`CodeException` + `IOException`** 两个异常——`CodeException`(第三方返回的业务错误码)与 `IOException`(网络/超时/不可恢复故障)。两个异常都来自 `@pylonts/core`,**不能自定义、不能替换**(`pylonts gen third` 会校验,缺失即报错拒绝生成)。原因:第三方集成有两类必然失败——业务层失败(第三方返回错误码,需转译给调用方)与传输层失败(网络/超时,需按故障重试或上报),client 骨架的异常翻译依赖这两个契约。
48
+ - `description`(可选):服务或方法说明。
42
49
 
43
- | 属性 | 说明 |
44
- |------|------|
45
- | `name` | 消息名(如 `QueryBalanceResult`),生成产物的类型名 |
46
- | `fields` | 线格式字段 map,key 即协议字段名(`out_trade_no`、`appId` 原样保留) |
47
- | `refs` | 同事实变体链接(见下) |
48
- | `schema` | 反向指针,指向所属 method(构建器写入) |
49
-
50
- `defineThirdMethod` 写回自有字段的 `name/schema`(与 `defineTable` 同一惯例)。
51
-
52
- ## 字段与本地实体的关系
53
-
54
- 两个通道,按"同一事实"的表达方式选择:
50
+ 字段与本地实体列/其他消息字段的关系,两个通道,按"同一事实"的表达方式选择:
55
51
 
56
52
  ### 同一概念且类型一致 → 共享实例
57
53
 
58
- 直接复用本地实体列实例,类型/语义/默认值自动跟随实体,DTO 投影继承全部语义(与 `from(table)` 完全同构):
54
+ 直接复用本地实体列实例,类型/语义/默认值自动跟随实体,DTO 投影继承全部语义:
59
55
 
60
56
  ```ts
61
57
  args: {
62
- name: 'QueryBalanceArgs',
58
+ name: 'PayParams',
63
59
  fields: {
64
- openid: user.columns.openid, // 共享实例,schema 仍指向 t_user
60
+ openid: dtoField(user.columns.openid), // user TableSchema 实例,此处复用其 openid 列的 Field 对象
65
61
  },
66
62
  },
67
63
  ```
68
64
 
69
- 前提是线格式字段名与实体列名一致(协议恰好也叫 `openid`)。名字不同时(协议叫 `userId` 而列叫 `user_id`),不共享实例,走 refs。
65
+ 线格式字段名(map key)与列名无需一致——key 是协议拼写,value 是任意 Field 实例,两者解耦。协议叫 `userId`、列叫 `user_id` 照样共享:
70
66
 
71
- ### 同一概念但类型/格式不同(变体)→ 自有字段 + refs
67
+ ```ts
68
+ fields: {
69
+ userId: dtoField(user.columns.user_id), // key 按协议拼写,value 复用列实例
70
+ },
71
+ ```
72
72
 
73
- 声明自有线格式类型,再挂一条 `refs` 链接指向实体列。规则先定义一次:
73
+ > `user` 是 `schema/user.table.ts` 中 `export const user = defineTable('user', { ... })` 导出的 **TableSchema 实例**(`user.columns` 是它的列 map,`user.columns.openid` 是该表 `openid` 列的 Field 实例)。共享实例即把**同一个 Field 对象**放入消息字段,类型/语义/默认值全部跟随表定义。
74
74
 
75
- ```ts
76
- // 规则 = 名称 + 两端(具名 map)。同一语义全局只允许一条(重复定义抛错)。
77
- const fenYuan = defineFieldRule({
78
- name: 'fenYuan',
79
- ends: { fen: {}, yuan: {} },
80
- });
75
+ ### 同一概念但类型/格式不同 → 自有字段
81
76
 
77
+ 声明自有线格式类型:
78
+
79
+ ```ts
82
80
  const totalFee = intField({ optional: false, label: '金额(分)' });
83
81
 
84
82
  fields: {
85
- total_fee: totalFee,
83
+ total_fee: dtoField(totalFee),
86
84
  },
87
- refs: [
88
- {
89
- field: totalFee, // 本地定义(本消息的 wire 字段)
90
- ref: order.columns.amount, // 其他定义(表列或其他消息字段)
91
- convert: { rule: fenYuan, end: fenYuan.ends.fen },
92
- },
93
- ],
94
85
  ```
95
86
 
96
- - `field`:本消息的 wire 字段实例(构建器校验必须是本消息字段)
97
- - `ref`:其他 schema 的字段实例(表列或其他消息字段,构建器校验不得是本消息字段)
98
- - `convert`(可选):绑定一条规则到这对字段——仅当两字段需要转化时声明,纯关联不需要
99
- - `rule`:`FieldRuleSchema`——规则 = 名称 + 两端(如 `fenYuan` 的 `fen`/`yuan` 端)。加密/脱敏/换算统一为规则名维度,`defineFieldRule` 按名称查重,同一语义只声明一次
100
- - `end`:`field` 所站的端——引用 `rule.ends.fen` / `rule.ends.yuan`(具名引用,无索引魔法;构建器按实例校验),`ref` 自动占另一端——不再重复声明 from/to
101
- - 生成器将来为这对字段产出两个方向的函数(field 端→ref 端 与 ref 端→field 端)
87
+ wire 字段与本地字段之间的换算/映射(分↔元、加密、脱敏)由 convert 防腐层承载,`FieldRuleSchema` 换算规则为规划能力、尚未接入消息绑定。
102
88
 
103
89
  ## 嵌套字段
104
90
 
105
- 线格式字段支持递归嵌套,用 `arrayField` / `objectField`(Field 体系,非表列):
91
+ 线格式字段支持递归嵌套,用 `objectField` / `arrayField`(Field 体系,非表列):
106
92
 
107
93
  ```ts
108
94
  fields: {
109
- payer_info: objectField({
95
+ payer_info: dtoField(objectField({
110
96
  properties: {
111
97
  openid: stringField({ optional: false, maxLength: 64 }),
112
98
  },
113
- }),
114
- coupons: arrayField({ items: intField() }),
99
+ })),
100
+ coupons: dtoField(arrayField({ items: intField() })),
115
101
  },
116
102
  ```
117
103
 
118
- 表列不支持这两个类型(`buildCreateTableSql` 直接报错)。
104
+ 表列不支持这两个类型(`buildCreateTableSql` 直接报错,定义期即拦截)。
105
+
106
+ ## 枚举与异常
107
+
108
+ 第三方消息字段可用 `enumField` 挂 `defineEnum` 枚举,两者与 `defineThirdService` 定义在同一源文件中(named export),供 `pylonts gen third` 生成枚举产物。
109
+
110
+ 异常不在此处定义——方法 `throws` 固定声明 `@pylonts/core` 的 `CodeException` + `IOException`(见上文「定义」一节),`pylonts gen third` 强校验。
111
+
112
+ ## 存储与生成
113
+
114
+ - 声明:`third_schema/{thirdApi.name}/*.third-service.ts`——一文件一服务(named export),目录名 = project.config.ts 的 `thirdApis` 实例名。
115
+ - 生成:`pylonts gen third`——对每个 thirdApi,扫描 `third_schema/{name}/`,按三步产出到 `third/{name}/`:
116
+ 0. **throws 校验**(生成前置闸门):每个方法必须声明 `CodeException` + `IOException`,缺失即报错列出违规方法,不写任何产物;
117
+ 1. **枚举**:模块导出的 `defineEnum` 实例 → `third/{name}/enums/{JsName}.enum.ts`(复用 enum-driver 的 `renderEnum`,与表枚举同一渲染),一 jsName 一文件;
118
+ 2. **DTO**:每方法 args/results 用 typebox-driver 渲染 TypeBox 消息 + Static 类型,**一源文件一生成文件**,输出 `third/{name}/{stem}.third-service.gen.ts`(覆盖写);
119
+ 3. **客户端骨架**:每服务渲染一个 class(构造配置接口 + 每方法 async 签名 + Not-implemented throw),输出 `third/{name}/{stem}.client.ts`——**已存在则跳过**(方法体是用户填充的),`--force` 覆盖。
120
+ - DTO 的枚举字段 import 走**相对路径** `./enums/{JsName}.enum`(DTO 与 enums/ 同处 `third/{name}/` 下,`moduleResolution: bundler` 解析 `.enum.ts`),不依赖根 `enums/` 子包。
121
+ - 生成物目录是子包:`third/` 目录带 `package.json`,`exports` 声明 `*.third-service.gen` 子路径,供 convert 产物 import。
122
+
123
+ ## 客户端骨架是生成的,方法体是手写的
119
124
 
120
- ## DTO 转发
125
+ `defineThirdService` 只描述**契约**(构造配置 + 方法列表)。`gen third` 生成的 `{name}.client.ts` 是一个**骨架**:导出 `{Service}Config` 接口(TODO 注释标注 transport 配置——baseUrl/凭据/密钥属外部实现,不在契约内)+ `{Service}` 类(constructor 空实现),每方法带完整签名(args/results 类型从同名 `.third-service.gen` import type)与 `throw new Error('Not implemented: ...')` stub(含 `// @gen:stub` 标记,与 gen-service 骨架同一套 marker 约定)。**签名/throws 注释由生成器保证与契约同步,方法体、构造配置、签名加密等外部交互由人工填充**——已存在文件默认跳过(避免覆盖人工实现),`--force` 才重写。
126
+
127
+ ## convert 防腐接线
128
+
129
+ 第三方消息(`args`/`results` 是 `DtoMessage`,天然满足 `ConvertSourceSchema`)可直接作为 convert 的**源或目标**,用于 wire 消息 ↔ 本地模型的防腐翻译。```ts
130
+ // convert_schema/{api.name}/{app.name}/convert/wx-pay.convert.ts
131
+ import { wxPayService } from '../../../third_schema/wx/wxpay.third-service';
132
+
133
+ const getPayParams = wxPayService.methods.getPayParams;
134
+
135
+ export const wxPayConvert = defineConvert({
136
+ name: 'WxPayConvert',
137
+ api,
138
+ app: admin,
139
+ methods: {
140
+ toPayParams: {
141
+ sources: [order], // 本地订单表 → wire 请求
142
+ target: getPayParams.args,
143
+ },
144
+ toLocalPayResult: {
145
+ sources: [getPayParams.results], // wire 响应 → 本地 DTO
146
+ target: WxPayParamsResultDto,
147
+ },
148
+ },
149
+ });
150
+ ```
121
151
 
122
- DTO 通过 `from(thirdMethod, fields)` 投影第三方消息字段,构建转发引用(透传不复制)。线格式字段名保持协议原样,不做 camelCase。见 [dto.md](./dto.md#从字段集合投影)。
152
+ convert 文件绑定第三方服务身份时按 `{third-service}.convert.ts` 命名(上例 `wx-pay.convert.ts` 对应 `wxpay.third-service.ts` 的 `WxPayService`)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "author": "",
42
42
  "license": "MIT",
43
43
  "dependencies": {
44
- "@pylonts/core": "^1.1.2"
44
+ "@pylonts/core": "^1.1.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "^7.0.2",
package/src/dto.ts CHANGED
@@ -1,324 +1,332 @@
1
- import { BaseField, CollectionSchemaBase, Field, Operator, SchemaBase, walkContainer } from './dsl.js';
2
- import { TableSchema } from './db.js';
3
- import type { EntitySchema } from './entity.js';
4
- import type { ImportBase } from './import-base.js';
5
- import { toCamelCase } from '@pylonts/core';
6
-
7
- // Interface (DTO) field definitions.
8
- // Naming convention: all DTO types and builders use the Dto prefix.
9
- // A DtoField stores the database Field and the API-only extras separately.
10
-
11
- /** Re-export — Operator lives on the DSL level (see dsl.ts). */
12
- export type { Operator } from './dsl.js';
13
-
14
- /** Re-export — ImportBase lives on its own module (see import-base.ts). */
15
- export type { ImportBase } from './import-base.js';
16
-
17
- /** Re-export — MockDescriptor lives on its own module (see mock.ts). */
18
- export type { MockDescriptor } from './mock.js';
19
-
20
- /**
21
- * Reference to an existing TypeBox base schema by its import location.
22
- * Serializable metadata: the driver renders `import { name } from 'from'`
23
- * and `Type.Intersect([name, ...])` — the runtime schema is never loaded by the DSL.
24
- */
25
- export interface ImportRef extends ImportBase {
26
- /**
27
- * Generic type arguments for the base schema (e.g. PageResult(OrderRow)).
28
- * Two forms, both local DTOs:
29
- * string — the DTO export name
30
- * DtoMessage — the DTO instance itself; the driver resolves it to its name
31
- */
32
- args?: (string | DtoMessage)[];
33
- }
34
-
35
- export type DtoArrayFieldDef = BaseField & {
36
- type: 'array';
37
- jsType: 'array';
38
- /** Element type: inline DtoField, or a reference to an existing DTO (rendered by name) */
39
- items: DtoField | DtoMessage;
40
- };
41
-
42
- export type DtoObjectFieldDef = BaseField & {
43
- type: 'object';
44
- jsType: 'object';
45
- properties: Record<string, DtoField>;
46
- };
47
-
48
- export class DtoField implements SchemaBase {
49
- /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
50
- * 构造时未知,由 buildMessage 从 map key 反写。 */
51
- name: string;
52
- /** 字段描述 */
53
- description?: string;
54
- /** 所属容器(buildMessage / defineRouteData / definePageData 反写) */
55
- schema?: CollectionSchemaBase;
56
- field: Field | DtoArrayFieldDef | DtoObjectFieldDef;
57
- pattern?: string;
58
- optional?: boolean;
59
- operator?: Operator;
60
- /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
61
- default?: unknown;
62
-
63
- constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef) {
64
- this.name = '';
65
- this.field = field;
66
- }
67
-
68
- setPattern(value: string): this {
69
- this.pattern = value;
70
- return this;
71
- }
72
-
73
- setDescription(value: string): this {
74
- this.description = value;
75
- return this;
76
- }
77
-
78
- getDescription(): string | undefined {
79
- return this.description;
80
- }
81
-
82
- /** True when this field wraps a DB column (picked via from()); false for inline fields. */
83
- isColumn(): boolean {
84
- return this.field.schema?.type === 'table';
85
- }
86
-
87
- setOptional(value: boolean): this {
88
- this.optional = value;
89
- return this;
90
- }
91
-
92
- /** Set a default value — emitted as a TypeBox schema default annotation */
93
- setDefault(value: unknown): this {
94
- this.default = value;
95
- return this;
96
- }
97
-
98
- /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
99
- setOperator(value: Operator): this {
100
- this.operator = value;
101
- return this;
102
- }
103
-
104
- /** optional 优先于 field.optional */
105
- isOptional(): boolean {
106
- if (this.optional !== undefined) return this.optional;
107
- return this.field.optional ?? false;
108
- }
109
- }
110
-
111
- export class DtoArrayField extends DtoField {
112
- declare field: DtoArrayFieldDef;
113
-
114
- /** Element type: inline DtoField or a referenced DtoMessage (rendered by name). */
115
- items(): DtoField | DtoMessage {
116
- return this.field.items;
117
- }
118
- }
119
-
120
- export class DtoObjectField extends DtoField {
121
- declare field: DtoObjectFieldDef;
122
-
123
- properties(): Record<string, DtoField> {
124
- return this.field.properties;
125
- }
126
- }
127
-
128
- export enum DtoDirection {
129
- Input = 'input',
130
- Output = 'output',
131
- Query = 'query',
132
- Pk = 'pk',
133
- }
134
-
135
- export class DtoMessage implements CollectionSchemaBase {
136
- type = 'dto';
137
- name: string;
138
- description?: string;
139
- /** 方向:输入或输出 */
140
- direction: DtoDirection;
141
- fields: Record<string, DtoField>;
142
- /** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */
143
- bases: ImportRef[] = [];
144
-
145
- constructor(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string) {
146
- this.name = name;
147
- this.direction = direction;
148
- this.fields = fields;
149
- this.description = description;
150
- }
151
-
152
- /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
153
- include(...refs: ImportRef[]): this {
154
- this.bases.push(...refs);
155
- return this;
156
- }
157
- }
158
-
159
- export function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): DtoField {
160
- return new DtoField(field);
161
- }
162
-
163
- /** Structural check — DtoMessage instances may come from a different module copy, so instanceof is unreliable. */
164
- export function isDtoMessage(v: unknown): v is DtoMessage {
165
- if (typeof v !== 'object' || v === null) return false;
166
- return (v as Record<string, unknown>).type === 'dto';
167
- }
168
-
169
- /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */
170
- export function isDtoField(v: unknown): v is DtoField {
171
- if (typeof v !== 'object' || v === null) return false;
172
- return 'field' in v && !('type' in v);
173
- }
174
-
175
- export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
176
- // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
177
- // referenced by name (the driver renders Type.Array(<DtoName>)).
178
- return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
179
- }
180
-
181
- export function dtoObjectField(def: { properties: Record<string, DtoField> } & Omit<BaseField, 'name'>): DtoObjectField {
182
- return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
183
- }
184
-
185
- function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
186
- const message = new DtoMessage(name, direction, fields, description);
187
- // Write back the DTO field name from the map key (safe: DtoField instances
188
- // are created per DTO, never shared).
189
- for (const key of Object.keys(message.fields)) {
190
- const df = message.fields[key];
191
- if (!(df instanceof DtoField)) {
192
- const got = df === null || df === undefined ? String(df) : `${(df as object).constructor.name ?? typeof df}`;
193
- throw new Error(
194
- `[dto] DTO "${name}" field "${key}" must be a DtoField (created with dtoField()/dtoArrayField()/dtoObjectField()), got ${got}. ` +
195
- `Did you pass enumField(...) directly? Use dtoField(enumField({...})) instead.`
196
- );
197
- }
198
- df.name = key;
199
- df.schema = message;
200
- // Custom (inline) fields are owned by this DTO: write back name + schema.
201
- // Fields picked via from() share the database Field instance whose
202
- // name/schema already point to the table leave them untouched.
203
- if (df.field.schema === undefined) {
204
- df.field.name = key;
205
- df.field.schema = message;
206
- }
207
- writeBackNested(df, message);
208
- }
209
- return message;
210
- }
211
-
212
- /** Write back name/schema on nested DTO fields (array items, object
213
- * properties) — both plain-Field containers (objectField/arrayField) and
214
- * DtoField containers (dtoObjectField/dtoArrayField). DtoMessage item
215
- * references are skipped — they carry their own identity. */
216
- function writeBackNested(df: DtoField, message: DtoMessage): void {
217
- const f = df.field;
218
- if (f.type === 'array') {
219
- const items = f.items;
220
- if (isDtoMessage(items)) return;
221
- if (isDtoField(items)) {
222
- writeBackNested(items, message);
223
- return;
224
- }
225
- walkContainer(items, writeBackLeaf(message));
226
- return;
227
- }
228
- if (f.type === 'object') {
229
- for (const [key, child] of Object.entries(f.properties)) {
230
- if (isDtoField(child)) {
231
- child.name = key;
232
- child.schema = message;
233
- if (child.field.schema === undefined) {
234
- child.field.name = key;
235
- child.field.schema = message;
236
- }
237
- writeBackNested(child, message);
238
- } else {
239
- writeBackLeaf(message)(child, key);
240
- }
241
- }
242
- }
243
- }
244
-
245
- /** Name/schema write-back for a plain Field (own fields only — shared
246
- * instances keep their original identity). */
247
- function writeBackLeaf(message: DtoMessage): (f: Field, key?: string) => void {
248
- return (f, key) => {
249
- if (key !== undefined && f.schema === undefined) {
250
- f.name = key;
251
- f.schema = message;
252
- }
253
- };
254
- }
255
-
256
- export function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
257
- const message = buildMessage(name, DtoDirection.Input, fields, description);
258
- // Rule A — set optionality from the DB column rule (skips fields the author
259
- // already set): nullable / default → optional, else required.
260
- // PK columns are always required.
261
- for (const field of Object.values(message.fields)) {
262
- if (field.optional !== undefined) continue;
263
- const f = field.field as Field;
264
- if (f.schema?.type !== 'table') continue;
265
- const table = f.schema as TableSchema;
266
- field.optional = table.isPk(f) ? false : f.optional !== false || f.default !== undefined;
267
- }
268
- return message;
269
- }
270
-
271
- export function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
272
- return buildMessage(name, DtoDirection.Output, fields, description);
273
- }
274
-
275
- export function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
276
- const message = buildMessage(name, DtoDirection.Query, fields, description);
277
- // Rule B: query/search fields are always optional.
278
- for (const field of Object.values(message.fields)) field.optional = true;
279
- return message;
280
- }
281
-
282
- export function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
283
- const message = buildMessage(name, DtoDirection.Pk, fields, description);
284
- // Rule P: PK locator fields are required, other fields are optional.
285
- for (const field of Object.values(message.fields)) {
286
- if (field.optional !== undefined) continue;
287
- const f = field.field as Field;
288
- const table = f.schema?.type === 'table' ? (f.schema as TableSchema) : undefined;
289
- field.optional = table !== undefined && table.isPk(f) ? false : true;
290
- }
291
- return message;
292
- }
293
-
294
- /** Field-collection source a DTO can project from: a DB table, another DTO
295
- * message (protocol fields keep their names), or an entity (which may carry
296
- * aggregate fields). */
297
- export type DtoFieldSource = TableSchema | DtoMessage | EntitySchema;
298
-
299
- function ownsField(source: DtoFieldSource, field: Field | DtoArrayFieldDef | DtoObjectFieldDef): boolean {
300
- if (isDtoMessage(source)) {
301
- return Object.values(source.fields).some((df) => df.field === field);
302
- }
303
- return Object.values(source.columns).some((c) => c === field);
304
- }
305
-
306
- /** Project fields from a field-collection source (table, DTO message or
307
- * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field
308
- * instances keep their original identity — the projection references them. */
309
- export function from(
310
- source: DtoFieldSource,
311
- fields: (Field | DtoArrayFieldDef | DtoObjectFieldDef)[],
312
- ): Record<string, DtoField> {
313
- const out: Record<string, DtoField> = {};
314
- for (const field of fields) {
315
- if (!ownsField(source, field)) {
316
- throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${isDtoMessage(source) ? 'dto' : source.type}`);
317
- }
318
- // DB columns map to camelCase interface names (mer_id → merId); aggregate
319
- // field names are already camel and pass through; DTO message fields are
320
- // protocol names themselves and stay untouched.
321
- out[isDtoMessage(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
322
- }
323
- return out;
1
+ import { BaseField, CollectionSchemaBase, Field, Operator, SchemaBase, walkContainer } from './dsl.js';
2
+ import { TableSchema } from './db.js';
3
+ import type { EntitySchema } from './entity.js';
4
+ import type { ImportBase } from './import-base.js';
5
+ import { toCamelCase } from '@pylonts/core';
6
+
7
+ // Interface (DTO) field definitions.
8
+ // Naming convention: all DTO types and builders use the Dto prefix.
9
+ // A DtoField stores the database Field and the API-only extras separately.
10
+
11
+ /** Re-export — Operator lives on the DSL level (see dsl.ts). */
12
+ export type { Operator } from './dsl.js';
13
+
14
+ /** Re-export — ImportBase lives on its own module (see import-base.ts). */
15
+ export type { ImportBase } from './import-base.js';
16
+
17
+ /** Re-export — MockDescriptor lives on its own module (see mock.ts). */
18
+ export type { MockDescriptor } from './mock.js';
19
+
20
+ /**
21
+ * Reference to an existing TypeBox base schema by its import location.
22
+ * Serializable metadata: the driver renders `import { name } from 'from'`
23
+ * and `Type.Intersect([name, ...])` — the runtime schema is never loaded by the DSL.
24
+ */
25
+ export interface ImportRef extends ImportBase {
26
+ /**
27
+ * Generic type arguments for the base schema (e.g. PageResult(OrderRow)).
28
+ * Two forms, both local DTOs:
29
+ * string — the DTO export name
30
+ * DtoMessage — the DTO instance itself; the driver resolves it to its name
31
+ */
32
+ args?: (string | DtoMessage)[];
33
+ }
34
+
35
+ export type DtoArrayFieldDef = BaseField & {
36
+ type: 'array';
37
+ jsType: 'array';
38
+ /** Element type: inline DtoField, or a reference to an existing DTO (rendered by name) */
39
+ items: DtoField | DtoMessage;
40
+ };
41
+
42
+ export type DtoObjectFieldDef = BaseField & {
43
+ type: 'object';
44
+ jsType: 'object';
45
+ properties: Record<string, DtoField>;
46
+ };
47
+
48
+ export class DtoField implements SchemaBase {
49
+ /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
50
+ * 构造时未知,由 buildMessage 从 map key 反写。 */
51
+ name: string;
52
+ /** 字段描述 */
53
+ description?: string;
54
+ /** 所属容器(buildMessage / defineRouteData / definePageData 反写) */
55
+ schema?: CollectionSchemaBase;
56
+ field: Field | DtoArrayFieldDef | DtoObjectFieldDef;
57
+ pattern?: string;
58
+ optional?: boolean;
59
+ operator?: Operator;
60
+ /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
61
+ default?: unknown;
62
+ /** Optional reference to another DtoField — this field reuses the referenced field's type/constraints */
63
+ ref?: DtoField;
64
+
65
+ constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef) {
66
+ this.name = '';
67
+ this.field = field;
68
+ }
69
+
70
+ setPattern(value: string): this {
71
+ this.pattern = value;
72
+ return this;
73
+ }
74
+
75
+ setDescription(value: string): this {
76
+ this.description = value;
77
+ return this;
78
+ }
79
+
80
+ getDescription(): string | undefined {
81
+ return this.description;
82
+ }
83
+
84
+ /** True when this field wraps a DB column (picked via from()); false for inline fields. */
85
+ isColumn(): boolean {
86
+ return this.field.schema?.type === 'table';
87
+ }
88
+
89
+ setOptional(value: boolean): this {
90
+ this.optional = value;
91
+ return this;
92
+ }
93
+
94
+ /** Set a default value — emitted as a TypeBox schema default annotation */
95
+ setDefault(value: unknown): this {
96
+ this.default = value;
97
+ return this;
98
+ }
99
+
100
+ /** Reference another DtoField — this field reuses the referenced field's type/constraints */
101
+ setRef(value: DtoField): this {
102
+ this.ref = value;
103
+ return this;
104
+ }
105
+
106
+ /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
107
+ setOperator(value: Operator): this {
108
+ this.operator = value;
109
+ return this;
110
+ }
111
+
112
+ /** optional 优先于 field.optional */
113
+ isOptional(): boolean {
114
+ if (this.optional !== undefined) return this.optional;
115
+ return this.field.optional ?? false;
116
+ }
117
+ }
118
+
119
+ export class DtoArrayField extends DtoField {
120
+ declare field: DtoArrayFieldDef;
121
+
122
+ /** Element type: inline DtoField or a referenced DtoMessage (rendered by name). */
123
+ items(): DtoField | DtoMessage {
124
+ return this.field.items;
125
+ }
126
+ }
127
+
128
+ export class DtoObjectField extends DtoField {
129
+ declare field: DtoObjectFieldDef;
130
+
131
+ properties(): Record<string, DtoField> {
132
+ return this.field.properties;
133
+ }
134
+ }
135
+
136
+ export enum DtoDirection {
137
+ Input = 'input',
138
+ Output = 'output',
139
+ Query = 'query',
140
+ Pk = 'pk',
141
+ }
142
+
143
+ export class DtoMessage implements CollectionSchemaBase {
144
+ type = 'dto';
145
+ name: string;
146
+ description?: string;
147
+ /** 方向:输入或输出 */
148
+ direction: DtoDirection;
149
+ fields: Record<string, DtoField>;
150
+ /** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */
151
+ bases: ImportRef[] = [];
152
+
153
+ constructor(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string) {
154
+ this.name = name;
155
+ this.direction = direction;
156
+ this.fields = fields;
157
+ this.description = description;
158
+ }
159
+
160
+ /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
161
+ include(...refs: ImportRef[]): this {
162
+ this.bases.push(...refs);
163
+ return this;
164
+ }
165
+ }
166
+
167
+ export function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): DtoField {
168
+ return new DtoField(field);
169
+ }
170
+
171
+ /** Structural check DtoMessage instances may come from a different module copy, so instanceof is unreliable. */
172
+ export function isDtoMessage(v: unknown): v is DtoMessage {
173
+ if (typeof v !== 'object' || v === null) return false;
174
+ return (v as Record<string, unknown>).type === 'dto';
175
+ }
176
+
177
+ /** Structural check a DtoField wraps a Field in a .field property and has no .type of its own. */
178
+ export function isDtoField(v: unknown): v is DtoField {
179
+ if (typeof v !== 'object' || v === null) return false;
180
+ return 'field' in v && !('type' in v);
181
+ }
182
+
183
+ export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
184
+ // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
185
+ // referenced by name (the driver renders Type.Array(<DtoName>)).
186
+ return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
187
+ }
188
+
189
+ export function dtoObjectField(def: { properties: Record<string, DtoField> } & Omit<BaseField, 'name'>): DtoObjectField {
190
+ return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
191
+ }
192
+
193
+ function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
194
+ const message = new DtoMessage(name, direction, fields, description);
195
+ // Write back the DTO field name from the map key (safe: DtoField instances
196
+ // are created per DTO, never shared).
197
+ for (const key of Object.keys(message.fields)) {
198
+ const df = message.fields[key];
199
+ if (!(df instanceof DtoField)) {
200
+ const got = df === null || df === undefined ? String(df) : `${(df as object).constructor.name ?? typeof df}`;
201
+ throw new Error(
202
+ `[dto] DTO "${name}" field "${key}" must be a DtoField (created with dtoField()/dtoArrayField()/dtoObjectField()), got ${got}. ` +
203
+ `Did you pass enumField(...) directly? Use dtoField(enumField({...})) instead.`
204
+ );
205
+ }
206
+ df.name = key;
207
+ df.schema = message;
208
+ // Custom (inline) fields are owned by this DTO: write back name + schema.
209
+ // Fields picked via from() share the database Field instance whose
210
+ // name/schema already point to the table — leave them untouched.
211
+ if (df.field.schema === undefined) {
212
+ df.field.name = key;
213
+ df.field.schema = message;
214
+ }
215
+ writeBackNested(df, message);
216
+ }
217
+ return message;
218
+ }
219
+
220
+ /** Write back name/schema on nested DTO fields (array items, object
221
+ * properties) — both plain-Field containers (objectField/arrayField) and
222
+ * DtoField containers (dtoObjectField/dtoArrayField). DtoMessage item
223
+ * references are skipped — they carry their own identity. */
224
+ function writeBackNested(df: DtoField, message: DtoMessage): void {
225
+ const f = df.field;
226
+ if (f.type === 'array') {
227
+ const items = f.items;
228
+ if (isDtoMessage(items)) return;
229
+ if (isDtoField(items)) {
230
+ writeBackNested(items, message);
231
+ return;
232
+ }
233
+ walkContainer(items, writeBackLeaf(message));
234
+ return;
235
+ }
236
+ if (f.type === 'object') {
237
+ for (const [key, child] of Object.entries(f.properties)) {
238
+ if (isDtoField(child)) {
239
+ child.name = key;
240
+ child.schema = message;
241
+ if (child.field.schema === undefined) {
242
+ child.field.name = key;
243
+ child.field.schema = message;
244
+ }
245
+ writeBackNested(child, message);
246
+ } else {
247
+ writeBackLeaf(message)(child, key);
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ /** Name/schema write-back for a plain Field (own fields only — shared
254
+ * instances keep their original identity). */
255
+ function writeBackLeaf(message: DtoMessage): (f: Field, key?: string) => void {
256
+ return (f, key) => {
257
+ if (key !== undefined && f.schema === undefined) {
258
+ f.name = key;
259
+ f.schema = message;
260
+ }
261
+ };
262
+ }
263
+
264
+ export function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
265
+ const message = buildMessage(name, DtoDirection.Input, fields, description);
266
+ // Rule A set optionality from the DB column rule (skips fields the author
267
+ // already set): nullable / default → optional, else required.
268
+ // PK columns are always required.
269
+ for (const field of Object.values(message.fields)) {
270
+ if (field.optional !== undefined) continue;
271
+ const f = field.field as Field;
272
+ if (f.schema?.type !== 'table') continue;
273
+ const table = f.schema as TableSchema;
274
+ field.optional = table.isPk(f) ? false : f.optional !== false || f.default !== undefined;
275
+ }
276
+ return message;
277
+ }
278
+
279
+ export function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
280
+ return buildMessage(name, DtoDirection.Output, fields, description);
281
+ }
282
+
283
+ export function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
284
+ const message = buildMessage(name, DtoDirection.Query, fields, description);
285
+ // Rule B: query/search fields are always optional.
286
+ for (const field of Object.values(message.fields)) field.optional = true;
287
+ return message;
288
+ }
289
+
290
+ export function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
291
+ const message = buildMessage(name, DtoDirection.Pk, fields, description);
292
+ // Rule P: PK locator fields are required, other fields are optional.
293
+ for (const field of Object.values(message.fields)) {
294
+ if (field.optional !== undefined) continue;
295
+ const f = field.field as Field;
296
+ const table = f.schema?.type === 'table' ? (f.schema as TableSchema) : undefined;
297
+ field.optional = table !== undefined && table.isPk(f) ? false : true;
298
+ }
299
+ return message;
300
+ }
301
+
302
+ /** Field-collection source a DTO can project from: a DB table, another DTO
303
+ * message (protocol fields keep their names), or an entity (which may carry
304
+ * aggregate fields). */
305
+ export type DtoFieldSource = TableSchema | DtoMessage | EntitySchema;
306
+
307
+ function ownsField(source: DtoFieldSource, field: Field | DtoArrayFieldDef | DtoObjectFieldDef): boolean {
308
+ if (isDtoMessage(source)) {
309
+ return Object.values(source.fields).some((df) => df.field === field);
310
+ }
311
+ return Object.values(source.columns).some((c) => c === field);
312
+ }
313
+
314
+ /** Project fields from a field-collection source (table, DTO message or
315
+ * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field
316
+ * instances keep their original identity the projection references them. */
317
+ export function from(
318
+ source: DtoFieldSource,
319
+ fields: (Field | DtoArrayFieldDef | DtoObjectFieldDef)[],
320
+ ): Record<string, DtoField> {
321
+ const out: Record<string, DtoField> = {};
322
+ for (const field of fields) {
323
+ if (!ownsField(source, field)) {
324
+ throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${isDtoMessage(source) ? 'dto' : source.type}`);
325
+ }
326
+ // DB columns map to camelCase interface names (mer_id → merId); aggregate
327
+ // field names are already camel and pass through; DTO message fields are
328
+ // protocol names themselves and stay untouched.
329
+ out[isDtoMessage(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
330
+ }
331
+ return out;
324
332
  }