@pylonts/dsl 1.1.16 → 1.1.18

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/src/dto.ts CHANGED
@@ -1,332 +1,365 @@
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;
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 type { TokenSchema } from './token.js';
6
+ import { toCamelCase } from '@pylonts/core';
7
+
8
+ // Interface (DTO) field definitions.
9
+ // Naming convention: all DTO types and builders use the Dto prefix.
10
+ // A DtoField stores the database Field and the API-only extras separately.
11
+
12
+ /** Re-export Operator lives on the DSL level (see dsl.ts). */
13
+ export type { Operator } from './dsl.js';
14
+
15
+ /** Re-export ImportBase lives on its own module (see import-base.ts). */
16
+ export type { ImportBase } from './import-base.js';
17
+
18
+ /** Re-export MockDescriptor lives on its own module (see mock.ts). */
19
+ export type { MockDescriptor } from './mock.js';
20
+
21
+ /**
22
+ * Reference to an existing TypeBox base schema by its import location.
23
+ * Serializable metadata: the driver renders `import { name } from 'from'`
24
+ * and `Type.Intersect([name, ...])` — the runtime schema is never loaded by the DSL.
25
+ */
26
+ export interface ImportRef extends ImportBase {
27
+ /**
28
+ * Generic type arguments for the base schema (e.g. PageResult(OrderRow)).
29
+ * Two forms, both local DTOs:
30
+ * string — the DTO export name
31
+ * DtoMessage — the DTO instance itself; the driver resolves it to its name
32
+ */
33
+ args?: (string | DtoMessage)[];
34
+ }
35
+
36
+ export type DtoArrayFieldDef = BaseField & {
37
+ type: 'array';
38
+ jsType: 'array';
39
+ /** Element type: inline DtoField, or a reference to an existing DTO (rendered by name) */
40
+ items: DtoField | DtoMessage;
41
+ };
42
+
43
+ export type DtoObjectFieldDef = BaseField & {
44
+ type: 'object';
45
+ jsType: 'object';
46
+ properties: Record<string, DtoField>;
47
+ };
48
+
49
+ export class DtoField implements SchemaBase {
50
+ /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
51
+ * 构造时未知,由 buildMessage 从 map key 反写。 */
52
+ name: string;
53
+ /** 字段描述 */
54
+ description?: string;
55
+ /** 所属容器(buildMessage / defineRouteData / definePageData 反写) */
56
+ schema?: CollectionSchemaBase;
57
+ field: Field | DtoArrayFieldDef | DtoObjectFieldDef;
58
+ pattern?: string;
59
+ optional?: boolean;
60
+ operator?: Operator;
61
+ /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */
62
+ default?: unknown;
63
+ /** Optional reference to another DtoField — this field reuses the referenced field's type/constraints */
64
+ ref?: DtoField;
65
+ /** Server-injection marker: this field is filled from the token at runtime
66
+ * (client never sends it). Set by fromToken(); the driver renders it as an
67
+ * Optional field inside a __inject base of the DTO. */
68
+ injectFrom?: TokenSchema;
69
+
70
+ constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef) {
71
+ this.name = '';
72
+ this.field = field;
73
+ }
74
+
75
+ setPattern(value: string): this {
76
+ this.pattern = value;
77
+ return this;
78
+ }
79
+
80
+ setDescription(value: string): this {
81
+ this.description = value;
82
+ return this;
83
+ }
84
+
85
+ getDescription(): string | undefined {
86
+ return this.description;
87
+ }
88
+
89
+ /** True when this field wraps a DB column (picked via from()); false for inline fields. */
90
+ isColumn(): boolean {
91
+ return this.field.schema?.type === 'table';
92
+ }
93
+
94
+ setOptional(value: boolean): this {
95
+ this.optional = value;
96
+ return this;
97
+ }
98
+
99
+ /** Set a default value — emitted as a TypeBox schema default annotation */
100
+ setDefault(value: unknown): this {
101
+ this.default = value;
102
+ return this;
103
+ }
104
+
105
+ /** Reference another DtoField — this field reuses the referenced field's type/constraints */
106
+ setRef(value: DtoField): this {
107
+ this.ref = value;
108
+ return this;
109
+ }
110
+
111
+ /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
112
+ setOperator(value: Operator): this {
113
+ this.operator = value;
114
+ return this;
115
+ }
116
+
117
+ /** optional 优先于 field.optional */
118
+ isOptional(): boolean {
119
+ if (this.optional !== undefined) return this.optional;
120
+ return this.field.optional ?? false;
121
+ }
122
+ }
123
+
124
+ export class DtoArrayField extends DtoField {
125
+ declare field: DtoArrayFieldDef;
126
+
127
+ /** Element type: inline DtoField or a referenced DtoMessage (rendered by name). */
128
+ items(): DtoField | DtoMessage {
129
+ return this.field.items;
130
+ }
131
+ }
132
+
133
+ export class DtoObjectField extends DtoField {
134
+ declare field: DtoObjectFieldDef;
135
+
136
+ properties(): Record<string, DtoField> {
137
+ return this.field.properties;
138
+ }
139
+ }
140
+
141
+ export enum DtoDirection {
142
+ Input = 'input',
143
+ Output = 'output',
144
+ Query = 'query',
145
+ Pk = 'pk',
146
+ }
147
+
148
+ export class DtoMessage implements CollectionSchemaBase {
149
+ type = 'dto';
150
+ name: string;
151
+ description?: string;
152
+ /** 方向:输入或输出 */
153
+ direction: DtoDirection;
154
+ fields: Record<string, DtoField>;
155
+ /** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */
156
+ bases: ImportRef[] = [];
157
+
158
+ constructor(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string) {
159
+ this.name = name;
160
+ this.direction = direction;
161
+ this.fields = fields;
162
+ this.description = description;
163
+ }
164
+
165
+ /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
166
+ include(...refs: ImportRef[]): this {
167
+ this.bases.push(...refs);
168
+ return this;
169
+ }
170
+ }
171
+
172
+ export function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): DtoField {
173
+ return new DtoField(field);
174
+ }
175
+
176
+ /** Structural check — DtoMessage instances may come from a different module copy, so instanceof is unreliable. */
177
+ export function isDtoMessage(v: unknown): v is DtoMessage {
178
+ if (typeof v !== 'object' || v === null) return false;
179
+ return (v as Record<string, unknown>).type === 'dto';
180
+ }
181
+
182
+ /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */
183
+ export function isDtoField(v: unknown): v is DtoField {
184
+ if (typeof v !== 'object' || v === null) return false;
185
+ return 'field' in v && !('type' in v);
186
+ }
187
+
188
+ export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit<BaseField, 'name'>): DtoArrayField {
189
+ // Items stay as-is: an inline DtoField is rendered inline, a DtoMessage is
190
+ // referenced by name (the driver renders Type.Array(<DtoName>)).
191
+ return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def });
192
+ }
193
+
194
+ export function dtoObjectField(def: { properties: Record<string, DtoField> } & Omit<BaseField, 'name'>): DtoObjectField {
195
+ return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def });
196
+ }
197
+
198
+ function buildMessage(name: string, direction: DtoDirection, fields: Record<string, DtoField>, description?: string): DtoMessage {
199
+ const message = new DtoMessage(name, direction, fields, description);
200
+ // Write back the DTO field name from the map key (safe: DtoField instances
201
+ // are created per DTO, never shared).
202
+ for (const key of Object.keys(message.fields)) {
203
+ const df = message.fields[key];
204
+ if (!(df instanceof DtoField)) {
205
+ const got = df === null || df === undefined ? String(df) : `${(df as object).constructor.name ?? typeof df}`;
206
+ throw new Error(
207
+ `[dto] DTO "${name}" field "${key}" must be a DtoField (created with dtoField()/dtoArrayField()/dtoObjectField()), got ${got}. ` +
208
+ `Did you pass enumField(...) directly? Use dtoField(enumField({...})) instead.`
209
+ );
210
+ }
211
+ df.name = key;
212
+ df.schema = message;
213
+ // Custom (inline) fields are owned by this DTO: write back name + schema.
214
+ // Fields picked via from() share the database Field instance whose
215
+ // name/schema already point to the table — leave them untouched.
216
+ if (df.field.schema === undefined) {
217
+ df.field.name = key;
218
+ df.field.schema = message;
219
+ }
220
+ writeBackNested(df, message);
221
+ }
222
+ return message;
223
+ }
224
+
225
+ /** Write back name/schema on nested DTO fields (array items, object
226
+ * properties) both plain-Field containers (objectField/arrayField) and
227
+ * DtoField containers (dtoObjectField/dtoArrayField). DtoMessage item
228
+ * references are skipped — they carry their own identity. */
229
+ function writeBackNested(df: DtoField, message: DtoMessage): void {
230
+ const f = df.field;
231
+ if (f.type === 'array') {
232
+ const items = f.items;
233
+ if (isDtoMessage(items)) return;
234
+ if (isDtoField(items)) {
235
+ writeBackNested(items, message);
236
+ return;
237
+ }
238
+ walkContainer(items, writeBackLeaf(message));
239
+ return;
240
+ }
241
+ if (f.type === 'object') {
242
+ for (const [key, child] of Object.entries(f.properties)) {
243
+ if (isDtoField(child)) {
244
+ child.name = key;
245
+ child.schema = message;
246
+ if (child.field.schema === undefined) {
247
+ child.field.name = key;
248
+ child.field.schema = message;
249
+ }
250
+ writeBackNested(child, message);
251
+ } else {
252
+ writeBackLeaf(message)(child, key);
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ /** Name/schema write-back for a plain Field (own fields only — shared
259
+ * instances keep their original identity). */
260
+ function writeBackLeaf(message: DtoMessage): (f: Field, key?: string) => void {
261
+ return (f, key) => {
262
+ if (key !== undefined && f.schema === undefined) {
263
+ f.name = key;
264
+ f.schema = message;
265
+ }
266
+ };
267
+ }
268
+
269
+ export function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
270
+ const message = buildMessage(name, DtoDirection.Input, fields, description);
271
+ // Rule A set optionality from the DB column rule (skips fields the author
272
+ // already set): nullable / default → optional, else required.
273
+ // PK columns are always required.
274
+ for (const field of Object.values(message.fields)) {
275
+ if (field.optional !== undefined) continue;
276
+ const f = field.field as Field;
277
+ if (f.schema?.type !== 'table') continue;
278
+ const table = f.schema as TableSchema;
279
+ field.optional = table.isPk(f) ? false : f.optional !== false || f.default !== undefined;
280
+ }
281
+ return message;
282
+ }
283
+
284
+ export function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
285
+ return buildMessage(name, DtoDirection.Output, fields, description);
286
+ }
287
+
288
+ export function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
289
+ const message = buildMessage(name, DtoDirection.Query, fields, description);
290
+ // Rule B: query/search fields are always optional.
291
+ for (const field of Object.values(message.fields)) field.optional = true;
292
+ return message;
293
+ }
294
+
295
+ export function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
296
+ const message = buildMessage(name, DtoDirection.Pk, fields, description);
297
+ // Rule P: PK locator fields are required, other fields are optional.
298
+ for (const field of Object.values(message.fields)) {
299
+ if (field.optional !== undefined) continue;
300
+ const f = field.field as Field;
301
+ const table = f.schema?.type === 'table' ? (f.schema as TableSchema) : undefined;
302
+ field.optional = table !== undefined && table.isPk(f) ? false : true;
303
+ }
304
+ return message;
305
+ }
306
+
307
+ /** Field-collection source a DTO can project from: a DB table, another DTO
308
+ * message (protocol fields keep their names), or an entity (which may carry
309
+ * aggregate fields). */
310
+ export type DtoFieldSource = TableSchema | DtoMessage | EntitySchema;
311
+
312
+ function ownsField(source: DtoFieldSource, field: Field | DtoArrayFieldDef | DtoObjectFieldDef): boolean {
313
+ if (isDtoMessage(source)) {
314
+ return Object.values(source.fields).some((df) => df.field === field);
315
+ }
316
+ return Object.values(source.columns).some((c) => c === field);
317
+ }
318
+
319
+ /** Project fields from a field-collection source (table, DTO message or
320
+ * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field
321
+ * instances keep their original identity — the projection references them. */
322
+ export function from(
323
+ source: DtoFieldSource,
324
+ fields: (Field | DtoArrayFieldDef | DtoObjectFieldDef)[],
325
+ ): Record<string, DtoField> {
326
+ const out: Record<string, DtoField> = {};
327
+ for (const field of fields) {
328
+ if (!ownsField(source, field)) {
329
+ throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${isDtoMessage(source) ? 'dto' : source.type}`);
330
+ }
331
+ // DB columns map to camelCase interface names (mer_id → merId); aggregate
332
+ // field names are already camel and pass through; DTO message fields are
333
+ // protocol names themselves and stay untouched.
334
+ out[isDtoMessage(source) ? field.name : toCamelCase(field.name)] = dtoField(field);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function ownsTokenField(token: TokenSchema, field: DtoField): boolean {
340
+ return (
341
+ Object.values(token.security).some((df) => df === field) ||
342
+ Object.values(token.identity).some((df) => df === field)
343
+ );
344
+ }
345
+
346
+ /** Project fields from a TokenSchema (security/identity segments) as
347
+ * server-injected DTO fields. Unlike from(), the projection does NOT share
348
+ * the token's DtoField instance — each field is a NEW DtoField wrapping the
349
+ * same underlying Field, referencing the token field via setRef() so the DTO
350
+ * write-back (buildMessage) never mutates the token's own fields. Every
351
+ * produced field is marked injectFrom (rendered inside a __inject base:
352
+ * Optional in the wire schema, filled from the token at runtime). */
353
+ export function fromToken(token: TokenSchema, fields: DtoField[]): Record<string, DtoField> {
354
+ const out: Record<string, DtoField> = {};
355
+ for (const field of fields) {
356
+ if (!ownsTokenField(token, field)) {
357
+ throw new Error(`dto.fromToken(${token.name}): field ${field.name} does not belong to this token`);
358
+ }
359
+ const df = dtoField(field.field);
360
+ df.setRef(field);
361
+ df.injectFrom = token;
362
+ out[field.name] = df;
363
+ }
364
+ return out;
332
365
  }