@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.
@@ -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 renderBasic(field, pattern, resolver) {
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
- return renderBasic(f.field, f.pattern, resolver);
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
- for (const base of schema.bases ?? [])
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
- `export const ${schema.name} = ${body};`,
114
- `export type ${schema.name} = Static<typeof ${schema.name}>;`,
148
+ renderDtoExport(schema, resolver),
149
+ renderDtoTypeExport(schema.name),
115
150
  '',
116
151
  ].join('\n');
117
152
  }
118
153
  function renderBase(base) {
119
- return base.args !== undefined && base.args.length > 0
120
- ? `${base.name}(${base.args.join(', ')})`
121
- : base.name;
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
  }
@@ -0,0 +1,2 @@
1
+ /** snake_case → camelCase: mer_id → merId; names without underscores are unchanged */
2
+ export declare function toCamelCase(name: string): string;
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 })).op('like'),
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 })).op('like')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonts/dsl",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.ts",
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?: SchemaBase;
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 TableSchema extends SchemaBase {
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: string,
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
- /** Generic type arguments for the base schema (same-file DTO export names), e.g. PageResult(AdvertRow) */
20
- args?: string[];
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, extra: DtoExtras = {}) {
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
- op(value: Operator): this {
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 type DtoDirection = 'input' | 'output' | 'query' | 'pk';
120
+ export enum DtoDirection {
121
+ Input = 'input',
122
+ Output = 'output',
123
+ Query = 'query',
124
+ Pk = 'pk',
125
+ }
103
126
 
104
- export interface DtoMessage extends SchemaBase {
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?: ImportRef[];
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[]): DtoMessage;
145
+ include(...refs: ImportRef[]): this {
146
+ this.bases.push(...refs);
147
+ return this;
148
+ }
112
149
  }
113
150
 
114
- export function dtoField(field: Field, extra: DtoExtras = {}): DtoField {
115
- return new DtoField(field, extra);
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'>, extra: DtoExtras = {}): DtoArrayField {
119
- return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def }, extra);
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'>, extra: DtoExtras = {}): DtoObjectField {
123
- return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def }, extra);
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: DtoMessage = {
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
- return buildMessage(name, 'input', fields, description);
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, 'output', fields, description);
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
- return buildMessage(name, 'query', fields, description);
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
- return buildMessage(name, 'pk', fields, description);
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
- out[field.name] = dtoField(field);
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
  }