import { BaseField, CollectionSchemaBase, Field, Operator, SchemaBase, walkContainer } from './dsl.js'; import { TableSchema } from './db.js'; import type { EntitySchema } from './entity.js'; import type { ImportBase } from './import-base.js'; import type { TokenSchema } from './token.js'; import { toCamelCase } from '@pylonts/core'; // Interface (DTO) field definitions. // Naming convention: all DTO types and builders use the Dto prefix. // A DtoField stores the database Field and the API-only extras separately. /** Re-export — Operator lives on the DSL level (see dsl.ts). */ export type { Operator } from './dsl.js'; /** Re-export — ImportBase lives on its own module (see import-base.ts). */ export type { ImportBase } from './import-base.js'; /** Re-export — MockDescriptor lives on its own module (see mock.ts). */ export type { MockDescriptor } from './mock.js'; /** * Reference to an existing TypeBox base schema by its import location. * Serializable metadata: the driver renders `import { name } from 'from'` * and `Type.Intersect([name, ...])` — the runtime schema is never loaded by the DSL. */ export interface ImportRef extends ImportBase { /** * Generic type arguments for the base schema (e.g. PageResult(OrderRow)). * Two forms, both local DTOs: * string — the DTO export name * DtoMessage — the DTO instance itself; the driver resolves it to its name */ args?: (string | DtoMessage)[]; } export type DtoArrayFieldDef = BaseField & { type: 'array'; jsType: 'array'; /** Element type: inline DtoField, or a reference to an existing DTO (rendered by name) */ items: DtoField | DtoMessage; }; export type DtoObjectFieldDef = BaseField & { type: 'object'; jsType: 'object'; properties: Record; }; export class DtoField implements SchemaBase { /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。 * 构造时未知,由 buildMessage 从 map key 反写。 */ name: string; /** 字段描述 */ description?: string; /** 所属容器(buildMessage / defineRouteData / definePageData 反写) */ schema?: CollectionSchemaBase; field: Field | DtoArrayFieldDef | DtoObjectFieldDef; pattern?: string; optional?: boolean; operator?: Operator; /** TypeBox default annotation (API contract level); falls back to field.default (DB default) */ default?: unknown; /** Optional reference to another DtoField — this field reuses the referenced field's type/constraints */ ref?: DtoField; /** Server-injection marker: this field is filled from the token at runtime * (client never sends it). Set by fromToken(); the driver renders it as an * Optional field inside a __inject base of the DTO. */ injectFrom?: TokenSchema; constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef) { this.name = ''; this.field = field; } setPattern(value: string): this { this.pattern = value; return this; } setDescription(value: string): this { this.description = value; return this; } getDescription(): string | undefined { return this.description; } /** True when this field wraps a DB column (picked via from()); false for inline fields. */ isColumn(): boolean { return this.field.schema?.type === 'table'; } setOptional(value: boolean): this { this.optional = value; return this; } /** Set a default value — emitted as a TypeBox schema default annotation */ setDefault(value: unknown): this { this.default = value; return this; } /** Reference another DtoField — this field reuses the referenced field's type/constraints */ setRef(value: DtoField): this { this.ref = value; return this; } /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */ setOperator(value: Operator): this { this.operator = value; return this; } /** optional 优先于 field.optional */ isOptional(): boolean { if (this.optional !== undefined) return this.optional; return this.field.optional ?? false; } } export class DtoArrayField extends DtoField { declare field: DtoArrayFieldDef; /** Element type: inline DtoField or a referenced DtoMessage (rendered by name). */ items(): DtoField | DtoMessage { return this.field.items; } } export class DtoObjectField extends DtoField { declare field: DtoObjectFieldDef; properties(): Record { return this.field.properties; } } export enum DtoDirection { Input = 'input', Output = 'output', Query = 'query', Pk = 'pk', } export class DtoMessage implements CollectionSchemaBase { type = 'dto'; name: string; description?: string; /** 方向:输入或输出 */ direction: DtoDirection; fields: Record; /** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */ bases: ImportRef[] = []; constructor(name: string, direction: DtoDirection, fields: Record, description?: string) { this.name = name; this.direction = direction; this.fields = fields; this.description = description; } /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */ include(...refs: ImportRef[]): this { this.bases.push(...refs); return this; } } export function dtoField(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): DtoField { return new DtoField(field); } /** Structural check — DtoMessage instances may come from a different module copy, so instanceof is unreliable. */ export function isDtoMessage(v: unknown): v is DtoMessage { if (typeof v !== 'object' || v === null) return false; return (v as Record).type === 'dto'; } /** Structural check — a DtoField wraps a Field in a .field property and has no .type of its own. */ export function isDtoField(v: unknown): v is DtoField { if (typeof v !== 'object' || v === null) return false; return 'field' in v && !('type' in v); } /** Resolve a ref chain to its terminal DtoField (the one without .ref). * Cycles are a DSL definition error — fail loudly at render time. */ export function resolveDtoRefChain(f: DtoField): DtoField { const seen = new Set(); let cur: DtoField = f; while (cur.ref !== undefined) { if (seen.has(cur.ref)) { throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`); } seen.add(cur.ref); cur = cur.ref; } return cur; } /** TS type of a DtoField in generated code. * A field shared by reference (its schema is the owning DTO — utils args * like `args: { items: OrderSubmitRequest.fields.items }`) renders as an * indexed access on the DTO's generated type (the DTO owns the structure). * Array elements render by name (`ItemDto[]` — named DTO) or by recursion * (`Array` — scalar). Plain wire objects (objectField) render * their property shape (`{ key: type }`); DtoField-class containers are * rejected at build time (DTOs must not nest inline structures). * Enum → its JS name, date/datetime → string. */ export function dtoFieldJsType(df: DtoField): string { const owner = df.schema as { type?: string; name?: string } | undefined; if (owner?.type === 'dto' && owner.name !== undefined && owner.name !== '' && df.name !== '') { return `${owner.name}['${df.name}']`; } return dtoFieldJsTypeInner(df.field); } /** Type of a raw field object — unwraps DtoField-class wrappers * (dtoField(dtoArrayField(...)) stores the def inside the instance's * .field) and recurses: named-DTO elements (Name[]), scalar elements * (Array), plain wire objects ({ key: type }), enums, and scalars. */ function dtoFieldJsTypeInner(field: Field | DtoArrayFieldDef | DtoObjectFieldDef): string { const f = (field as { field?: unknown }).field ?? field; const inner = f as { type?: string; jsType?: string; enum?: { jsName: string }; items?: DtoField | DtoMessage; properties?: Record; }; if (inner.type === 'enum') return inner.enum!.jsName; if (inner.type === 'date' || inner.type === 'datetime') return 'string'; if (inner.type === 'array') { const items = inner.items!; return isDtoMessage(items) ? `${items.name}[]` : `Array<${dtoFieldJsType(items)}>`; } if (inner.type === 'object') { // Plain objectField properties are bare Fields; DtoObjectFieldDef // properties are DtoFields. Recurse through both. const props = Object.entries(inner.properties ?? {}) .map(([k, v]) => { const optional = isDtoField(v) ? v.isOptional() : (v as Field).optional ?? false; const type = isDtoField(v) ? dtoFieldJsType(v) : dtoFieldJsTypeInner(v as Field); return `${k}${optional ? '?' : ''}: ${type}`; }) .join('; '); return `{ ${props} }`; } return inner.jsType ?? ''; } /** Enum JS names referenced by a DtoField, recursing into inline array/object * wrappers; DtoMessage item references stop the walk. First-occurrence order. */ export function dtoCollectEnumRefs(df: DtoField, out: string[] = []): string[] { const f = df.field; if (f.type === 'enum') { out.push(f.enum.jsName); } else if (f.type === 'array') { const items = f.items; if (isDtoField(items)) dtoCollectEnumRefs(items, out); } else if (f.type === 'object') { for (const child of Object.values(f.properties)) dtoCollectEnumRefs(child, out); } return out; } export function dtoArrayField(def: { items: DtoField | DtoMessage } & Omit): DtoArrayField { // Items stay as-is: a DtoMessage is referenced by name (the driver renders // Type.Array()); a scalar DtoField element renders its primitive // type. Inline container elements are rejected by buildMessage/defineUtils // (DTOs must not nest inline structures — every object needs a name). return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def }); } export function dtoObjectField(def: { properties: Record } & Omit): DtoObjectField { return new DtoObjectField({ name: '', type: 'object', jsType: 'object', ...def }); } /** DTOs must not nest DtoField-class containers inline: dtoObjectField / * dtoArrayField instances (and dtoField(dtoObjectField(...))-style wraps) * have no reusable name — extract a named DTO and reference it as an array * element (dtoArrayField({ items: namedDto })), and array items must be a * named DTO or a scalar field. Plain Field containers (objectField / * arrayField — wire-format nesting) stay legal and render inline. * DtoField-class wrappers (dtoField(dtoArrayField(...))) carry the def * inside the instance's .field, so both layers are unwrapped. */ export function assertNoInlineContainers(dtoName: string, fields: Record): void { for (const [key, df] of Object.entries(fields)) { const f = (df.field as { field?: unknown }).field ?? df.field; const field = f as { type?: string; items?: DtoField | DtoMessage }; // Only DtoField-class containers are banned (they need a name). Plain // Field objects (objectField — wire-format nesting) are legal and render // inline: dtoField(objectField({...})) stays allowed. const isDtoClassContainer = isDtoField(df.field) || typeof (df as { properties?: unknown }).properties === 'function'; if (isDtoClassContainer && field.type === 'object') { throw new Error( `[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`, ); } if (field.type === 'array' && isDtoField(field.items)) { const items = (field.items.field as { field?: unknown }).field ?? field.items.field; const item = items as { type?: string }; if (item.type === 'object' || item.type === 'array') { throw new Error( `[dto] "${dtoName}" field "${key}": inline container elements are not allowed — array items must be a named DTO or a scalar field`, ); } } } } function buildMessage(name: string, direction: DtoDirection, fields: Record, description?: string): DtoMessage { assertNoInlineContainers(name, fields); const message = new DtoMessage(name, direction, fields, description); // Write back the DTO field name from the map key (safe: DtoField instances // are created per DTO, never shared). for (const key of Object.keys(message.fields)) { const df = message.fields[key]; if (!(df instanceof DtoField)) { const got = df === null || df === undefined ? String(df) : `${(df as object).constructor.name ?? typeof df}`; throw new Error( `[dto] DTO "${name}" field "${key}" must be a DtoField (created with dtoField()/dtoArrayField()/dtoObjectField()), got ${got}. ` + `Did you pass enumField(...) directly? Use dtoField(enumField({...})) instead.` ); } df.name = key; df.schema = message; // Custom (inline) fields are owned by this DTO: write back name + schema. // Fields picked via from() share the database Field instance whose // name/schema already point to the table — leave them untouched. if (df.field.schema === undefined) { df.field.name = key; df.field.schema = message; } writeBackNested(df, message); } return message; } /** Write back name/schema on nested DTO fields (array items, object * properties) — both plain-Field containers (objectField/arrayField) and * DtoField containers (dtoObjectField/dtoArrayField). DtoMessage item * references are skipped — they carry their own identity. */ function writeBackNested(df: DtoField, message: DtoMessage): void { const f = df.field; if (f.type === 'array') { const items = f.items; if (isDtoMessage(items)) return; if (isDtoField(items)) { writeBackNested(items, message); return; } walkContainer(items, writeBackLeaf(message)); return; } if (f.type === 'object') { for (const [key, child] of Object.entries(f.properties)) { if (isDtoField(child)) { child.name = key; child.schema = message; if (child.field.schema === undefined) { child.field.name = key; child.field.schema = message; } writeBackNested(child, message); } else { writeBackLeaf(message)(child, key); } } } } /** Name/schema write-back for a plain Field (own fields only — shared * instances keep their original identity). */ function writeBackLeaf(message: DtoMessage): (f: Field, key?: string) => void { return (f, key) => { if (key !== undefined && f.schema === undefined) { f.name = key; f.schema = message; } }; } export function buildInput(name: string, fields: Record, description?: string): DtoMessage { const message = buildMessage(name, DtoDirection.Input, fields, description); // Rule A — set optionality from the DB column rule (skips fields the author // already set): nullable / default → optional, else required. // PK columns are always required. for (const field of Object.values(message.fields)) { if (field.optional !== undefined) continue; const f = field.field as Field; if (f.schema?.type !== 'table') continue; const table = f.schema as TableSchema; field.optional = table.isPk(f) ? false : f.optional !== false || f.default !== undefined; } return message; } export function buildOutput(name: string, fields: Record, description?: string): DtoMessage { return buildMessage(name, DtoDirection.Output, fields, description); } export function buildQuery(name: string, fields: Record, description?: string): DtoMessage { const message = buildMessage(name, DtoDirection.Query, fields, description); // Rule B: query/search fields are always optional. for (const field of Object.values(message.fields)) field.optional = true; return message; } export function buildPk(name: string, fields: Record, description?: string): DtoMessage { const message = buildMessage(name, DtoDirection.Pk, fields, description); // Rule P: PK locator fields are required, other fields are optional. for (const field of Object.values(message.fields)) { if (field.optional !== undefined) continue; const f = field.field as Field; const table = f.schema?.type === 'table' ? (f.schema as TableSchema) : undefined; field.optional = table !== undefined && table.isPk(f) ? false : true; } return message; } /** Field-collection source a DTO can project from: a DB table, another DTO * message (protocol fields keep their names), or an entity (which may carry * aggregate fields). */ export type DtoFieldSource = TableSchema | DtoMessage | EntitySchema; function ownsField(source: DtoFieldSource, field: Field | DtoArrayFieldDef | DtoObjectFieldDef): boolean { if (isDtoMessage(source)) { return Object.values(source.fields).some((df) => df.field === field); } return Object.values(source.columns).some((c) => c === field); } /** Project fields from a field-collection source (table, DTO message or * entity) and wrap them as DTO fields (aligned with dto.from). Shared Field * instances keep their original identity — the projection references them. */ export function from( source: DtoFieldSource, fields: (Field | DtoArrayFieldDef | DtoObjectFieldDef)[], ): Record { const out: Record = {}; for (const field of fields) { if (!ownsField(source, field)) { throw new Error(`dto.from(${source.name}): field ${field.name} does not belong to this ${isDtoMessage(source) ? 'dto' : source.type}`); } // DB columns map to camelCase interface names (mer_id → merId); aggregate // field names are already camel and pass through; DTO message fields are // protocol names themselves and stay untouched. out[isDtoMessage(source) ? field.name : toCamelCase(field.name)] = dtoField(field); } return out; } function ownsTokenField(token: TokenSchema, field: DtoField): boolean { return ( Object.values(token.security).some((df) => df === field) || Object.values(token.identity).some((df) => df === field) ); } /** Project fields from a TokenSchema (security/identity segments) as * server-injected DTO fields. Unlike from(), the projection does NOT share * the token's DtoField instance — each field is a NEW DtoField wrapping the * same underlying Field, referencing the token field via setRef() so the DTO * write-back (buildMessage) never mutates the token's own fields. Every * produced field is marked injectFrom (rendered inside a __inject base: * Optional in the wire schema, filled from the token at runtime). */ export function fromToken(token: TokenSchema, fields: DtoField[]): Record { const out: Record = {}; for (const field of fields) { if (!ownsTokenField(token, field)) { throw new Error(`dto.fromToken(${token.name}): field ${field.name} does not belong to this token`); } const df = dtoField(field.field); df.setRef(field); df.injectFrom = token; out[field.name] = df; } return out; }