@pylonts/dsl 1.0.3 → 1.0.5
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/bases.d.ts +5 -0
- package/dist/bases.js +20 -0
- package/dist/check-inheritance.d.ts +9 -0
- package/dist/check-inheritance.js +61 -0
- package/dist/dsl.d.ts +28 -15
- package/dist/dsl.js +44 -1
- package/dist/dto.d.ts +38 -21
- package/dist/dto.js +96 -34
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mermaid-driver.js +7 -3
- package/dist/typebox-driver.d.ts +6 -0
- package/dist/typebox-driver.js +56 -20
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +8 -0
- package/docs/dto.md +17 -10
- package/package.json +1 -1
- package/src/bases.ts +20 -0
- package/src/check-inheritance.ts +86 -0
- package/src/dsl.ts +50 -19
- package/src/dto.ts +98 -48
- package/src/index.ts +3 -0
- package/src/mermaid-driver.ts +6 -3
- package/src/typebox-driver.ts +183 -140
- package/src/utils.ts +6 -0
package/dist/typebox-driver.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectDtoImports = collectDtoImports;
|
|
4
|
+
exports.renderDtoExport = renderDtoExport;
|
|
5
|
+
exports.renderDtoTypeExport = renderDtoTypeExport;
|
|
3
6
|
exports.renderDtoMessage = renderDtoMessage;
|
|
4
7
|
const dto_1 = require("./dto");
|
|
5
8
|
function renderString(s) {
|
|
6
9
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
7
10
|
}
|
|
8
|
-
function
|
|
11
|
+
function renderDefault(v) {
|
|
12
|
+
if (typeof v === 'string')
|
|
13
|
+
return renderString(v);
|
|
14
|
+
return JSON.stringify(v);
|
|
15
|
+
}
|
|
16
|
+
function renderBasic(field, pattern, defaultValue, resolver) {
|
|
9
17
|
if (pattern !== undefined && field.type !== 'string') {
|
|
10
18
|
throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
|
|
11
19
|
}
|
|
20
|
+
const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
|
|
12
21
|
switch (field.type) {
|
|
13
22
|
case 'string': {
|
|
14
23
|
const opts = [];
|
|
@@ -18,16 +27,20 @@ function renderBasic(field, pattern, resolver) {
|
|
|
18
27
|
opts.push(`maxLength: ${field.maxLength}`);
|
|
19
28
|
if (pattern !== undefined)
|
|
20
29
|
opts.push(`pattern: ${renderString(pattern)}`);
|
|
30
|
+
if (def !== undefined)
|
|
31
|
+
opts.push(def);
|
|
21
32
|
return opts.length > 0 ? `Type.String({ ${opts.join(', ')} })` : 'Type.String()';
|
|
22
33
|
}
|
|
23
34
|
case 'text':
|
|
24
|
-
return 'Type.String()';
|
|
35
|
+
return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
|
|
25
36
|
case 'integer': {
|
|
26
37
|
const opts = [];
|
|
27
38
|
if (field.min !== undefined)
|
|
28
39
|
opts.push(`minimum: ${field.min}`);
|
|
29
40
|
if (field.max !== undefined)
|
|
30
41
|
opts.push(`maximum: ${field.max}`);
|
|
42
|
+
if (def !== undefined)
|
|
43
|
+
opts.push(def);
|
|
31
44
|
return opts.length > 0 ? `Type.Integer({ ${opts.join(', ')} })` : 'Type.Integer()';
|
|
32
45
|
}
|
|
33
46
|
case 'bigint':
|
|
@@ -37,15 +50,22 @@ function renderBasic(field, pattern, resolver) {
|
|
|
37
50
|
case 'datetime':
|
|
38
51
|
// Transmitted as string over HTTP: bigint/decimal keep full precision,
|
|
39
52
|
// date/time serialize to string.
|
|
40
|
-
return 'Type.String()';
|
|
53
|
+
return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
|
|
41
54
|
case 'boolean':
|
|
42
|
-
return 'Type.Boolean()';
|
|
55
|
+
return def !== undefined ? `Type.Boolean({ ${def} })` : 'Type.Boolean()';
|
|
43
56
|
case 'json':
|
|
44
|
-
return 'Type.Unknown()';
|
|
57
|
+
return def !== undefined ? `Type.Unknown({ ${def} })` : 'Type.Unknown()';
|
|
45
58
|
case 'enum': {
|
|
46
59
|
const ref = resolver?.(field.enum.jsName);
|
|
47
60
|
if (!ref)
|
|
48
61
|
throw new Error(`enum field ${field.name}: no import ref for ${field.enum.jsName} — pass an EnumResolver`);
|
|
62
|
+
if (def !== undefined) {
|
|
63
|
+
const member = field.enum.values.find((v) => v.value === defaultValue);
|
|
64
|
+
if (!member) {
|
|
65
|
+
throw new Error(`enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`);
|
|
66
|
+
}
|
|
67
|
+
return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol} })`;
|
|
68
|
+
}
|
|
49
69
|
return `Type.Enum(${ref.name})`;
|
|
50
70
|
}
|
|
51
71
|
default:
|
|
@@ -70,7 +90,10 @@ function renderValue(f, indent, resolver) {
|
|
|
70
90
|
return renderObject(f.properties(), indent + 1, resolver);
|
|
71
91
|
}
|
|
72
92
|
// DtoField only wraps a database Field; array/object defs live in the subclasses.
|
|
73
|
-
|
|
93
|
+
// DTO-level default wins over the DB field default; the DB default (string)
|
|
94
|
+
// is used as a fallback so from() picks carry it into the API contract.
|
|
95
|
+
const defaultValue = f.default !== undefined ? f.default : f.field.default;
|
|
96
|
+
return renderBasic(f.field, f.pattern, defaultValue, resolver);
|
|
74
97
|
}
|
|
75
98
|
function collectEnumImports(f, resolver, out) {
|
|
76
99
|
if (f instanceof dto_1.DtoArrayField) {
|
|
@@ -89,34 +112,47 @@ function collectEnumImports(f, resolver, out) {
|
|
|
89
112
|
out.set(`${ref.from}#${ref.name}`, ref);
|
|
90
113
|
}
|
|
91
114
|
}
|
|
115
|
+
/** Collect all imports needed to render a DTO: include() bases + enum references. */
|
|
116
|
+
function collectDtoImports(schema, resolver, out) {
|
|
117
|
+
for (const base of schema.bases ?? [])
|
|
118
|
+
out.set(`${base.from}#${base.name}`, base);
|
|
119
|
+
for (const f of Object.values(schema.fields))
|
|
120
|
+
collectEnumImports(f, resolver, out);
|
|
121
|
+
}
|
|
122
|
+
/** Render one DTO export (const + type) — no file header, for file-level generation. */
|
|
123
|
+
function renderDtoExport(schema, resolver) {
|
|
124
|
+
const object = renderObject(schema.fields, 1, resolver);
|
|
125
|
+
const bases = schema.bases ?? [];
|
|
126
|
+
const body = bases.length > 0
|
|
127
|
+
? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
|
|
128
|
+
: object;
|
|
129
|
+
return `export const ${schema.name} = ${body};`;
|
|
130
|
+
}
|
|
131
|
+
/** Render the Static type export for a DTO. */
|
|
132
|
+
function renderDtoTypeExport(name) {
|
|
133
|
+
return `export type ${name} = Static<typeof ${name}>;`;
|
|
134
|
+
}
|
|
92
135
|
function renderDtoMessage(schema, options = {}) {
|
|
93
136
|
const { resolver, source } = options;
|
|
94
137
|
const imports = new Map();
|
|
95
|
-
|
|
96
|
-
imports.set(`${base.from}#${base.name}`, base);
|
|
97
|
-
for (const f of Object.values(schema.fields))
|
|
98
|
-
collectEnumImports(f, resolver, imports);
|
|
138
|
+
collectDtoImports(schema, resolver, imports);
|
|
99
139
|
const header = [
|
|
100
140
|
'// AUTO-GENERATED by typebox-driver — DO NOT EDIT',
|
|
101
141
|
...(source !== undefined ? [`// Source: ${source}`] : []),
|
|
102
142
|
"import { Type, Static } from '@sinclair/typebox';",
|
|
103
143
|
...[...imports.values()].map((r) => `import { ${r.name} } from '${r.from}';`),
|
|
104
144
|
];
|
|
105
|
-
const object = renderObject(schema.fields, 1, resolver);
|
|
106
|
-
const bases = schema.bases ?? [];
|
|
107
|
-
const body = bases.length > 0
|
|
108
|
-
? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
|
|
109
|
-
: object;
|
|
110
145
|
return [
|
|
111
146
|
...header,
|
|
112
147
|
'',
|
|
113
|
-
|
|
114
|
-
|
|
148
|
+
renderDtoExport(schema, resolver),
|
|
149
|
+
renderDtoTypeExport(schema.name),
|
|
115
150
|
'',
|
|
116
151
|
].join('\n');
|
|
117
152
|
}
|
|
118
153
|
function renderBase(base) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
if (base.args === undefined || base.args.length === 0)
|
|
155
|
+
return base.name;
|
|
156
|
+
const args = base.args.map((a) => (typeof a === 'string' ? a : a.name));
|
|
157
|
+
return `${base.name}(${args.join(', ')})`;
|
|
122
158
|
}
|
package/dist/utils.d.ts
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── naming conversions ──
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.toCamelCase = toCamelCase;
|
|
5
|
+
/** snake_case → camelCase: mer_id → merId; names without underscores are unchanged */
|
|
6
|
+
function toCamelCase(name) {
|
|
7
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
8
|
+
}
|
package/docs/dto.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# 定义 DTO(四种方向)
|
|
2
2
|
|
|
3
|
-
DTO
|
|
3
|
+
DTO 描述接口出入参。方向决定可选性规则,由各方向工厂设置;**规则统一只在 `field.optional === undefined` 时生效**(作者已显式设置的跳过):
|
|
4
4
|
|
|
5
|
-
| 构建器 | 方向 |
|
|
5
|
+
| 构建器 | 方向 | 可选性规则 |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `buildInput` | input |
|
|
8
|
-
| `buildOutput` | output |
|
|
9
|
-
| `buildQuery` | query |
|
|
10
|
-
| `buildPk` | pk |
|
|
7
|
+
| `buildInput` | input | 按 DB 列规则:可空 / 有默认 / 自增列 → 可选;NOT NULL 无默认 → 必填 |
|
|
8
|
+
| `buildOutput` | output | 不设置(保持字段构建器/作者设置) |
|
|
9
|
+
| `buildQuery` | query | 全部可选 |
|
|
10
|
+
| `buildPk` | pk | 主键字段必填,其他字段可选 |
|
|
11
11
|
|
|
12
12
|
## 从表提取字段
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ buildOutput('OrderRow', from(order, [order.fields.id, order.fields.order_no]));
|
|
|
22
22
|
|
|
23
23
|
// 查询:分页 + 过滤(query 字段恒为可选,.op() 声明比较操作符)
|
|
24
24
|
buildQuery('OrderPageQuery', {
|
|
25
|
-
keyword: dtoField(stringField({ maxLength: 32 })).
|
|
25
|
+
keyword: dtoField(stringField({ maxLength: 32 })).setOperator('like'),
|
|
26
26
|
...from(order, [order.fields.mer_id]),
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -30,16 +30,23 @@ buildQuery('OrderPageQuery', {
|
|
|
30
30
|
buildPk('OrderDetailRequest', from(order, [order.fields.id]));
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
`from(table, fields)` 提取表字段包装为 DTO 字段,字段实例与表共享,`name/schema`
|
|
33
|
+
`from(table, fields)` 提取表字段包装为 DTO 字段,字段实例与表共享,`name/schema` 保持指向表。**DTO 字段名转 camelCase**(`mer_id` → `merId`),与 DB 列名(snake_case)分离。`from()` 本身不做任何可选性推断——推断在各方向工厂。
|
|
34
34
|
|
|
35
35
|
## 独立字段
|
|
36
36
|
|
|
37
|
-
不来自表的内联字段直接用 `dtoField(...)` 包装任意字段构建器,可加 `pattern`、`optional`、`operator`。
|
|
37
|
+
不来自表的内联字段直接用 `dtoField(...)` 包装任意字段构建器,可加 `pattern`、`optional`、`operator`、`default`。
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
|
-
dtoField(stringField({ maxLength: 32 })).
|
|
40
|
+
dtoField(stringField({ maxLength: 32 })).setOperator('like')
|
|
41
|
+
dtoField(intField()).setDefault(0) // TypeBox default 注解
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
## 默认值
|
|
45
|
+
|
|
46
|
+
- `setDefault(v)` 设 DTO 层默认值,渲染为 TypeBox `default:` 注解(`Type.String({ default: 'PENDING' })`、`Type.Enum(OrderStatus, { default: OrderStatus.PENDING })`)。
|
|
47
|
+
- 枚举默认值必须是该枚举的成员值(value),渲染时解析为成员引用;非成员值直接报错。
|
|
48
|
+
- 未显式设置时,fallback 到字段构建器的 `default`(DB 默认值,string),`from()` 提取的字段自动带出。
|
|
49
|
+
|
|
43
50
|
## 继承基础 schema
|
|
44
51
|
|
|
45
52
|
```ts
|
package/package.json
CHANGED
package/src/bases.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DtoMessage, ImportRef } from './dto';
|
|
2
|
+
|
|
3
|
+
// Named base-schema references for common protocol DTOs.
|
|
4
|
+
//
|
|
5
|
+
// These are ImportRef metadata (not re-exports of the actual TypeBox schemas):
|
|
6
|
+
// the DSL stores { from, name } so the generator can emit the import line and
|
|
7
|
+
// the identifier — the runtime schema object itself is never loaded by the DSL.
|
|
8
|
+
//
|
|
9
|
+
// `import { PageRequest } from '@pylonts/dsl'` therefore gives .include() an
|
|
10
|
+
// already-resolved reference — no static analysis or name lookup needed.
|
|
11
|
+
|
|
12
|
+
/** Paginated query request base — renders `import { PageRequest } from '@pylonts/core'` + Intersect */
|
|
13
|
+
export const PageRequest: ImportRef = { from: '@pylonts/core', name: 'PageRequest' };
|
|
14
|
+
|
|
15
|
+
/** Paginated list response base — renders `import { PageResult } from '@pylonts/core'` + `PageResult(<row>)` */
|
|
16
|
+
export const PageResult = (row: DtoMessage): ImportRef => ({
|
|
17
|
+
from: '@pylonts/core',
|
|
18
|
+
name: 'PageResult',
|
|
19
|
+
args: [row],
|
|
20
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { DtoMessage } from './dto';
|
|
2
|
+
import type { TableSchema } from './dsl';
|
|
3
|
+
|
|
4
|
+
// Inheritance check — find DTO fields that should inherit from a DB column
|
|
5
|
+
// (via from()) but were written by hand, so they miss the column's
|
|
6
|
+
// type / semantic / optionality backfill.
|
|
7
|
+
//
|
|
8
|
+
// Inference: the tables a DSL file is about are inferred from the referenced
|
|
9
|
+
// fields in the same file (each Field carries schema identity via .schema).
|
|
10
|
+
// A field without a table ref whose name matches a column of an inferred table
|
|
11
|
+
// is a candidate for inheritance. Fields with an explicit semantic or enum type
|
|
12
|
+
// are treated as author-intent and skipped.
|
|
13
|
+
//
|
|
14
|
+
// False-positive boundary: when a DSL file has zero referenced fields, no table
|
|
15
|
+
// can be inferred and nothing is reported (never guess which table a hand-written
|
|
16
|
+
// field belongs to).
|
|
17
|
+
|
|
18
|
+
export interface InheritanceIssue {
|
|
19
|
+
file: string;
|
|
20
|
+
container: string;
|
|
21
|
+
field: string;
|
|
22
|
+
/** e.g. ["t_order.order_no"] or ["t_order.id", "t_merchant.id"] when several inferred tables share the name */
|
|
23
|
+
candidates: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** snake_case → camelCase: order_no → orderNo; names without underscores are unchanged */
|
|
27
|
+
function toCamelCase(name: string): string {
|
|
28
|
+
return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDtoMessage(v: unknown): v is DtoMessage {
|
|
32
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
33
|
+
const o = v as Record<string, unknown>;
|
|
34
|
+
return o.type === 'dto' && typeof o.name === 'string' && typeof o.fields === 'object' && o.fields !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FieldEntry {
|
|
38
|
+
name: string;
|
|
39
|
+
field: { schema?: { type?: string }; semantic?: string; type?: string };
|
|
40
|
+
container: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectFields(mod: Record<string, unknown>): FieldEntry[] {
|
|
44
|
+
const out: FieldEntry[] = [];
|
|
45
|
+
for (const [name, v] of Object.entries(mod)) {
|
|
46
|
+
if (isDtoMessage(v)) {
|
|
47
|
+
const container = v as { fields: Record<string, { field: FieldEntry['field'] }> };
|
|
48
|
+
for (const [fname, f] of Object.entries(container.fields)) {
|
|
49
|
+
out.push({ name: fname, field: f.field, container: name });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check one loaded DSL module (its exports) for inheritance gaps. */
|
|
57
|
+
export function checkInheritance(mod: Record<string, unknown>, file: string): InheritanceIssue[] {
|
|
58
|
+
const fields = collectFields(mod);
|
|
59
|
+
if (fields.length === 0) return [];
|
|
60
|
+
|
|
61
|
+
// Infer the tables this file is about from the referenced fields' schema identity.
|
|
62
|
+
const tables = new Map<string, Map<string, string>>(); // table -> camelCol -> origCol
|
|
63
|
+
for (const f of fields) {
|
|
64
|
+
const schema = f.field.schema;
|
|
65
|
+
if (schema?.type !== 'table') continue;
|
|
66
|
+
const table = schema as TableSchema;
|
|
67
|
+
if (!tables.has(table.name)) tables.set(table.name, new Map());
|
|
68
|
+
tables.get(table.name)!.set(toCamelCase(f.name), f.name);
|
|
69
|
+
}
|
|
70
|
+
if (tables.size === 0) return [];
|
|
71
|
+
|
|
72
|
+
const issues: InheritanceIssue[] = [];
|
|
73
|
+
for (const f of fields) {
|
|
74
|
+
if (f.field.schema?.type === 'table') continue;
|
|
75
|
+
if (f.field.semantic !== undefined || f.field.type === 'enum') continue;
|
|
76
|
+
const candidates: string[] = [];
|
|
77
|
+
for (const [t, cols] of tables) {
|
|
78
|
+
const orig = cols.get(f.name);
|
|
79
|
+
if (orig !== undefined) candidates.push(`${t}.${orig}`);
|
|
80
|
+
}
|
|
81
|
+
if (candidates.length > 0) {
|
|
82
|
+
issues.push({ file, container: f.container, field: f.name, candidates });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return issues;
|
|
86
|
+
}
|
package/src/dsl.ts
CHANGED
|
@@ -8,17 +8,24 @@ export interface SchemaBase {
|
|
|
8
8
|
description?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Field collection schemas (DB table vs DTO message); `type` is the discriminator */
|
|
12
|
+
export interface CollectionSchemaBase extends SchemaBase {
|
|
13
|
+
type: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export interface BaseField {
|
|
12
17
|
name: string;
|
|
13
18
|
/** 显示名称(中文标签) */
|
|
14
19
|
label?: string;
|
|
15
20
|
/** 字段描述 */
|
|
16
21
|
description?: string;
|
|
22
|
+
/** 业务语义码(如 'merchant_name'),驱动 mock 生成等下游消费 */
|
|
23
|
+
semantic?: string;
|
|
17
24
|
optional?: boolean;
|
|
18
25
|
readOnly?: boolean;
|
|
19
26
|
default?: string;
|
|
20
27
|
/** 所属 schema(db 或 dto) */
|
|
21
|
-
schema?:
|
|
28
|
+
schema?: CollectionSchemaBase;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
interface StringField extends BaseField {
|
|
@@ -132,7 +139,24 @@ export type ForeignKey = {
|
|
|
132
139
|
references: Field | Field[];
|
|
133
140
|
};
|
|
134
141
|
|
|
135
|
-
export interface
|
|
142
|
+
export interface TableSchemaOptions {
|
|
143
|
+
description?: string;
|
|
144
|
+
paginated?: boolean;
|
|
145
|
+
actor?: boolean;
|
|
146
|
+
generator?: string;
|
|
147
|
+
autoIncrement?: Field;
|
|
148
|
+
primaryKey?: Field | Field[];
|
|
149
|
+
indexes?: Index[];
|
|
150
|
+
foreignKeys?: Record<string, ForeignKey>;
|
|
151
|
+
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
152
|
+
phrase?: DictionaryEntry;
|
|
153
|
+
fields: Record<string, Field>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class TableSchema implements CollectionSchemaBase {
|
|
157
|
+
type = 'table';
|
|
158
|
+
name: string;
|
|
159
|
+
description?: string;
|
|
136
160
|
/** 分页 */
|
|
137
161
|
paginated?: boolean;
|
|
138
162
|
/** 系统操作者(如小程序为 C 端用户,管理端为运营) */
|
|
@@ -148,6 +172,28 @@ export interface TableSchema extends SchemaBase {
|
|
|
148
172
|
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
149
173
|
phrase?: DictionaryEntry;
|
|
150
174
|
fields: Record<string, Field>;
|
|
175
|
+
|
|
176
|
+
constructor(name: string, options: TableSchemaOptions) {
|
|
177
|
+
this.name = name;
|
|
178
|
+
this.description = options.description;
|
|
179
|
+
this.paginated = options.paginated;
|
|
180
|
+
this.actor = options.actor;
|
|
181
|
+
this.generator = options.generator;
|
|
182
|
+
this.autoIncrement = options.autoIncrement;
|
|
183
|
+
this.primaryKey = options.primaryKey;
|
|
184
|
+
this.indexes = options.indexes;
|
|
185
|
+
this.foreignKeys = options.foreignKeys;
|
|
186
|
+
this.phrase = options.phrase;
|
|
187
|
+
this.fields = options.fields;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** True when the field is part of this table's primary key */
|
|
191
|
+
isPk(fieldRef: Field): boolean {
|
|
192
|
+
if (this.primaryKey === undefined) return false;
|
|
193
|
+
return Array.isArray(this.primaryKey)
|
|
194
|
+
? this.primaryKey.includes(fieldRef)
|
|
195
|
+
: this.primaryKey === fieldRef;
|
|
196
|
+
}
|
|
151
197
|
}
|
|
152
198
|
|
|
153
199
|
// Field builders: type and jsType are fixed, pass extra properties only.
|
|
@@ -200,23 +246,8 @@ export function enumField(extra: Omit<EnumField, 'name' | 'type' | 'jsType'>): E
|
|
|
200
246
|
return { name: '', type: 'enum', jsType, ...extra };
|
|
201
247
|
}
|
|
202
248
|
|
|
203
|
-
export function defineTable(
|
|
204
|
-
name
|
|
205
|
-
schema: {
|
|
206
|
-
description?: string;
|
|
207
|
-
paginated?: boolean;
|
|
208
|
-
actor?: boolean;
|
|
209
|
-
generator?: string;
|
|
210
|
-
autoIncrement?: Field;
|
|
211
|
-
primaryKey?: Field | Field[];
|
|
212
|
-
indexes?: Index[];
|
|
213
|
-
foreignKeys?: Record<string, ForeignKey>;
|
|
214
|
-
/** 引用的实体短语(词典条目) */
|
|
215
|
-
phrase?: DictionaryEntry;
|
|
216
|
-
fields: Record<string, Field>;
|
|
217
|
-
},
|
|
218
|
-
): TableSchema {
|
|
219
|
-
const table: TableSchema = { name, ...schema };
|
|
249
|
+
export function defineTable(name: string, schema: TableSchemaOptions): TableSchema {
|
|
250
|
+
const table = new TableSchema(name, schema);
|
|
220
251
|
for (const key of Object.keys(table.fields)) {
|
|
221
252
|
const field = table.fields[key];
|
|
222
253
|
if (field.schema && field.schema !== table) {
|
package/src/dto.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { BaseField, Field, SchemaBase, TableSchema } from './dsl';
|
|
1
|
+
import { BaseField, CollectionSchemaBase, Field, SchemaBase, TableSchema } from './dsl';
|
|
2
|
+
import { toCamelCase } from './utils';
|
|
2
3
|
|
|
3
4
|
// Interface (DTO) field definitions.
|
|
4
5
|
// Naming convention: all DTO types and builders use the Dto prefix.
|
|
@@ -16,18 +17,15 @@ export interface ImportRef {
|
|
|
16
17
|
from: string;
|
|
17
18
|
/** Named export, e.g. 'PageRequest' */
|
|
18
19
|
name: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Generic type arguments for the base schema (e.g. PageResult(OrderRow)).
|
|
22
|
+
* Two forms, both local DTOs:
|
|
23
|
+
* string — the DTO export name
|
|
24
|
+
* DtoMessage — the DTO instance itself; the driver resolves it to its name
|
|
25
|
+
*/
|
|
26
|
+
args?: (string | DtoMessage)[];
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
export type DtoExtras = {
|
|
24
|
-
pattern?: string;
|
|
25
|
-
/** 联合判断是否可选,定义后优先级高于 field.optional */
|
|
26
|
-
optional?: boolean;
|
|
27
|
-
/** 查询比较操作符(query 方向字段) */
|
|
28
|
-
operator?: Operator;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
29
|
export type DtoArrayFieldDef = BaseField & {
|
|
32
30
|
type: 'array';
|
|
33
31
|
jsType: 'array';
|
|
@@ -40,23 +38,24 @@ export type DtoObjectFieldDef = BaseField & {
|
|
|
40
38
|
properties: Record<string, DtoField>;
|
|
41
39
|
};
|
|
42
40
|
|
|
43
|
-
export class DtoField {
|
|
41
|
+
export class DtoField implements SchemaBase {
|
|
44
42
|
/** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
|
|
45
43
|
* 构造时未知,由 buildMessage 从 map key 反写。 */
|
|
46
44
|
name: string;
|
|
45
|
+
/** 字段描述 */
|
|
46
|
+
description?: string;
|
|
47
47
|
/** 所属 DTO 容器(buildMessage 反写) */
|
|
48
48
|
schema?: DtoMessage;
|
|
49
49
|
field: Field | DtoArrayFieldDef | DtoObjectFieldDef;
|
|
50
50
|
pattern?: string;
|
|
51
51
|
optional?: boolean;
|
|
52
52
|
operator?: Operator;
|
|
53
|
+
/** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
|
|
54
|
+
default?: unknown;
|
|
53
55
|
|
|
54
|
-
constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef
|
|
56
|
+
constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef) {
|
|
55
57
|
this.name = '';
|
|
56
58
|
this.field = field;
|
|
57
|
-
this.pattern = extra.pattern;
|
|
58
|
-
this.optional = extra.optional;
|
|
59
|
-
this.operator = extra.operator;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
setPattern(value: string): this {
|
|
@@ -64,15 +63,34 @@ export class DtoField {
|
|
|
64
63
|
return this;
|
|
65
64
|
}
|
|
66
65
|
|
|
66
|
+
setDescription(value: string): this {
|
|
67
|
+
this.description = value;
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getDescription(): string | undefined {
|
|
72
|
+
return this.description;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when this field wraps a DB column (picked via from()); false for inline fields. */
|
|
76
|
+
isColumn(): boolean {
|
|
77
|
+
return this.field.schema?.type === 'table';
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
setOptional(value: boolean): this {
|
|
68
81
|
this.optional = value;
|
|
69
82
|
return this;
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
/** Set a default value — emitted as a TypeBox schema default annotation */
|
|
86
|
+
setDefault(value: unknown): this {
|
|
87
|
+
this.default = value;
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
/** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
|
|
73
|
-
|
|
92
|
+
setOperator(value: Operator): this {
|
|
74
93
|
this.operator = value;
|
|
75
|
-
this.optional = true;
|
|
76
94
|
return this;
|
|
77
95
|
}
|
|
78
96
|
|
|
@@ -99,46 +117,55 @@ export class DtoObjectField extends DtoField {
|
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
export
|
|
120
|
+
export enum DtoDirection {
|
|
121
|
+
Input = 'input',
|
|
122
|
+
Output = 'output',
|
|
123
|
+
Query = 'query',
|
|
124
|
+
Pk = 'pk',
|
|
125
|
+
}
|
|
103
126
|
|
|
104
|
-
export
|
|
127
|
+
export class DtoMessage implements CollectionSchemaBase {
|
|
128
|
+
type = 'dto';
|
|
129
|
+
name: string;
|
|
130
|
+
description?: string;
|
|
105
131
|
/** 方向:输入或输出 */
|
|
106
132
|
direction: DtoDirection;
|
|
107
133
|
fields: Record<string, DtoField>;
|
|
108
134
|
/** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */
|
|
109
|
-
bases
|
|
135
|
+
bases: ImportRef[] = [];
|
|
136
|
+
|
|
137
|
+
constructor(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string) {
|
|
138
|
+
this.name = name;
|
|
139
|
+
this.direction = direction;
|
|
140
|
+
this.fields = fields;
|
|
141
|
+
this.description = description;
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
/** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
|
|
111
|
-
include(...refs: ImportRef[]):
|
|
145
|
+
include(...refs: ImportRef[]): this {
|
|
146
|
+
this.bases.push(...refs);
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
112
149
|
}
|
|
113
150
|
|
|
114
|
-
export function dtoField(field: Field
|
|
115
|
-
return new DtoField(field
|
|
151
|
+
export function dtoField(field: Field): DtoField {
|
|
152
|
+
return new DtoField(field);
|
|
116
153
|
}
|
|
117
154
|
|
|
118
|
-
export function dtoArrayField(def: { items: DtoField } & Omit<BaseField, 'name'
|
|
119
|
-
|
|
155
|
+
export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
|
|
156
|
+
// Reuse an existing DTO as the array element: expand its fields into an object.
|
|
157
|
+
const items = def.items instanceof DtoMessage
|
|
158
|
+
? new DtoObjectField({ name: '', type: 'object', jsType: 'object', properties: def.items.fields })
|
|
159
|
+
: def.items;
|
|
160
|
+
return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def, items });
|
|
120
161
|
}
|
|
121
162
|
|
|
122
|
-
export function dtoObjectField(def: { properties: Record<string, DtoField> } & Omit<BaseField, 'name'
|
|
123
|
-
return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def }
|
|
163
|
+
export function dtoObjectField(def: { properties: Record<string, DtoField> } & Omit<BaseField, 'name'>): DtoObjectField {
|
|
164
|
+
return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
|
|
124
165
|
}
|
|
125
166
|
|
|
126
167
|
function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
127
|
-
const message
|
|
128
|
-
name,
|
|
129
|
-
direction,
|
|
130
|
-
description,
|
|
131
|
-
fields,
|
|
132
|
-
bases: [],
|
|
133
|
-
include(...refs: ImportRef[]): DtoMessage {
|
|
134
|
-
this.bases!.push(...refs);
|
|
135
|
-
return this;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
if (direction === 'query') {
|
|
139
|
-
// Rule B: query/search fields are always optional.
|
|
140
|
-
for (const field of Object.values(message.fields)) field.optional = true;
|
|
141
|
-
}
|
|
168
|
+
const message = new DtoMessage(name, direction, fields, description);
|
|
142
169
|
// Write back the DTO field name from the map key (safe: DtoField instances
|
|
143
170
|
// are created per DTO, never shared).
|
|
144
171
|
for (const key of Object.keys(message.fields)) {
|
|
@@ -157,19 +184,40 @@ function buildMessage(name: string, direction: DtoDirection, fields: Record<stri
|
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
export function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
160
|
-
|
|
187
|
+
const message = buildMessage(name, DtoDirection.Input, fields, description);
|
|
188
|
+
// Rule A — set optionality from the DB column rule (skips fields the author
|
|
189
|
+
// already set): nullable / default / auto-increment → optional, else required.
|
|
190
|
+
for (const field of Object.values(message.fields)) {
|
|
191
|
+
if (field.optional !== undefined) continue;
|
|
192
|
+
const f = field.field as Field;
|
|
193
|
+
if (f.schema?.type !== 'table') continue;
|
|
194
|
+
const table = f.schema as TableSchema;
|
|
195
|
+
field.optional = f.optional !== false || f.default !== undefined || table.autoIncrement === f;
|
|
196
|
+
}
|
|
197
|
+
return message;
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
export function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
164
|
-
return buildMessage(name,
|
|
201
|
+
return buildMessage(name, DtoDirection.Output, fields, description);
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
export function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
168
|
-
|
|
205
|
+
const message = buildMessage(name, DtoDirection.Query, fields, description);
|
|
206
|
+
// Rule B: query/search fields are always optional.
|
|
207
|
+
for (const field of Object.values(message.fields)) field.optional = true;
|
|
208
|
+
return message;
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
export function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
|
|
172
|
-
|
|
212
|
+
const message = buildMessage(name, DtoDirection.Pk, fields, description);
|
|
213
|
+
// Rule P: PK locator fields are required, other fields are optional.
|
|
214
|
+
for (const field of Object.values(message.fields)) {
|
|
215
|
+
if (field.optional !== undefined) continue;
|
|
216
|
+
const f = field.field as Field;
|
|
217
|
+
const table = f.schema as TableSchema | undefined;
|
|
218
|
+
field.optional = table !== undefined && table.isPk(f) ? false : true;
|
|
219
|
+
}
|
|
220
|
+
return message;
|
|
173
221
|
}
|
|
174
222
|
|
|
175
223
|
/** Pick columns from a built table and wrap them as DTO fields (aligned with dto.from). */
|
|
@@ -179,7 +227,9 @@ export function from(table: TableSchema, fields: Field[]): Record<string, DtoFie
|
|
|
179
227
|
if (field.schema !== table) {
|
|
180
228
|
throw new Error(`dto.from(${table.name}): field ${field.name} does not belong to this table`);
|
|
181
229
|
}
|
|
182
|
-
|
|
230
|
+
// DTO field name is camelCase (mer_id → merId); the underlying field.name
|
|
231
|
+
// stays snake_case (DB column).
|
|
232
|
+
out[toCamelCase(field.name)] = dtoField(field);
|
|
183
233
|
}
|
|
184
234
|
return out;
|
|
185
235
|
}
|