Files
seclusion/plop/utils/relation-parser.ts
charilezhou 3119460f13 feat(plop): 优化生成器支持 Prisma 关联关系
- 支持一对多/多对一关系定义并生成到 Prisma schema
- 简化流程:查询关联配置根据关系自动预填
- 修复 Handlebars 模板 HTML 转义导致的乱码问题
- 修复 controller 模板缺少 Prisma 导入的问题
- 新增页面模板 (page.hbs) 生成前端页面
- 添加 FindAllParams/PaginationQueryDto 索引签名修复类型兼容

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:30:18 +08:00

341 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 关联关系 DSL 解析器
*
* DSL 格式:
* - 关联关系: 关联名:目标模型 字段1,字段2,... [noList]
* - 多对多: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
* - 一对多: 关系名:目标模型 [optional]
*/
/**
* 关联关系配置(用于查询时 include
*/
export interface RelationConfig {
/** 关联名,如 'class' */
name: string;
/** 目标模型 PascalCase如 'Class' */
model: string;
/** 目标模型 camelCase如 'class' */
modelCamel: string;
/** select 字段列表 */
selectFields: string[];
/** 是否在列表中包含(默认 true */
includeInList: boolean;
}
/**
* 一对多关系配置(新模型包含多个目标模型)
* 例如Dormitory 包含多个 Student
* - 新模型students Student[]
* - 目标模型dormitoryId String?, dormitory Dormitory?
*/
export interface OneToManyConfig {
/** 关系名(复数),如 'students' */
name: string;
/** 目标模型 PascalCase如 'Student' */
targetModel: string;
/** 目标模型 camelCase如 'student' */
target: string;
/** 外键字段名(在目标模型中),如 'dormitoryId' */
foreignKey: string;
/** 反向关联名(在目标模型中),如 'dormitory' */
backRelation: string;
/** 是否可选(外键是否可为 null默认 true */
optional: boolean;
}
/**
* 多对一关系配置(新模型属于一个目标模型)
* 例如Grade(成绩) 属于 Student
* - 新模型studentId String, student Student
* - 目标模型grades Grade[]
*/
export interface ManyToOneConfig {
/** 外键字段名,如 'studentId' */
foreignKey: string;
/** 关联名,如 'student' */
name: string;
/** 目标模型 PascalCase如 'Student' */
targetModel: string;
/** 目标模型 camelCase如 'student' */
target: string;
/** 反向关联名(在目标模型中,复数),如 'grades' */
backRelation: string;
/** 是否可选,默认 false */
optional: boolean;
}
/**
* 多对多关系<E585B3><E7B3BB><EFBFBD>
*/
export interface ManyToManyConfig {
/** 关系名,如 'teachers' */
name: string;
/** 中间表名,如 'classTeacher' */
through: string;
/** 当前实体外键,如 'classId' */
foreignKey: string;
/** 目标实体外键,如 'teacherId' */
targetKey: string;
/** 目标模型 camelCase如 'teacher' */
target: string;
/** 目标模型 PascalCase如 'Teacher' */
targetModel: string;
/** 目标实体 select 字段列表 */
selectFields: string[];
}
/**
* 解析关联关系 DSL
*
* @example
* 输入:
* ```
* class:Class id,code,name
* headTeacher:Teacher id,teacherNo,name,subject noList
* ```
*
* 输出:
* [
* { name: 'class', model: 'Class', selectFields: ['id', 'code', 'name'], includeInList: true },
* { name: 'headTeacher', model: 'Teacher', selectFields: ['id', 'teacherNo', 'name', 'subject'], includeInList: false },
* ]
*/
export function parseRelations(dsl: string): RelationConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const relations: RelationConfig[] = [];
for (const line of lines) {
// 匹配格式: 关联名:目标模型 字段1,字段2,... [noList]
const match = line.match(/^(\w+):(\w+)\s+([\w,]+)(?:\s+(noList))?$/);
if (!match) {
console.warn(`无法解析关联配置行: ${line}`);
continue;
}
const [, name, model, fieldsStr, noListFlag] = match;
const selectFields = fieldsStr.split(',').map((f) => f.trim());
relations.push({
name,
model,
modelCamel: model.charAt(0).toLowerCase() + model.slice(1),
selectFields,
includeInList: !noListFlag,
});
}
return relations;
}
/**
* 解析多对多关系 DSL
*
* @example
* 输入:
* ```
* teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject
* ```
*
* 输出:
* [
* {
* name: 'teachers',
* through: 'classTeacher',
* foreignKey: 'classId',
* targetKey: 'teacherId',
* target: 'teacher',
* targetModel: 'Teacher',
* selectFields: ['id', 'teacherNo', 'name', 'subject'],
* },
* ]
*/
export function parseManyToMany(dsl: string): ManyToManyConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const configs: ManyToManyConfig[] = [];
for (const line of lines) {
// 匹配格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
const match = line.match(/^(\w+):(\w+):(\w+):(\w+):(\w+)\s+([\w,]+)$/);
if (!match) {
console.warn(`无法解析多对多配置行: ${line}`);
continue;
}
const [, name, through, foreignKey, targetKey, targetModel, fieldsStr] = match;
const selectFields = fieldsStr.split(',').map((f) => f.trim());
configs.push({
name,
through,
foreignKey,
targetKey,
target: targetModel.charAt(0).toLowerCase() + targetModel.slice(1),
targetModel,
selectFields,
});
}
return configs;
}
/**
* 解析统计关系(逗号分隔的字符串)
*
* @example
* 输入: "students, orders"
* 输出: ['students', 'orders']
*/
export function parseCountRelations(input: string): string[] {
if (!input || !input.trim()) {
return [];
}
return input
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/**
* 获取字段的 TypeScript 类型(用于生成关联类型)
* 简化版本,假设大多数字段是 string
*/
export function getFieldType(fieldName: string): string {
// 常见的非字符串字段
const numberFields = ['id', 'count', 'amount', 'price', 'quantity', 'sort', 'order'];
const booleanFields = ['is', 'has', 'can', 'should', 'enabled', 'active', 'visible'];
if (numberFields.some((f) => fieldName.toLowerCase().includes(f))) {
return 'number';
}
if (booleanFields.some((f) => fieldName.toLowerCase().startsWith(f))) {
return 'boolean';
}
return 'string';
}
/**
* 生成关联类型的 select 对象字符串
*
* @example
* 输入: ['id', 'code', 'name']
* 输出: '{ id: true, code: true, name: true }'
*/
export function generateSelectObject(fields: string[]): string {
const pairs = fields.map((f) => `${f}: true`);
return `{ ${pairs.join(', ')} }`;
}
/**
* 生成关联类型定义字符串
*
* @example
* 输入: { name: 'class', selectFields: ['id', 'code', 'name'] }
* 输出: 'class?: { id: string; code: string; name: string } | null'
*/
export function generateRelationTypeField(config: RelationConfig): string {
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
return `${config.name}?: { ${fieldTypes.join('; ')} } | null`;
}
/**
* 生成多对多类型定义字符串
*
* @example
* 输入: { name: 'teachers', target: 'teacher', selectFields: ['id', 'name'] }
* 输出: 'teachers?: Array<{ teacher: { id: string; name: string } }>'
*/
export function generateManyToManyTypeField(config: ManyToManyConfig): string {
const fieldTypes = config.selectFields.map((f) => `${f}: ${getFieldType(f)}`);
return `${config.name}?: Array<{ ${config.target}: { ${fieldTypes.join('; ')} } }>`;
}
/**
* 解析一对多关系配置
* 格式: 关系名:目标模型 [optional]
*
* @example
* 输入: "students:Student optional"
* 输出: { name: 'students', targetModel: 'Student', target: 'student', foreignKey: 'dormitoryId', backRelation: 'dormitory', optional: true }
*/
export function parseOneToMany(dsl: string, currentModelName: string): OneToManyConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const configs: OneToManyConfig[] = [];
const currentModelCamel = currentModelName.charAt(0).toLowerCase() + currentModelName.slice(1);
for (const line of lines) {
// 匹配格式: 关系名:目标模型 [optional]
const match = line.match(/^(\w+):(\w+)(?:\s+(optional))?$/);
if (!match) {
console.warn(`无法解析一对多配置行: ${line}`);
continue;
}
const [, name, targetModel, optionalFlag] = match;
const target = targetModel.charAt(0).toLowerCase() + targetModel.slice(1);
configs.push({
name,
targetModel,
target,
foreignKey: `${currentModelCamel}Id`,
backRelation: currentModelCamel,
optional: !!optionalFlag,
});
}
return configs;
}
/**
* 解析多对一关系配置
* 格式: 关联名:目标模型 [optional]
*
* @example
* 输入: "student:Student"
* 输出: { foreignKey: 'studentId', name: 'student', targetModel: 'Student', target: 'student', backRelation: 'grades', optional: false }
*/
export function parseManyToOne(dsl: string, currentModelPluralName: string): ManyToOneConfig[] {
const lines = dsl
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const configs: ManyToOneConfig[] = [];
for (const line of lines) {
// 匹配格式: 关联名:目标模型 [optional]
const match = line.match(/^(\w+):(\w+)(?:\s+(optional))?$/);
if (!match) {
console.warn(`无法解析多对一配置行: ${line}`);
continue;
}
const [, name, targetModel, optionalFlag] = match;
const target = targetModel.charAt(0).toLowerCase() + targetModel.slice(1);
configs.push({
foreignKey: `${target}Id`,
name,
targetModel,
target,
backRelation: currentModelPluralName,
optional: !!optionalFlag,
});
}
return configs;
}