@pylonts/dsl 1.0.0

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.
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderDtoMessage = renderDtoMessage;
4
+ const dto_1 = require("./dto");
5
+ function renderString(s) {
6
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
7
+ }
8
+ function renderBasic(field, pattern, resolver) {
9
+ if (pattern !== undefined && field.type !== 'string') {
10
+ throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
11
+ }
12
+ switch (field.type) {
13
+ case 'string': {
14
+ const opts = [];
15
+ if (field.minLength !== undefined)
16
+ opts.push(`minLength: ${field.minLength}`);
17
+ if (field.maxLength !== undefined)
18
+ opts.push(`maxLength: ${field.maxLength}`);
19
+ if (pattern !== undefined)
20
+ opts.push(`pattern: ${renderString(pattern)}`);
21
+ return opts.length > 0 ? `Type.String({ ${opts.join(', ')} })` : 'Type.String()';
22
+ }
23
+ case 'text':
24
+ return 'Type.String()';
25
+ case 'integer': {
26
+ const opts = [];
27
+ if (field.min !== undefined)
28
+ opts.push(`minimum: ${field.min}`);
29
+ if (field.max !== undefined)
30
+ opts.push(`maximum: ${field.max}`);
31
+ return opts.length > 0 ? `Type.Integer({ ${opts.join(', ')} })` : 'Type.Integer()';
32
+ }
33
+ case 'bigint':
34
+ case 'decimal':
35
+ case 'time':
36
+ case 'date':
37
+ case 'datetime':
38
+ // Transmitted as string over HTTP: bigint/decimal keep full precision,
39
+ // date/time serialize to string.
40
+ return 'Type.String()';
41
+ case 'boolean':
42
+ return 'Type.Boolean()';
43
+ case 'json':
44
+ return 'Type.Unknown()';
45
+ case 'enum': {
46
+ if (!field.jsName)
47
+ throw new Error(`enum field ${field.name} requires jsName to render`);
48
+ const ref = resolver?.(field.jsName);
49
+ if (!ref)
50
+ throw new Error(`enum field ${field.name}: no import ref for ${field.jsName} — pass an EnumResolver`);
51
+ return `Type.Enum(${ref.name})`;
52
+ }
53
+ default:
54
+ // Field union is exhaustive; this branch is unreachable at runtime.
55
+ throw new Error(`unsupported field type: ${String(field.type)}`);
56
+ }
57
+ }
58
+ function renderObject(fields, indent, resolver) {
59
+ const pad = ' '.repeat(indent);
60
+ const entries = Object.entries(fields).map(([name, f]) => `${pad}${name}: ${renderField(f, indent, resolver)}`);
61
+ return `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
62
+ }
63
+ function renderField(f, indent, resolver) {
64
+ const base = renderValue(f, indent, resolver);
65
+ return f.isOptional() ? `Type.Optional(${base})` : base;
66
+ }
67
+ function renderValue(f, indent, resolver) {
68
+ if (f instanceof dto_1.DtoArrayField) {
69
+ return `Type.Array(${renderField(f.items(), indent + 1, resolver)})`;
70
+ }
71
+ if (f instanceof dto_1.DtoObjectField) {
72
+ return renderObject(f.properties(), indent + 1, resolver);
73
+ }
74
+ // DtoField only wraps a database Field; array/object defs live in the subclasses.
75
+ return renderBasic(f.field, f.pattern, resolver);
76
+ }
77
+ function collectEnumImports(f, resolver, out) {
78
+ if (f instanceof dto_1.DtoArrayField) {
79
+ collectEnumImports(f.items(), resolver, out);
80
+ return;
81
+ }
82
+ if (f instanceof dto_1.DtoObjectField) {
83
+ for (const child of Object.values(f.properties()))
84
+ collectEnumImports(child, resolver, out);
85
+ return;
86
+ }
87
+ if (f.field.type === 'enum') {
88
+ if (!f.field.jsName)
89
+ throw new Error(`enum field ${f.field.name} requires jsName to render`);
90
+ const ref = resolver?.(f.field.jsName);
91
+ if (!ref)
92
+ throw new Error(`enum field ${f.field.name}: no import ref for ${f.field.jsName} — pass an EnumResolver`);
93
+ out.set(`${ref.from}#${ref.name}`, ref);
94
+ }
95
+ }
96
+ function renderDtoMessage(schema, options = {}) {
97
+ const { resolver, source } = options;
98
+ const imports = new Map();
99
+ for (const base of schema.bases ?? [])
100
+ imports.set(`${base.from}#${base.name}`, base);
101
+ for (const f of Object.values(schema.fields))
102
+ collectEnumImports(f, resolver, imports);
103
+ const header = [
104
+ '// AUTO-GENERATED by typebox-driver — DO NOT EDIT',
105
+ ...(source !== undefined ? [`// Source: ${source}`] : []),
106
+ "import { Type, Static } from '@sinclair/typebox';",
107
+ ...[...imports.values()].map((r) => `import { ${r.name} } from '${r.from}';`),
108
+ ];
109
+ const object = renderObject(schema.fields, 1, resolver);
110
+ const bases = schema.bases ?? [];
111
+ const body = bases.length > 0
112
+ ? `Type.Intersect([${bases.map(renderBase).join(', ')}, ${object}])`
113
+ : object;
114
+ return [
115
+ ...header,
116
+ '',
117
+ `export const ${schema.name} = ${body};`,
118
+ `export type ${schema.name} = Static<typeof ${schema.name}>;`,
119
+ '',
120
+ ].join('\n');
121
+ }
122
+ function renderBase(base) {
123
+ return base.args !== undefined && base.args.length > 0
124
+ ? `${base.name}(${base.args.join(', ')})`
125
+ : base.name;
126
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@pylonts/dsl",
3
+ "version": "1.0.0",
4
+ "description": "Schema definition DSL with drivers: MySQL DDL, TS enum, TypeBox schema codegen.",
5
+ "type": "commonjs",
6
+ "main": "src/index.ts",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "prepublishOnly": "npm run build",
15
+ "typecheck": "npx tsc --noEmit"
16
+ },
17
+ "keywords": [
18
+ "dsl",
19
+ "schema",
20
+ "typebox",
21
+ "mysql"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "devDependencies": {
26
+ "typescript": "^7.0.2"
27
+ }
28
+ }
package/src/dsl.ts ADDED
@@ -0,0 +1,209 @@
1
+ // DSL field type definitions.
2
+ // Shape: { type: <type name>, <extension fields> }
3
+
4
+ export interface SchemaBase {
5
+ name: string;
6
+ description?: string;
7
+ }
8
+
9
+ export interface BaseField {
10
+ name: string;
11
+ /** 显示名称(中文标签) */
12
+ label?: string;
13
+ /** 字段描述 */
14
+ description?: string;
15
+ optional?: boolean;
16
+ readOnly?: boolean;
17
+ default?: string;
18
+ /** 所属 schema(db 或 dto) */
19
+ schema?: SchemaBase;
20
+ }
21
+
22
+ interface StringField extends BaseField {
23
+ type: 'string';
24
+ jsType: 'string';
25
+ minLength?: number;
26
+ maxLength?: number;
27
+ }
28
+
29
+ interface TextField extends BaseField {
30
+ type: 'text';
31
+ jsType: 'string';
32
+ }
33
+
34
+ interface IntField extends BaseField {
35
+ type: 'integer';
36
+ jsType: 'number';
37
+ min?: number;
38
+ max?: number;
39
+ }
40
+
41
+ interface BigintField extends BaseField {
42
+ type: 'bigint';
43
+ jsType: 'string';
44
+ // Value is transported as string to keep precision beyond 2^53.
45
+ }
46
+
47
+ interface DecimalField extends BaseField {
48
+ type: 'decimal';
49
+ jsType: 'string';
50
+ precision: number;
51
+ scale: number;
52
+ // Value is transported as string to avoid binary float error.
53
+ }
54
+
55
+ interface BooleanField extends BaseField {
56
+ type: 'boolean';
57
+ jsType: 'boolean';
58
+ }
59
+
60
+ interface DateField extends BaseField {
61
+ type: 'date';
62
+ jsType: 'Date';
63
+ }
64
+
65
+ interface TimeField extends BaseField {
66
+ type: 'time';
67
+ jsType: 'string';
68
+ }
69
+
70
+ interface DateTimeField extends BaseField {
71
+ type: 'datetime';
72
+ jsType: 'Date';
73
+ }
74
+
75
+ export interface EnumValue {
76
+ value: string | number;
77
+ symbol: string;
78
+ label: string;
79
+ }
80
+
81
+ export interface EnumField extends BaseField {
82
+ type: 'enum';
83
+ jsType: 'string' | 'number';
84
+ valueType: 'string' | 'integer';
85
+ /** JS 定义名称,如 MerchantStatus */
86
+ jsName?: string;
87
+ values: EnumValue[];
88
+ }
89
+
90
+ interface JsonField extends BaseField {
91
+ type: 'json';
92
+ jsType: 'object';
93
+ }
94
+
95
+ export type Field =
96
+ | StringField
97
+ | TextField
98
+ | IntField
99
+ | BigintField
100
+ | DecimalField
101
+ | BooleanField
102
+ | DateField
103
+ | TimeField
104
+ | DateTimeField
105
+ | EnumField
106
+ | JsonField;
107
+
108
+ export type Index = {
109
+ name?: string;
110
+ fields: Field | Field[];
111
+ unique?: boolean;
112
+ };
113
+
114
+ export type ForeignKey = {
115
+ fields: Field | Field[];
116
+ references: Field | Field[];
117
+ };
118
+
119
+ export interface TableSchema extends SchemaBase {
120
+ /** 分页 */
121
+ paginated?: boolean;
122
+ /** 系统操作者(如小程序为 C 端用户,管理端为运营) */
123
+ actor?: boolean;
124
+ /** id 生成器 */
125
+ generator?: string;
126
+ primaryKey?: Field | Field[];
127
+ indexes?: Index[];
128
+ /** 外键,引用其他表的字段 */
129
+ foreignKeys?: Record<string, ForeignKey>;
130
+ fields: Record<string, Field>;
131
+ }
132
+
133
+ // Field builders: type and jsType are fixed, pass extra properties only.
134
+ // The field name is written back from the map key later (see buildTable).
135
+
136
+ type FieldExtras<T extends Field> = Omit<T, 'name' | 'type' | 'jsType'>;
137
+
138
+ export function stringField(extra: FieldExtras<StringField> = {}): StringField {
139
+ return { name: '', type: 'string', jsType: 'string', ...extra };
140
+ }
141
+
142
+ export function textField(extra: FieldExtras<TextField> = {}): TextField {
143
+ return { name: '', type: 'text', jsType: 'string', ...extra };
144
+ }
145
+
146
+ export function intField(extra: FieldExtras<IntField> = {}): IntField {
147
+ return { name: '', type: 'integer', jsType: 'number', ...extra };
148
+ }
149
+
150
+ export function bigintField(extra: FieldExtras<BigintField> = {}): BigintField {
151
+ return { name: '', type: 'bigint', jsType: 'string', ...extra };
152
+ }
153
+
154
+ export function decimalField(extra: FieldExtras<DecimalField>): DecimalField {
155
+ return { name: '', type: 'decimal', jsType: 'string', ...extra };
156
+ }
157
+
158
+ export function booleanField(extra: FieldExtras<BooleanField> = {}): BooleanField {
159
+ return { name: '', type: 'boolean', jsType: 'boolean', ...extra };
160
+ }
161
+
162
+ export function dateField(extra: FieldExtras<DateField> = {}): DateField {
163
+ return { name: '', type: 'date', jsType: 'Date', ...extra };
164
+ }
165
+
166
+ export function timeField(extra: FieldExtras<TimeField> = {}): TimeField {
167
+ return { name: '', type: 'time', jsType: 'string', ...extra };
168
+ }
169
+
170
+ export function datetimeField(extra: FieldExtras<DateTimeField> = {}): DateTimeField {
171
+ return { name: '', type: 'datetime', jsType: 'Date', ...extra };
172
+ }
173
+
174
+ export function jsonField(extra: FieldExtras<JsonField> = {}): JsonField {
175
+ return { name: '', type: 'json', jsType: 'object', ...extra };
176
+ }
177
+
178
+ export function enumField(extra: Omit<EnumField, 'name' | 'type' | 'jsType'>): EnumField {
179
+ const jsType = extra.valueType === 'integer' ? 'number' : 'string';
180
+ return { name: '', type: 'enum', jsType, ...extra };
181
+ }
182
+
183
+ export function buildTable(
184
+ name: string,
185
+ schema: {
186
+ description?: string;
187
+ paginated?: boolean;
188
+ actor?: boolean;
189
+ generator?: string;
190
+ primaryKey?: Field | Field[];
191
+ indexes?: Index[];
192
+ foreignKeys?: Record<string, ForeignKey>;
193
+ fields: Record<string, Field>;
194
+ },
195
+ ): TableSchema {
196
+ const table: TableSchema = { name, ...schema };
197
+ for (const key of Object.keys(table.fields)) {
198
+ table.fields[key].name = key;
199
+ table.fields[key].schema = table;
200
+ }
201
+ for (const [fkName, fk] of Object.entries(table.foreignKeys ?? {})) {
202
+ const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
203
+ for (const ref of refs) {
204
+ if (!ref.schema) throw new Error(`foreign key ${fkName}: references field has no schema`);
205
+ if (ref.schema === table) throw new Error(`foreign key ${fkName}: cannot reference own table ${table.name}`);
206
+ }
207
+ }
208
+ return table;
209
+ }
package/src/dto.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { BaseField, Field, SchemaBase, TableSchema } from './dsl';
2
+
3
+ // Interface (DTO) field definitions.
4
+ // Naming convention: all DTO types and builders use the Dto prefix.
5
+ // A DtoField stores the database Field and the API-only extras separately.
6
+
7
+ export type Operator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ne';
8
+
9
+ /**
10
+ * Reference to an existing TypeBox base schema by its import location.
11
+ * Serializable metadata: the driver renders `import { name } from 'from'`
12
+ * and `Type.Intersect([name, ...])` — the runtime schema is never loaded by the DSL.
13
+ */
14
+ export interface ImportRef {
15
+ /** Module specifier, e.g. '@pylonts/core' */
16
+ from: string;
17
+ /** Named export, e.g. 'PageRequest' */
18
+ name: string;
19
+ /** Generic type arguments for the base schema (same-file DTO export names), e.g. PageResult(AdvertRow) */
20
+ args?: string[];
21
+ }
22
+
23
+ export type DtoExtras = {
24
+ pattern?: string;
25
+ /** 联合判断是否可选,定义后优先级高于 field.optional */
26
+ optional?: boolean;
27
+ /** 查询比较操作符(query 方向字段) */
28
+ operator?: Operator;
29
+ };
30
+
31
+ export type DtoArrayFieldDef = BaseField & {
32
+ type: 'array';
33
+ jsType: 'array';
34
+ items: DtoField;
35
+ };
36
+
37
+ export type DtoObjectFieldDef = BaseField & {
38
+ type: 'object';
39
+ jsType: 'object';
40
+ properties: Record<string, DtoField>;
41
+ };
42
+
43
+ export class DtoField {
44
+ /** DTO 语义字段名(接口字段名),与 field.name(数据库列名)含义不同。
45
+ * 构造时未知,由 buildMessage 从 map key 反写。 */
46
+ name: string;
47
+ /** 所属 DTO 容器(buildMessage 反写) */
48
+ schema?: DtoMessage;
49
+ field: Field | DtoArrayFieldDef | DtoObjectFieldDef;
50
+ pattern?: string;
51
+ optional?: boolean;
52
+ operator?: Operator;
53
+
54
+ constructor(field: Field | DtoArrayFieldDef | DtoObjectFieldDef, extra: DtoExtras = {}) {
55
+ this.name = '';
56
+ this.field = field;
57
+ this.pattern = extra.pattern;
58
+ this.optional = extra.optional;
59
+ this.operator = extra.operator;
60
+ }
61
+
62
+ setPattern(value: string): this {
63
+ this.pattern = value;
64
+ return this;
65
+ }
66
+
67
+ setOptional(value: boolean): this {
68
+ this.optional = value;
69
+ return this;
70
+ }
71
+
72
+ /** 查询比较操作符(query 方向字段)。Rule B: 查询字段恒为可选 */
73
+ op(value: Operator): this {
74
+ this.operator = value;
75
+ this.optional = true;
76
+ return this;
77
+ }
78
+
79
+ /** optional 优先于 field.optional */
80
+ isOptional(): boolean {
81
+ if (this.optional !== undefined) return this.optional;
82
+ return this.field.optional ?? false;
83
+ }
84
+ }
85
+
86
+ export class DtoArrayField extends DtoField {
87
+ declare field: DtoArrayFieldDef;
88
+
89
+ items(): DtoField {
90
+ return this.field.items;
91
+ }
92
+ }
93
+
94
+ export class DtoObjectField extends DtoField {
95
+ declare field: DtoObjectFieldDef;
96
+
97
+ properties(): Record<string, DtoField> {
98
+ return this.field.properties;
99
+ }
100
+ }
101
+
102
+ export type DtoDirection = 'input' | 'output' | 'query' | 'pk';
103
+
104
+ export interface DtoMessage extends SchemaBase {
105
+ /** 方向:输入或输出 */
106
+ direction: DtoDirection;
107
+ fields: Record<string, DtoField>;
108
+ /** TypeBox base schemas to intersect with at generation time (e.g. PageRequest) */
109
+ bases?: ImportRef[];
110
+ /** 引用已存在的 TypeBox base schema,例如 include({ from: '@pylonts/core', name: 'PageRequest' }) */
111
+ include(...refs: ImportRef[]): DtoMessage;
112
+ }
113
+
114
+ export function dtoField(field: Field, extra: DtoExtras = {}): DtoField {
115
+ return new DtoField(field, extra);
116
+ }
117
+
118
+ export function dtoArrayField(def: { items: DtoField } & Omit<BaseField, 'name'>, extra: DtoExtras = {}): DtoArrayField {
119
+ return new DtoArrayField({ name: '', type: 'array', jsType: 'array', ...def }, extra);
120
+ }
121
+
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);
124
+ }
125
+
126
+ 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
+ }
142
+ // Write back the DTO field name from the map key (safe: DtoField instances
143
+ // are created per DTO, never shared).
144
+ for (const key of Object.keys(message.fields)) {
145
+ const df = message.fields[key];
146
+ df.name = key;
147
+ df.schema = message;
148
+ // Custom (inline) fields are owned by this DTO: write back name + schema.
149
+ // Fields picked via from() share the database Field instance whose
150
+ // name/schema already point to the table — leave them untouched.
151
+ if (df.field.schema === undefined) {
152
+ df.field.name = key;
153
+ df.field.schema = message;
154
+ }
155
+ }
156
+ return message;
157
+ }
158
+
159
+ export function buildInput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
160
+ return buildMessage(name, 'input', fields, description);
161
+ }
162
+
163
+ export function buildOutput(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
164
+ return buildMessage(name, 'output', fields, description);
165
+ }
166
+
167
+ export function buildQuery(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
168
+ return buildMessage(name, 'query', fields, description);
169
+ }
170
+
171
+ export function buildPk(name: string, fields: Record<string, DtoField>, description?: string): DtoMessage {
172
+ return buildMessage(name, 'pk', fields, description);
173
+ }
174
+
175
+ /** Pick columns from a built table and wrap them as DTO fields (aligned with dto.from). */
176
+ export function from(table: TableSchema, fields: Field[]): Record<string, DtoField> {
177
+ const out: Record<string, DtoField> = {};
178
+ for (const field of fields) {
179
+ if (field.schema !== table) {
180
+ throw new Error(`dto.from(${table.name}): field ${field.name} does not belong to this table`);
181
+ }
182
+ out[field.name] = dtoField(field);
183
+ }
184
+ return out;
185
+ }
@@ -0,0 +1,44 @@
1
+ import { EnumField } from './dsl';
2
+
3
+ // Enum driver: renders an EnumField into a standalone TypeScript enum file.
4
+ // Shape matches the generated-enum product consumed by the TypeBox driver (Type.Enum):
5
+ //
6
+ // export enum UserStatus {
7
+ // ACTIVE = 'ACTIVE',
8
+ // DISABLED = 'DISABLED',
9
+ // }
10
+ //
11
+ // export const USER_STATUS_LABEL: Record<UserStatus, string> = {
12
+ // [UserStatus.ACTIVE]: '启用',
13
+ // [UserStatus.DISABLED]: '禁用',
14
+ // };
15
+
16
+ function renderString(s: string): string {
17
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
18
+ }
19
+
20
+ function renderValue(value: string | number): string {
21
+ return typeof value === 'number' ? String(value) : renderString(value);
22
+ }
23
+
24
+ /** 'UserStatus' → 'USER_STATUS_LABEL' (matches the label map naming convention) */
25
+ function labelName(jsName: string): string {
26
+ return `${jsName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase()}_LABEL`;
27
+ }
28
+
29
+ export function renderEnum(field: EnumField): string {
30
+ if (!field.jsName) throw new Error(`enum field ${field.name} requires jsName to render`);
31
+ const name = field.jsName;
32
+ const members = field.values.map((v) => ` ${v.symbol} = ${renderValue(v.value)},`);
33
+ const labels = field.values.map((v) => ` [${name}.${v.symbol}]: ${renderString(v.label)},`);
34
+ return [
35
+ `export enum ${name} {`,
36
+ ...members,
37
+ '}',
38
+ '',
39
+ `export const ${labelName(name)}: Record<${name}, string> = {`,
40
+ ...labels,
41
+ '};',
42
+ '',
43
+ ].join('\n');
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './dsl';
2
+ export * from './dto';
3
+ export * from './mysql-driver';
4
+ export * from './enum-driver';
5
+ export * from './typebox-driver';
@@ -0,0 +1,60 @@
1
+ import { Field, Index, TableSchema } from './dsl';
2
+
3
+ // MySQL driver: converts a TableSchema into a CREATE TABLE statement.
4
+
5
+ function columnType(field: Field): string {
6
+ switch (field.type) {
7
+ case 'string':
8
+ if (!field.maxLength) throw new Error(`field ${field.name} (string) requires maxLength`);
9
+ return `VARCHAR(${field.maxLength})`;
10
+ case 'text':
11
+ return 'TEXT';
12
+ case 'integer':
13
+ return 'INT';
14
+ case 'bigint':
15
+ return 'BIGINT';
16
+ case 'decimal':
17
+ return `DECIMAL(${field.precision}, ${field.scale})`;
18
+ case 'boolean':
19
+ return 'TINYINT(1)';
20
+ case 'date':
21
+ return 'DATE';
22
+ case 'time':
23
+ return 'TIME';
24
+ case 'datetime':
25
+ return 'DATETIME';
26
+ case 'enum':
27
+ // Enum is stored as a plain column: string -> VARCHAR(20), integer -> TINYINT.
28
+ return field.valueType === 'integer' ? 'TINYINT' : 'VARCHAR(20)';
29
+ case 'json':
30
+ return 'JSON';
31
+ }
32
+ }
33
+
34
+ function columnDef(field: Field): string {
35
+ const parts = [field.name, columnType(field)];
36
+ if (field.optional === false) parts.push('NOT NULL');
37
+ if (field.default !== undefined) parts.push(`DEFAULT '${field.default}'`);
38
+ return parts.join(' ');
39
+ }
40
+
41
+ function primaryKeyClause(schema: TableSchema): string | null {
42
+ if (!schema.primaryKey) return null;
43
+ const fields = Array.isArray(schema.primaryKey) ? schema.primaryKey : [schema.primaryKey];
44
+ return `PRIMARY KEY (${fields.map((f) => f.name).join(', ')})`;
45
+ }
46
+
47
+ function indexClause(index: Index): string {
48
+ const fields = Array.isArray(index.fields) ? index.fields : [index.fields];
49
+ const kind = index.unique ? 'UNIQUE KEY' : 'KEY';
50
+ const name = index.name ?? fields.map((f) => f.name).join('_');
51
+ return `${kind} ${name} (${fields.map((f) => f.name).join(', ')})`;
52
+ }
53
+
54
+ export function buildCreateTableSql(schema: TableSchema): string {
55
+ const lines = Object.values(schema.fields).map(columnDef);
56
+ const pk = primaryKeyClause(schema);
57
+ if (pk) lines.push(pk);
58
+ for (const index of schema.indexes ?? []) lines.push(indexClause(index));
59
+ return `CREATE TABLE \`${schema.name}\` (\n ${lines.join(',\n ')}\n);`;
60
+ }