@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.
- package/README.md +1 -0
- package/dist/aggregate.d.ts +3 -13
- package/dist/aggregate.js +19 -22
- package/dist/dto.d.ts +21 -2
- package/dist/dto.js +89 -9
- package/dist/flow-script.js +73 -7
- package/dist/flow.d.ts +20 -3
- package/dist/flow.js +82 -11
- package/dist/mermaid-driver.js +6 -1
- package/dist/repository.d.ts +2 -2
- package/dist/typebox-driver.js +19 -22
- package/dist/utils.d.ts +9 -2
- package/dist/utils.js +19 -7
- package/docs/aggregate-implementation.md +174 -0
- package/docs/aggregate.md +49 -12
- package/docs/dto.md +130 -73
- package/docs/table.md +41 -0
- package/docs/utils.md +111 -0
- package/package.json +1 -1
- package/src/aggregate.ts +32 -41
- package/src/dto.ts +102 -8
- package/src/flow-script.ts +68 -6
- package/src/flow.ts +99 -17
- package/src/mermaid-driver.ts +5 -1
- package/src/repository.ts +2 -2
- package/src/typebox-driver.ts +19 -23
- package/src/utils.ts +27 -9
package/docs/table.md
CHANGED
|
@@ -131,6 +131,47 @@ export const audit = defineTable('audit', {
|
|
|
131
131
|
|
|
132
132
|
> **外键是逻辑作用**:`foreignKeys` 用于定义期命名强校验与关系表达,**DDL 默认不渲染物理 FOREIGN KEY 约束**(`pylonts gen sql init` 不传 `generateForeignKeys`)。数据完整性由 Service/DAO 层保证;如需物理约束,调用 `buildCreateTableSql(schema, { generateForeignKeys: true })`。
|
|
133
133
|
|
|
134
|
+
## 扩展表(extends)
|
|
135
|
+
|
|
136
|
+
扩展表表示“本表是某张根表的延伸”:主键与根表主键同义、类型一致,生命周期跟随根表(创建 / 保存 / 删除一起做)。除这两条外,扩展表与普通表完全一样,可以有索引、外键、被其他表引用等。
|
|
137
|
+
|
|
138
|
+
### 定义
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// order 根表
|
|
142
|
+
export const order = defineTable('order', {
|
|
143
|
+
...
|
|
144
|
+
primaryKey: id,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// order_address 是 order 的扩展表
|
|
148
|
+
const addressId = stringField({ maxLength: 32 }); // 与 order.id 同类型
|
|
149
|
+
|
|
150
|
+
export const orderAddress = defineTable('order_address', {
|
|
151
|
+
extends: order, // 声明本表是 order 的扩展
|
|
152
|
+
primaryKey: addressId, // 主键与根主键同义
|
|
153
|
+
columns: {
|
|
154
|
+
id: addressId,
|
|
155
|
+
receiver_name: stringField({ label: '收货人', maxLength: 32, optional: false }),
|
|
156
|
+
...
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 规则
|
|
162
|
+
|
|
163
|
+
- 扩展表的主键与根表主键同义,类型必须一致;
|
|
164
|
+
- 根表不能是扩展表;
|
|
165
|
+
- 扩展表不能再 `extends`(禁止链式延伸);
|
|
166
|
+
- 一个根表可以有多个扩展表;
|
|
167
|
+
- 除主键和生命周期外,扩展表与普通表完全一样:可以有普通外键、索引、枚举等,也可被其他表引用;
|
|
168
|
+
- 扩展表不需要像普通外键那样命名 `{phrase}_{field}`,也不需要显式声明 `foreignKeys` 来表达与根的关系,`extends` 本身就是关系。
|
|
169
|
+
|
|
170
|
+
### 与聚合的关系
|
|
171
|
+
|
|
172
|
+
- `members: { items: [orderItem] }`:数组 → 普通表 → 一对多;
|
|
173
|
+
- `members: { address: orderAddress }`:非数组 → 扩展表 → 一对一。
|
|
174
|
+
|
|
134
175
|
## 生成 SQL
|
|
135
176
|
|
|
136
177
|
见 [driver.md](./driver.md)。
|
package/docs/utils.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# 工具模块与领域规则(UtilsSchema)
|
|
2
|
+
|
|
3
|
+
UtilsSchema 是**业务无关工具模块**(base utility modules)的声明:带完整签名的纯函数集合,在 service 层被调用(含 flow 的 `invoke`)。方法签名(args/result)由 schema 声明,方法体由用户实现。
|
|
4
|
+
|
|
5
|
+
**当 utils 名称与某张表同名时,它就是该实体的领域规则集**(DDD 领域服务落点)——命名即归属、位置即角色:`utils_schema/.../order.utils.ts` = order 表的领域规则(`OrderUtils`)。
|
|
6
|
+
|
|
7
|
+
## 定义
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineUtils, booleanField, decimalField, dtoField, intField, stringField } from '@pylonts/dsl';
|
|
11
|
+
import { api, miniuser } from '../../../../project.config';
|
|
12
|
+
import { order } from '../../../../schema/order.table';
|
|
13
|
+
|
|
14
|
+
const orderUtils = defineUtils({
|
|
15
|
+
name: 'OrderUtils',
|
|
16
|
+
api,
|
|
17
|
+
app: miniuser,
|
|
18
|
+
description: '订单领域规则',
|
|
19
|
+
methods: {
|
|
20
|
+
// 防御 guard:条件抛错,通过无返回值(void),失败即 throw
|
|
21
|
+
assertCancelable: {
|
|
22
|
+
args: { status: dtoField(order.columns.status) },
|
|
23
|
+
},
|
|
24
|
+
// 判断:返回 boolean 判定值,供 flow IF 条件位使用
|
|
25
|
+
canCancel: {
|
|
26
|
+
args: { status: dtoField(order.columns.status) },
|
|
27
|
+
result: booleanField(),
|
|
28
|
+
},
|
|
29
|
+
// 计算:返回派生标量
|
|
30
|
+
calcTotal: {
|
|
31
|
+
args: { unitPrice: dtoField(decimalField({ precision: 10, scale: 2 })), qty: dtoField(intField({ min: 1 })) },
|
|
32
|
+
result: decimalField({ precision: 10, scale: 2 }),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// 必须 default-export 方法引用对象(XXUtils 约定),loader 从 refs 反推 schema
|
|
38
|
+
export default {
|
|
39
|
+
assertCancelable: orderUtils.methods.assertCancelable,
|
|
40
|
+
canCancel: orderUtils.methods.canCancel,
|
|
41
|
+
calcTotal: orderUtils.methods.calcTotal,
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 方法形态
|
|
46
|
+
|
|
47
|
+
- **args**:`Record<string, DtoField>`——用 `dtoField(...)` 包装。可包装**表列**(`dtoField(order.columns.status)`,领域规则引用表字段的标准方式)或内联字段(`dtoField(stringField(...))`)。包装层反写不污染共享表列实例(与 `buildMessage` 同规则:仅内联字段写回底层)。
|
|
48
|
+
- **result**:`Field | undefined`——输出是全新值(boolean 判断、decimal 计算、string 派生文案),**不是共享表列**;省略 = void 方法(防御 guard 通过时无返回值)。
|
|
49
|
+
- **throws**:`ExceptionSchema[] | undefined`——方法可抛的异常清单,**防御 guard 的失败契约**。flow 里 `invoke` 防御 guard 时,其 throws 自动进入流程逃逸集——调用方 service 方法的 throws 必须包含它(或 TRY 捕获)。谓词(can/is/has)不抛,无需声明。
|
|
50
|
+
- **失败语义**:规则失败直接 `throw`(配合 `throws` 声明的 `BusinessException`/`CodeException` 通道),**不返回错误码结构**。
|
|
51
|
+
|
|
52
|
+
## 文件与存储约定
|
|
53
|
+
|
|
54
|
+
- 一文件一 utils;文件必须 **default-export 方法引用对象**(`export default { m: xxUtils.methods.m }`),schema 变量模块私有不导出。
|
|
55
|
+
- 文件名 = 名字去 `Utils`/`Util` 后缀转 kebab:`OrderUtils → order.utils.ts`、`AmtUtils → amt.utils.ts`。
|
|
56
|
+
- 存储位置按绑定(机器校验,`lint utils`):
|
|
57
|
+
|
|
58
|
+
| 绑定 | 目录(utils_schema/) | 生成物 |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| api + app | `{api}/{app}/utils/` | `{api}/src/modules/{app}/utils/{Name}.ts` |
|
|
61
|
+
| 仅 api | `{api}/common/utils/` | `{api}/src/modules/common/utils/`(后端公共) |
|
|
62
|
+
| 仅 app | `{app}/utils/` | `{app.dir}/src/utils/`(前端) |
|
|
63
|
+
| 无绑定 | `common/utils/` 或 `shared/utils/` | `{root}/shared/utils/`(前后端共享) |
|
|
64
|
+
|
|
65
|
+
## 领域规则三分类
|
|
66
|
+
|
|
67
|
+
从纯函数视角,方法的出口只有两种:**返回值**或**抛异常**。因此领域规则方法恰好三类:
|
|
68
|
+
|
|
69
|
+
| 分类 | 命名 | 本质 | result | throws | 失败语义 | 典型示例 |
|
|
70
|
+
|---|---|---|---|---|---|---|
|
|
71
|
+
| **1 防御 guard** | `assertXX` / `validateXX` / `ensureXX` | 条件抛错(前置校验) | `void` | **必填**(失败契约) | **失败必抛错**,通过无返回值 | `assertCancelable`——已完成/已取消订单 throw `BusinessException` |
|
|
72
|
+
| **2 判断** | `canXX` / `isXX` / `hasXX` | 返回判定值(谓词) | `boolean` | 无 | 返回 false,不抛 | `canCancel`、`isExpired`——供 flow `IF(...)` 条件位 |
|
|
73
|
+
| **3 计算** | `calcXX` / `formatXX` / 动词 | 派生值计算 | 标量(金额/数量/文案) | 一般不声明 | 一般不抛(入参合法前提) | `calcTotal`——金额计算 |
|
|
74
|
+
|
|
75
|
+
### 为什么这就是全集
|
|
76
|
+
|
|
77
|
+
- **判断 = 计算的特例**:boolean/枚举也是值,只是返回值窄化——单独分类是为 flow 谓词位(`IF(canCancel(slots.status))`)的语义清晰,不是本质区别。
|
|
78
|
+
- **防御 guard = 判断 + 抛错**:guard 内部必然先判断再 throw,是"判断"失败分支的显式化;通过时没有调用方需要的值,所以返回 `void`。
|
|
79
|
+
- **void 方法只能是防御 guard**:纯函数无副作用,不返回也不抛 = 什么都没做——void 方法若默认通过、特殊状态才抛,就是 guard 的语义。
|
|
80
|
+
|
|
81
|
+
### 命名与返回的一致性(机器校验,已落地)
|
|
82
|
+
|
|
83
|
+
- `assert/validate/ensure` 前缀 → **必须 void + 必须声明 throws**(guard:通过无值、失败抛契约异常);
|
|
84
|
+
- `can/is/has` 前缀 → **必须 boolean**(判断谓词,进 flow `IF` 条件位);
|
|
85
|
+
- 计算类 → 标量 result。
|
|
86
|
+
|
|
87
|
+
**双重校验**:
|
|
88
|
+
|
|
89
|
+
1. **声明侧**(`pylonts lint utils`):命名与 result 形态不匹配(如 `canXX` 返回 void、`assertXX` 返回 boolean)即违规报错;防御 guard 缺 throws 声明(失败契约缺失)同样违规。
|
|
90
|
+
2. **使用侧**(flow 编译期,`defineFlow` 校验):条件位谓词必须声明 boolean result——`IF(invoke(xxUtils.canCancel, ...))` 通过;`IF(invoke(xxUtils.assertCancelable, ...))`(void 守卫)和标量方法进 IF 直接报错,提示"守卫应 invoke 而非 IF"。
|
|
91
|
+
|
|
92
|
+
**逃逸集联动**:`invoke` 防御 guard 时,guard 的 throws 自动并入流程逃逸集(与 dao/third 的 throws 同规则)——调用方 service 方法契约因此必须声明该异常(或 TRY 捕获),守卫的失败成为可验证的契约,而不是隐式冒泡。
|
|
93
|
+
|
|
94
|
+
## 建议(best practices)
|
|
95
|
+
|
|
96
|
+
1. **命名即归属**:utils 名与表名一致 = 该实体领域规则集(`OrderUtils` ↔ `order`)。业务无关的通用工具(`AmtUtils`/`DateTimeUtils`)不绑定表,放共享目录。
|
|
97
|
+
2. **规则二分定性**(既定决策):**不查库 → utils 谓词**(纯函数);**查库 → service 断言方法**(`invoke(dao.findByName) → IF(isNotNull) → THROW`);**硬不变量 → 表约束**(TableSchema unique/FK,DB 物理兜底)。不建 RuleSchema——规则已有 schema:表/谓词/守卫。
|
|
98
|
+
3. **失败抛错,不返回错误码结构**:校验失败 `throw new Error('ORDER_CANNOT_CANCEL: ...')` 或业务异常类;flow 的 TRY-CATCH 按异常名捕获。
|
|
99
|
+
4. **复用判据**:规则被 ≥2 处使用才提取为 utils;单处使用留在 flow 守卫片段("三行相似胜过过早抽象")。
|
|
100
|
+
5. **纯函数、无 I/O**:utils 不做数据库/网络访问;需要 I/O 的规则属于 service。
|
|
101
|
+
6. **表列引用用 `dtoField(table.columns.x)` 包装**:包装层反写安全,共享列实例永不被动;参数名可以与列名不同(如 `state` 包装 `status` 列)。
|
|
102
|
+
7. **result 选型与命名一致**:防御 guard → **`void`** + `assert/validate/ensure` 前缀(抛错即语义);判断 → `booleanField()`(或枚举)+ `can/is/has` 前缀(供 flow 谓词位);计算 → `decimalField`/`intField`/`stringField` 标量。
|
|
103
|
+
8. **flow 集成**:判断类可直接作 `IF(cond)` 条件位谓词(多入参靠 invoke 多槽位),`gen service --flow` 渲染为 `OrderUtils.canCancel(body.status)`;防御 guard 通过 `invoke` 在流程前置位调用,渲染为 `OrderUtils.assertCancelable(body.status);`(void、内部 throw)。**计算类绑定标量槽**:flow 声明 `slots: { total: decimalField(...) }`,`invoke(calcTotal, args, slots.total)` 渲染 `const total: string = OrderUtils.calcTotal(body.unitPrice);`,标量槽可直接进守卫比较(`IF(gt(slots.total, 100))` → `if (Number(total) > 100)`)或作后续调用的标量参数(jsType 匹配直传)。
|
|
104
|
+
|
|
105
|
+
## 生成与合并
|
|
106
|
+
|
|
107
|
+
`pylonts gen utils`:生成 `export class OrderUtils { static ... }` 骨架(每方法 `Not implemented` throw),签名由 schema 推导(enum → 枚举类型名 + 自动 import;date/datetime → string;无 result → `void`)。已存在文件**方法级 MERGE**(用户填充的方法体保留、私有 helper 不碰、契约删方法移除、新方法追加 stub);`--force` 整文件覆盖。覆盖规范见 [gen-utils-overwrite.md](../../docs/gen-utils-overwrite.md)。
|
|
108
|
+
|
|
109
|
+
## 校验
|
|
110
|
+
|
|
111
|
+
`pylonts lint utils`:存储位置 + 绑定共享实例 + 文件名 + **命名↔result 一致性**(assert/validate/ensure → void,can/is/has → boolean)机器校验(`lint all` 已包含)。使用侧谓词 boolean 校验在 flow 编译期(`defineFlow`)执行。
|
package/package.json
CHANGED
package/src/aggregate.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import type { SchemaBase } from './dsl.js';
|
|
2
|
-
import type { TableSchema
|
|
2
|
+
import type { TableSchema } from './db.js';
|
|
3
3
|
|
|
4
4
|
// Aggregate declaration: groups multiple tables into one domain concept with
|
|
5
|
-
// a root table, member
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// a root table, member tables, cross-member invariants and inter-aggregate
|
|
6
|
+
// reference rules. This turns "multi-table consistency" from a convention
|
|
7
|
+
// (hand-written in flows) into a constraint (lintable, codegen-able).
|
|
8
|
+
//
|
|
9
|
+
// Members are intentionally minimal:
|
|
10
|
+
// members: { items: [orderItem] } -> array = 1:N (normal table)
|
|
11
|
+
// members: { address: orderAddress } -> single = 1:1 (extension table;
|
|
12
|
+
// designed but not implemented yet)
|
|
8
13
|
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
11
|
-
/** The member table (shared instance from schema/*.table.ts). */
|
|
12
|
-
table: TableSchema;
|
|
13
|
-
/** The foreign key on the member table pointing to the root table.
|
|
14
|
-
* Must exist in table.foreignKeys and its references must be the root's PK
|
|
15
|
-
* columns. Defaults to the (unique) FK referencing the root table. */
|
|
16
|
-
via?: ForeignKey;
|
|
17
|
-
/** 1:1 member (unique constraint on via.columns) vs 1:N (default). */
|
|
18
|
-
one?: boolean;
|
|
19
|
-
}
|
|
14
|
+
/** Member table(s) keyed by role name. Array = 1:N; non-array = 1:1 extension. */
|
|
15
|
+
export type AggregateMember = TableSchema | TableSchema[];
|
|
20
16
|
|
|
21
17
|
/** A cross-member invariant, checked by generated repository code. */
|
|
22
18
|
export interface AggregateInvariant {
|
|
@@ -38,7 +34,6 @@ export interface DomainAggregate extends SchemaBase {
|
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
export function defineAggregate(options: {
|
|
41
|
-
name: string;
|
|
42
37
|
root: TableSchema;
|
|
43
38
|
members?: Record<string, AggregateMember>;
|
|
44
39
|
invariants?: AggregateInvariant[];
|
|
@@ -47,7 +42,7 @@ export function defineAggregate(options: {
|
|
|
47
42
|
}): DomainAggregate {
|
|
48
43
|
const schema: DomainAggregate = {
|
|
49
44
|
type: 'aggregate',
|
|
50
|
-
name: options.name,
|
|
45
|
+
name: options.root.name,
|
|
51
46
|
description: options.description,
|
|
52
47
|
root: options.root,
|
|
53
48
|
members: options.members ?? {},
|
|
@@ -57,46 +52,42 @@ export function defineAggregate(options: {
|
|
|
57
52
|
|
|
58
53
|
// Root must have a primary key (aggregate identity).
|
|
59
54
|
if (options.root.primaryKey === undefined) {
|
|
60
|
-
throw new Error(`aggregate '${
|
|
55
|
+
throw new Error(`aggregate '${schema.name}': root table '${options.root.name}' must have a primary key`);
|
|
61
56
|
}
|
|
62
57
|
|
|
63
|
-
// Each member must attach to the root via an existing FK referencing the root.
|
|
64
58
|
const rootPkRefs = Array.isArray(options.root.primaryKey)
|
|
65
59
|
? options.root.primaryKey
|
|
66
60
|
: [options.root.primaryKey];
|
|
61
|
+
|
|
62
|
+
// Each member must attach to the root. Array members are normal 1:N tables
|
|
63
|
+
// and must have exactly one FK referencing the root. Non-array (1:1 extension)
|
|
64
|
+
// members are designed but not implemented yet.
|
|
67
65
|
for (const [role, member] of Object.entries(schema.members)) {
|
|
68
|
-
|
|
69
|
-
(
|
|
70
|
-
const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
|
|
71
|
-
return refs.every((r) => rootPkRefs.includes(r)) && refs.length === rootPkRefs.length;
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
if (fks.length === 0) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`aggregate '${options.name}': member '${role}' table '${member.table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
if (member.via !== undefined) {
|
|
80
|
-
const viaKeys = Object.values(member.table.foreignKeys ?? {});
|
|
81
|
-
if (!viaKeys.includes(member.via)) {
|
|
66
|
+
if (Array.isArray(member)) {
|
|
67
|
+
if (member.length !== 1) {
|
|
82
68
|
throw new Error(
|
|
83
|
-
`aggregate '${
|
|
69
|
+
`aggregate '${schema.name}': member '${role}' array must contain exactly one table schema`,
|
|
84
70
|
);
|
|
85
71
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
72
|
+
const table = member[0];
|
|
73
|
+
const fks = Object.values(table.foreignKeys ?? {}).filter((fk) => {
|
|
74
|
+
const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
|
|
75
|
+
return refs.length === rootPkRefs.length && refs.every((r) => rootPkRefs.includes(r));
|
|
76
|
+
});
|
|
77
|
+
if (fks.length === 0) {
|
|
88
78
|
throw new Error(
|
|
89
|
-
`aggregate '${
|
|
79
|
+
`aggregate '${schema.name}': member '${role}' table '${table.name}' has no foreign key referencing root '${options.root.name}' — declare one in the table's foreignKeys`,
|
|
90
80
|
);
|
|
91
81
|
}
|
|
92
|
-
} else {
|
|
93
|
-
// Default: the (single) FK referencing the root. More than one → must declare via.
|
|
94
82
|
if (fks.length > 1) {
|
|
95
83
|
throw new Error(
|
|
96
|
-
`aggregate '${
|
|
84
|
+
`aggregate '${schema.name}': member '${role}' table '${table.name}' has ${fks.length} foreign keys referencing root '${options.root.name}' — reduce to one FK for minimal aggregate declarations`,
|
|
97
85
|
);
|
|
98
86
|
}
|
|
99
|
-
|
|
87
|
+
} else {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`aggregate '${schema.name}': member '${role}' is a non-array table — 1:1 extension tables are designed but not implemented yet; use an array for 1:N members`,
|
|
90
|
+
);
|
|
100
91
|
}
|
|
101
92
|
}
|
|
102
93
|
|
package/src/dto.ts
CHANGED
|
@@ -185,13 +185,70 @@ export function isDtoField(v: unknown): v is DtoField {
|
|
|
185
185
|
return 'field' in v && !('type' in v);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
/**
|
|
189
|
-
*
|
|
188
|
+
/** Resolve a ref chain to its terminal DtoField (the one without .ref).
|
|
189
|
+
* Cycles are a DSL definition error — fail loudly at render time. */
|
|
190
|
+
export function resolveDtoRefChain(f: DtoField): DtoField {
|
|
191
|
+
const seen = new Set<DtoField>();
|
|
192
|
+
let cur: DtoField = f;
|
|
193
|
+
while (cur.ref !== undefined) {
|
|
194
|
+
if (seen.has(cur.ref)) {
|
|
195
|
+
throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
|
|
196
|
+
}
|
|
197
|
+
seen.add(cur.ref);
|
|
198
|
+
cur = cur.ref;
|
|
199
|
+
}
|
|
200
|
+
return cur;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** TS type of a DtoField in generated code.
|
|
204
|
+
* A field shared by reference (its schema is the owning DTO — utils args
|
|
205
|
+
* like `args: { items: OrderSubmitRequest.fields.items }`) renders as an
|
|
206
|
+
* indexed access on the DTO's generated type (the DTO owns the structure).
|
|
207
|
+
* Array elements render by name (`ItemDto[]` — named DTO) or by recursion
|
|
208
|
+
* (`Array<string>` — scalar). Plain wire objects (objectField) render
|
|
209
|
+
* their property shape (`{ key: type }`); DtoField-class containers are
|
|
210
|
+
* rejected at build time (DTOs must not nest inline structures).
|
|
211
|
+
* Enum → its JS name, date/datetime → string. */
|
|
190
212
|
export function dtoFieldJsType(df: DtoField): string {
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
213
|
+
const owner = df.schema as { type?: string; name?: string } | undefined;
|
|
214
|
+
if (owner?.type === 'dto' && owner.name !== undefined && owner.name !== '' && df.name !== '') {
|
|
215
|
+
return `${owner.name}['${df.name}']`;
|
|
216
|
+
}
|
|
217
|
+
return dtoFieldJsTypeInner(df.field);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Type of a raw field object — unwraps DtoField-class wrappers
|
|
221
|
+
* (dtoField(dtoArrayField(...)) stores the def inside the instance's
|
|
222
|
+
* .field) and recurses: named-DTO elements (Name[]), scalar elements
|
|
223
|
+
* (Array<T>), plain wire objects ({ key: type }), enums, and scalars. */
|
|
224
|
+
function dtoFieldJsTypeInner(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): string {
|
|
225
|
+
const f = (field as { field?: unknown }).field ?? field;
|
|
226
|
+
const inner = f as {
|
|
227
|
+
type?: string;
|
|
228
|
+
jsType?: string;
|
|
229
|
+
enum?: { jsName: string };
|
|
230
|
+
items?: DtoField | DtoMessage;
|
|
231
|
+
properties?: Record<string, DtoField | Field>;
|
|
232
|
+
};
|
|
233
|
+
if (inner.type === 'enum') return inner.enum!.jsName;
|
|
234
|
+
if (inner.type === 'date' || inner.type === 'datetime') return 'string';
|
|
235
|
+
if (inner.type === 'array') {
|
|
236
|
+
const items = inner.items!;
|
|
237
|
+
return isDtoMessage(items) ? `${items.name}[]` : `Array<${dtoFieldJsType(items)}>`;
|
|
238
|
+
}
|
|
239
|
+
if (inner.type === 'object') {
|
|
240
|
+
// Plain objectField properties are bare Fields; DtoObjectFieldDef
|
|
241
|
+
// properties are DtoFields. Recurse through both.
|
|
242
|
+
const props = Object.entries(inner.properties ?? {})
|
|
243
|
+
.map(([k, v]) => {
|
|
244
|
+
const optional = isDtoField(v) ? v.isOptional() : (v as Field).optional ?? false;
|
|
245
|
+
const type = isDtoField(v) ? dtoFieldJsType(v) : dtoFieldJsTypeInner(v as Field);
|
|
246
|
+
return `${k}${optional ? '?' : ''}: ${type}`;
|
|
247
|
+
})
|
|
248
|
+
.join('; ');
|
|
249
|
+
return `{ ${props} }`;
|
|
250
|
+
}
|
|
251
|
+
return inner.jsType ?? '';
|
|
195
252
|
}
|
|
196
253
|
|
|
197
254
|
/** Enum JS names referenced by a DtoField, recursing into inline array/object
|
|
@@ -210,8 +267,10 @@ export function dtoCollectEnumRefs(df: DtoField, out: string[] = []): string[] {
|
|
|
210
267
|
}
|
|
211
268
|
|
|
212
269
|
export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
|
|
213
|
-
// Items stay as-is:
|
|
214
|
-
//
|
|
270
|
+
// Items stay as-is: a DtoMessage is referenced by name (the driver renders
|
|
271
|
+
// Type.Array(<DtoName>)); a scalar DtoField element renders its primitive
|
|
272
|
+
// type. Inline container elements are rejected by buildMessage/defineUtils
|
|
273
|
+
// (DTOs must not nest inline structures — every object needs a name).
|
|
215
274
|
return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
|
|
216
275
|
}
|
|
217
276
|
|
|
@@ -219,7 +278,42 @@ export function dtoObjectField(def: { properties: Record<string, DtoField> } & O
|
|
|
219
278
|
return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
|
|
220
279
|
}
|
|
221
280
|
|
|
281
|
+
/** DTOs must not nest DtoField-class containers inline: dtoObjectField /
|
|
282
|
+
* dtoArrayField instances (and dtoField(dtoObjectField(...))-style wraps)
|
|
283
|
+
* have no reusable name — extract a named DTO and reference it as an array
|
|
284
|
+
* element (dtoArrayField({ items: namedDto })), and array items must be a
|
|
285
|
+
* named DTO or a scalar field. Plain Field containers (objectField /
|
|
286
|
+
* arrayField — wire-format nesting) stay legal and render inline.
|
|
287
|
+
* DtoField-class wrappers (dtoField(dtoArrayField(...))) carry the def
|
|
288
|
+
* inside the instance's .field, so both layers are unwrapped. */
|
|
289
|
+
export function assertNoInlineContainers(dtoName: string, fields: Record<string, DtoField>): void {
|
|
290
|
+
for (const [key, df] of Object.entries(fields)) {
|
|
291
|
+
const f = (df.field as { field?: unknown }).field ?? df.field;
|
|
292
|
+
const field = f as { type?: string; items?: DtoField | DtoMessage };
|
|
293
|
+
// Only DtoField-class containers are banned (they need a name). Plain
|
|
294
|
+
// Field objects (objectField — wire-format nesting) are legal and render
|
|
295
|
+
// inline: dtoField(objectField({...})) stays allowed.
|
|
296
|
+
const isDtoClassContainer =
|
|
297
|
+
isDtoField(df.field) || typeof (df as { properties?: unknown }).properties === 'function';
|
|
298
|
+
if (isDtoClassContainer && field.type === 'object') {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`[dto] "${dtoName}" field "${key}": inline object is not allowed — dtoObjectField containers must be named: extract a named DTO and reference it (dtoArrayField({ items: itemDto })) or use a plain objectField for wire-format nesting`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (field.type === 'array' && isDtoField(field.items)) {
|
|
304
|
+
const items = (field.items.field as { field?: unknown }).field ?? field.items.field;
|
|
305
|
+
const item = items as { type?: string };
|
|
306
|
+
if (item.type === 'object' || item.type === 'array') {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`[dto] "${dtoName}" field "${key}": inline container elements are not allowed — array items must be a named DTO or a scalar field`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
222
315
|
function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
316
|
+
assertNoInlineContainers(name, fields);
|
|
223
317
|
const message = new DtoMessage(name, direction, fields, description);
|
|
224
318
|
// Write back the DTO field name from the map key (safe: DtoField instances
|
|
225
319
|
// are created per DTO, never shared).
|
package/src/flow-script.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
guard,
|
|
22
22
|
ifNode,
|
|
23
23
|
isCall,
|
|
24
|
+
isConditionGroup,
|
|
24
25
|
isEnd,
|
|
25
26
|
isFlowNode,
|
|
26
27
|
isFlowSlot,
|
|
@@ -297,6 +298,10 @@ function addUsed(ctx: FlowCompile, slot: FlowSlot | undefined): void {
|
|
|
297
298
|
}
|
|
298
299
|
|
|
299
300
|
function addConditionUsed(ctx: FlowCompile, c: GuardCondition): void {
|
|
301
|
+
if (isConditionGroup(c)) {
|
|
302
|
+
for (const s of c.conds) addConditionUsed(ctx, s);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
300
305
|
if (!isCall(c)) {
|
|
301
306
|
addUsed(ctx, isFlowSlot(c.field) ? c.field : c.field.slot);
|
|
302
307
|
return;
|
|
@@ -310,6 +315,8 @@ function isInvokeStep(c: GuardCondition | InvokeStep): c is InvokeStep {
|
|
|
310
315
|
return (c as InvokeStep).kind === 'invoke';
|
|
311
316
|
}
|
|
312
317
|
|
|
318
|
+
/** Lower an IF condition: a top-level invoke is a predicate call, and
|
|
319
|
+
* composites convert invoke steps nested inside them recursively. */
|
|
313
320
|
function toCondition(c: GuardCondition | InvokeStep): GuardCondition {
|
|
314
321
|
if (isInvokeStep(c)) {
|
|
315
322
|
if (c.result !== undefined) {
|
|
@@ -317,6 +324,9 @@ function toCondition(c: GuardCondition | InvokeStep): GuardCondition {
|
|
|
317
324
|
}
|
|
318
325
|
return { method: c.method, args: c.args === undefined ? [] : Array.isArray(c.args) ? c.args : [c.args] };
|
|
319
326
|
}
|
|
327
|
+
if (isConditionGroup(c)) {
|
|
328
|
+
return { kind: c.kind, conds: c.conds.map((sub) => toCondition(sub)) };
|
|
329
|
+
}
|
|
320
330
|
return c;
|
|
321
331
|
}
|
|
322
332
|
|
|
@@ -341,11 +351,35 @@ function methodThrows(m: FlowMethodRef): ExceptionSchema[] {
|
|
|
341
351
|
}
|
|
342
352
|
|
|
343
353
|
/** Readable condition text used as node/branch labels (and as the throw
|
|
344
|
-
* label when THROW carries no message).
|
|
354
|
+
* label when THROW carries no message). Composites render parenthesized
|
|
355
|
+
* sub-conditions: !(a), (a && b), (a || b). */
|
|
345
356
|
function renderCondition(c: GuardCondition): string {
|
|
357
|
+
if (isConditionGroup(c)) {
|
|
358
|
+
const inner = c.conds.map(renderCondition).join(c.kind === 'and' ? ' && ' : c.kind === 'or' ? ' || ' : '');
|
|
359
|
+
return c.kind === 'not' ? `!(${inner})` : `(${inner})`;
|
|
360
|
+
}
|
|
346
361
|
if (!isCall(c)) {
|
|
347
362
|
if (isFlowSlot(c.field)) {
|
|
348
|
-
|
|
363
|
+
const nullOp = c.op === 'isNull' || c.op === 'isNotNull';
|
|
364
|
+
if (nullOp) return c.op === 'isNull' ? `${c.field.name} is null` : `${c.field.name} is not null`;
|
|
365
|
+
// scalar slot comparison: total > 100
|
|
366
|
+
const ref = c.field.name;
|
|
367
|
+
switch (c.op) {
|
|
368
|
+
case 'lt':
|
|
369
|
+
return `${ref} < ${renderValue(c.value)}`;
|
|
370
|
+
case 'le':
|
|
371
|
+
return `${ref} <= ${renderValue(c.value)}`;
|
|
372
|
+
case 'gt':
|
|
373
|
+
return `${ref} > ${renderValue(c.value)}`;
|
|
374
|
+
case 'ge':
|
|
375
|
+
return `${ref} >= ${renderValue(c.value)}`;
|
|
376
|
+
case 'eq':
|
|
377
|
+
return `${ref} = ${renderValue(c.value)}`;
|
|
378
|
+
case 'ne':
|
|
379
|
+
return `${ref} ≠ ${renderValue(c.value)}`;
|
|
380
|
+
default:
|
|
381
|
+
throw new Error(`unsupported comparison op '${c.op}'`);
|
|
382
|
+
}
|
|
349
383
|
}
|
|
350
384
|
const field = c.field.field as { name: string };
|
|
351
385
|
const ref = `${c.field.slot.name}.${field.name}`;
|
|
@@ -546,15 +580,21 @@ function compileTry(step: TryStep, ctx: FlowCompile, cont: FlowNodeOrEnd, inheri
|
|
|
546
580
|
const ordinal = ctx.tryTotal - ++ctx.tryCount + 1;
|
|
547
581
|
const suffix = ordinal === 1 ? '' : `${ordinal}`;
|
|
548
582
|
const body = compileFlowBody(`${ctx.name}.tryBody${suffix}`, undefined, step.body, ctx, inherit);
|
|
583
|
+
// Catch handlers and finally may read what the body produced before the
|
|
584
|
+
// failure point (Java semantics: try { row = dao.get() } catch { use(row) }).
|
|
585
|
+
// The body's productions seed their entry availability alongside the
|
|
586
|
+
// enclosing flow's inherited slots.
|
|
587
|
+
const bodyProduced = flowProducedSlots(body);
|
|
588
|
+
const catchInherit = [...inherit, ...bodyProduced];
|
|
549
589
|
const catches = step.catches.map(([ex, steps]) => ({
|
|
550
590
|
exception: ex,
|
|
551
|
-
handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx,
|
|
591
|
+
handler: compileFlowBody(`${ctx.name}.catch${ex.name}${suffix}`, undefined, steps, ctx, catchInherit),
|
|
552
592
|
}));
|
|
553
593
|
const t = tryNode(step.name ?? 'try', {
|
|
554
594
|
body,
|
|
555
595
|
catches,
|
|
556
596
|
finally: step.finally
|
|
557
|
-
? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx,
|
|
597
|
+
? compileFlowBody(`${ctx.name}.finally${suffix}`, undefined, step.finally, ctx, catchInherit)
|
|
558
598
|
: undefined,
|
|
559
599
|
});
|
|
560
600
|
ctx.seen.add(t);
|
|
@@ -585,6 +625,22 @@ function compileSub(step: SubStep, ctx: FlowCompile, cont: FlowNodeOrEnd, inheri
|
|
|
585
625
|
return n;
|
|
586
626
|
}
|
|
587
627
|
|
|
628
|
+
/** Slots a flow's own nodes produce (call results and writes) — the try
|
|
629
|
+
* body's productions become visible to its catch handlers and finally. */
|
|
630
|
+
function flowProducedSlots(f: FlowSchema): FlowSlot[] {
|
|
631
|
+
const out = new Set<FlowSlot>();
|
|
632
|
+
for (const n of f.nodes) {
|
|
633
|
+
if (isEnd(n)) continue;
|
|
634
|
+
if (isGuard(n) || isFlowNode(n)) {
|
|
635
|
+
for (const m of n.methods ?? []) {
|
|
636
|
+
if (isCall(m) && m.result !== undefined) out.add(m.result);
|
|
637
|
+
}
|
|
638
|
+
if (isFlowNode(n)) for (const w of n.writes ?? []) out.add(w);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return [...out];
|
|
642
|
+
}
|
|
643
|
+
|
|
588
644
|
/** Compile one flow (top-level or sub-flow): its own slots registry (args
|
|
589
645
|
* plus the named slots actually used inside), its own exception ends, and
|
|
590
646
|
* its own return end (linked through DANGLE). */
|
|
@@ -628,8 +684,11 @@ function buildFlow(
|
|
|
628
684
|
const nodes = [...ctx.seen];
|
|
629
685
|
const registry = defineSlots(buildSlots(ctx));
|
|
630
686
|
// Entry inheritance only for slots the flow actually consumes; unused
|
|
631
|
-
// productions of the enclosing flow are not this flow's concern.
|
|
632
|
-
|
|
687
|
+
// productions of the enclosing flow are not this flow's concern. Name-based
|
|
688
|
+
// matching: inherited instances may come from another flow's registry (the
|
|
689
|
+
// try body's re-bound productions), so identity comparison would drop them.
|
|
690
|
+
const usedNames = new Set([...ctx.usedSlots].map((s) => s.name));
|
|
691
|
+
const entrySlots = rewriteSlots(nodes, ctx.edges, registry, ctx.entrySlots.filter((s) => usedNames.has(s.name)));
|
|
633
692
|
return defineFlow(name, {
|
|
634
693
|
start,
|
|
635
694
|
description,
|
|
@@ -671,6 +730,9 @@ function rewriteSlots(nodes: FlowNodeOrEnd[], edges: FlowEdge[], slots: FlowSlot
|
|
|
671
730
|
return { method: m.method, args: m.args?.map(map), result: m.result ? map(m.result) : undefined };
|
|
672
731
|
};
|
|
673
732
|
const cond = (c: GuardCondition): GuardCondition => {
|
|
733
|
+
if (isConditionGroup(c)) {
|
|
734
|
+
return { kind: c.kind, conds: c.conds.map(cond) };
|
|
735
|
+
}
|
|
674
736
|
if (!isCall(c)) {
|
|
675
737
|
if (isFlowSlot(c.field)) {
|
|
676
738
|
return { kind: 'comparison', op: c.op, field: map(c.field), value: c.value };
|