feat(plop): 关联配置支持从 Schema 选择模型和字段

- 新增 schema-parser.ts 解析 Prisma schema 文件
- 关联关系先选择模型,再编辑预填的配置
- 多对多关系自动推断中间表配置
- 可用字段作为注释显示,方便用户参考

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 16:14:48 +08:00
parent 31598d79ae
commit 8c904c419a
2 changed files with 298 additions and 21 deletions

View File

@@ -17,6 +17,14 @@ import {
type RelationConfig, type RelationConfig,
type ManyToManyConfig, type ManyToManyConfig,
} from '../utils/relation-parser'; } from '../utils/relation-parser';
import {
parseSchema,
getAvailableModels,
getSelectableFields,
getModelByName,
inferManyToManyConfig,
type SchemaModel,
} from '../utils/schema-parser';
// 服务类型 // 服务类型
type ServiceType = 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService'; type ServiceType = 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService';
@@ -140,42 +148,123 @@ name:string 名称 "示例名称" min:2 max:100
}, },
// 关联关系配置RelationCrudService 或 ManyToManyCrudService 时显示) // 关联关系配置RelationCrudService 或 ManyToManyCrudService 时显示)
{ {
type: 'editor', type: 'checkbox',
name: 'relationsRaw', name: 'relationModels',
message: '定义关联关系(见下方示例:', message: '选择要关联的模型(可多选,之后会配置字段:',
when: (answers: { serviceType: ServiceType }) => when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'RelationCrudService' || answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService', answers.serviceType === 'ManyToManyCrudService',
default: `# 关联关系定义(每行一个关联) choices: () => {
# 格式: 关联名:目标模型 字段1,字段2,... [noList] try {
# noList: 不在列表中包含 const models = parseSchema();
# return getAvailableModels(models).map((m) => ({
# 示例: name: m,
# class:Class id,code,name value: m,
# headTeacher:Teacher id,teacherNo,name,subject noList }));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'relationsRaw',
message: '配置关联关系(已预填选中的模型,可调整字段和选项):',
when: (answers: { serviceType: ServiceType; relationModels?: string[] }) =>
(answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService') &&
answers.relationModels &&
answers.relationModels.length > 0,
default: (answers: { relationModels?: string[] }) => {
const models = parseSchema();
const lines: string[] = [
'# 关联关系配置(每行一个)',
'# 格式: 关联名:目标模型 字段1,字段2,... [noList]',
'# noList: 不在列表中显示',
'#',
];
`, for (const modelName of answers.relationModels || []) {
const model = getModelByName(models, modelName);
if (model) {
const fields = getSelectableFields(model);
// 默认选择 id 和前几个重要字段
const defaultFields = fields.slice(0, 4).join(',');
const relationName = modelName.charAt(0).toLowerCase() + modelName.slice(1);
lines.push(`${relationName}:${modelName} ${defaultFields}`);
lines.push(`# 可用字段: ${fields.join(', ')}`);
}
}
return lines.join('\n') + '\n';
},
}, },
// 多对多关系配置ManyToManyCrudService 时显示) // 多对多关系配置ManyToManyCrudService 时显示)
{ {
type: 'editor', type: 'checkbox',
name: 'manyToManyRaw', name: 'manyToManyModels',
message: '定义多对多关系(见下方示例:', message: '选择多对多关联的模型(可多选:',
when: (answers: { serviceType: ServiceType }) => when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'ManyToManyCrudService', answers.serviceType === 'ManyToManyCrudService',
default: `# 多对多关系定义(每行一个关系) choices: () => {
# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,... try {
# const models = parseSchema();
# 示例: return getAvailableModels(models).map((m) => ({
# teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject name: m,
value: m,
}));
} catch {
return [];
}
},
},
{
type: 'editor',
name: 'manyToManyRaw',
message: '配置多对多关系(已预填选中的模型,请确认中间表配置):',
when: (answers: { serviceType: ServiceType; manyToManyModels?: string[] }) =>
answers.serviceType === 'ManyToManyCrudService' &&
answers.manyToManyModels &&
answers.manyToManyModels.length > 0,
default: (answers: { name: string; manyToManyModels?: string[] }) => {
const models = parseSchema();
const currentModelName = answers.name.charAt(0).toUpperCase() + answers.name.slice(1);
const lines: string[] = [
'# 多对多关系配置(每行一个)',
'# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...',
'#',
];
`, for (const targetModelName of answers.manyToManyModels || []) {
const targetModel = getModelByName(models, targetModelName);
if (targetModel) {
const config = inferManyToManyConfig(models, currentModelName, targetModelName);
const fields = getSelectableFields(targetModel);
const defaultFields = fields.slice(0, 4).join(',');
const relationName = targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1) + 's';
if (config) {
lines.push(`${relationName}:${config.through}:${config.foreignKey}:${config.targetKey}:${targetModelName} ${defaultFields}`);
} else {
// 如果无法推断,给出模板
const through = `${answers.name}${targetModelName}`;
const foreignKey = `${answers.name}Id`;
const targetKey = `${targetModelName.charAt(0).toLowerCase() + targetModelName.slice(1)}Id`;
lines.push(`${relationName}:${through}:${foreignKey}:${targetKey}:${targetModelName} ${defaultFields}`);
lines.push(`# ⚠ 请确认中间表配置是否正确`);
}
lines.push(`# 可用字段: ${fields.join(', ')}`);
}
}
return lines.join('\n') + '\n';
},
}, },
// 统计关系配置RelationCrudService 或 ManyToManyCrudService 时显示) // 统计关系配置RelationCrudService 或 ManyToManyCrudService 时显示)
{ {
type: 'input', type: 'input',
name: 'countRelationsRaw', name: 'countRelationsRaw',
message: '统计关系(逗号分隔,如 students,orders,留空跳过:', message: '统计关系(输入将来会有的关系名,逗号分隔,如 students,orders:',
when: (answers: { serviceType: ServiceType }) => when: (answers: { serviceType: ServiceType }) =>
answers.serviceType === 'RelationCrudService' || answers.serviceType === 'RelationCrudService' ||
answers.serviceType === 'ManyToManyCrudService', answers.serviceType === 'ManyToManyCrudService',

188
plop/utils/schema-parser.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Prisma Schema 解析器
* 解析 schema.prisma 文件,提取模型和字段信息
*/
import fs from 'fs';
import path from 'path';
/** 字段信息 */
export interface SchemaField {
name: string;
type: string;
isOptional: boolean;
isArray: boolean;
isRelation: boolean;
relationModel?: string;
}
/** 模型信息 */
export interface SchemaModel {
name: string;
fields: SchemaField[];
relations: SchemaField[];
}
/**
* 解析 Prisma schema 文件
*/
export function parseSchema(schemaPath?: string): SchemaModel[] {
const defaultPath = path.resolve(process.cwd(), 'apps/api/prisma/schema.prisma');
const filePath = schemaPath || defaultPath;
if (!fs.existsSync(filePath)) {
console.warn(`Schema file not found: ${filePath}`);
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
const models: SchemaModel[] = [];
// 匹配 model 定义
const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g;
let match;
while ((match = modelRegex.exec(content)) !== null) {
const modelName = match[1];
const modelBody = match[2];
const fields: SchemaField[] = [];
const relations: SchemaField[] = [];
// 解析字段
const lines = modelBody.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// 跳过空行、注释、@@指令
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) {
continue;
}
// 匹配字段定义: fieldName Type? @...
const fieldMatch = trimmed.match(/^(\w+)\s+(\w+)(\[\])?\??/);
if (fieldMatch) {
const [, fieldName, fieldType, isArray] = fieldMatch;
const isOptional = trimmed.includes('?');
// 判断是否是关系字段(类型是其他模型)
const isRelation = /^[A-Z]/.test(fieldType) && fieldType !== 'String' &&
fieldType !== 'Int' && fieldType !== 'Float' && fieldType !== 'Boolean' &&
fieldType !== 'DateTime' && fieldType !== 'Json' && fieldType !== 'BigInt' &&
fieldType !== 'Decimal' && fieldType !== 'Bytes';
const field: SchemaField = {
name: fieldName,
type: fieldType,
isOptional,
isArray: !!isArray,
isRelation,
relationModel: isRelation ? fieldType : undefined,
};
fields.push(field);
if (isRelation) {
relations.push(field);
}
}
}
models.push({
name: modelName,
fields,
relations,
});
}
return models;
}
/**
* 获取所有可用的目标模型(排除中间表)
*/
export function getAvailableModels(models: SchemaModel[]): string[] {
// 排除常见的中间表模式
const excludePatterns = [
/^User\w*Role$/i,
/^Role\w*Permission$/i,
/^Role\w*Menu$/i,
/^\w+Teacher$/i,
/^\w+Student$/i,
];
return models
.filter((m) => {
// 排除中间表(通常只有外键字段和 createdAt
const nonIdFields = m.fields.filter((f) =>
!['id', 'createdAt', 'updatedAt', 'deletedAt'].includes(f.name) && !f.name.endsWith('Id')
);
// 如果非 ID 字段少于 2 个,可能是中间表
if (nonIdFields.length < 2) {
return false;
}
// 排除匹配的模式
return !excludePatterns.some((p) => p.test(m.name));
})
.map((m) => m.name);
}
/**
* 获取模型的可选字段(用于 select
*/
export function getSelectableFields(model: SchemaModel): string[] {
return model.fields
.filter((f) => !f.isRelation && !f.isArray)
.map((f) => f.name);
}
/**
* 获取模型的关系字段(用于统计关系)
*/
export function getRelationFields(model: SchemaModel): string[] {
return model.relations
.filter((r) => r.isArray) // 只返回一对多/多对多关系
.map((r) => r.name);
}
/**
* 根据模型名获取模型
*/
export function getModelByName(models: SchemaModel[], name: string): SchemaModel | undefined {
return models.find((m) => m.name === name);
}
/**
* 推断多对多中间表配置
* 根据当前模型和目标模型,找到中间表
*/
export function inferManyToManyConfig(
models: SchemaModel[],
currentModel: string,
targetModel: string
): { through: string; foreignKey: string; targetKey: string } | null {
// 查找包含两个模型外键的中间表
for (const model of models) {
const fields = model.fields;
const currentFk = fields.find((f) =>
f.name.toLowerCase() === `${currentModel.toLowerCase()}id`
);
const targetFk = fields.find((f) =>
f.name.toLowerCase() === `${targetModel.toLowerCase()}id`
);
if (currentFk && targetFk) {
// 转换为 camelCase
const through = model.name.charAt(0).toLowerCase() + model.name.slice(1);
return {
through,
foreignKey: currentFk.name,
targetKey: targetFk.name,
};
}
}
return null;
}