feat(plop): 关联配置支持从 Schema 选择模型和字段
- 新增 schema-parser.ts 解析 Prisma schema 文件 - 关联关系先选择模型,再编辑预填的配置 - 多对多关系自动推断中间表配置 - 可用字段作为注释显示,方便用户参考 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,14 @@ import {
|
||||
type RelationConfig,
|
||||
type ManyToManyConfig,
|
||||
} from '../utils/relation-parser';
|
||||
import {
|
||||
parseSchema,
|
||||
getAvailableModels,
|
||||
getSelectableFields,
|
||||
getModelByName,
|
||||
inferManyToManyConfig,
|
||||
type SchemaModel,
|
||||
} from '../utils/schema-parser';
|
||||
|
||||
// 服务类型
|
||||
type ServiceType = 'CrudService' | 'RelationCrudService' | 'ManyToManyCrudService';
|
||||
@@ -140,42 +148,123 @@ name:string 名称 "示例名称" min:2 max:100
|
||||
},
|
||||
// 关联关系配置(RelationCrudService 或 ManyToManyCrudService 时显示)
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'relationsRaw',
|
||||
message: '定义关联关系(见下方示例):',
|
||||
type: 'checkbox',
|
||||
name: 'relationModels',
|
||||
message: '选择要关联的模型(可多选,之后会配置字段):',
|
||||
when: (answers: { serviceType: ServiceType }) =>
|
||||
answers.serviceType === 'RelationCrudService' ||
|
||||
answers.serviceType === 'ManyToManyCrudService',
|
||||
default: `# 关联关系定义(每行一个关联)
|
||||
# 格式: 关联名:目标模型 字段1,字段2,... [noList]
|
||||
# noList: 不在列表中包含
|
||||
#
|
||||
# 示例:
|
||||
# class:Class id,code,name
|
||||
# headTeacher:Teacher id,teacherNo,name,subject noList
|
||||
choices: () => {
|
||||
try {
|
||||
const models = parseSchema();
|
||||
return getAvailableModels(models).map((m) => ({
|
||||
name: m,
|
||||
value: m,
|
||||
}));
|
||||
} 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 时显示)
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'manyToManyRaw',
|
||||
message: '定义多对多关系(见下方示例):',
|
||||
type: 'checkbox',
|
||||
name: 'manyToManyModels',
|
||||
message: '选择多对多关联的模型(可多选):',
|
||||
when: (answers: { serviceType: ServiceType }) =>
|
||||
answers.serviceType === 'ManyToManyCrudService',
|
||||
default: `# 多对多关系定义(每行一个关系)
|
||||
# 格式: 关系名:中间表:外键:目标键:目标模型 字段1,字段2,...
|
||||
#
|
||||
# 示例:
|
||||
# teachers:classTeacher:classId:teacherId:Teacher id,teacherNo,name,subject
|
||||
choices: () => {
|
||||
try {
|
||||
const models = parseSchema();
|
||||
return getAvailableModels(models).map((m) => ({
|
||||
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 时显示)
|
||||
{
|
||||
type: 'input',
|
||||
name: 'countRelationsRaw',
|
||||
message: '统计关系(逗号分隔,如 students,orders,留空跳过):',
|
||||
message: '统计关系(输入将来会有的关系名,逗号分隔,如 students,orders):',
|
||||
when: (answers: { serviceType: ServiceType }) =>
|
||||
answers.serviceType === 'RelationCrudService' ||
|
||||
answers.serviceType === 'ManyToManyCrudService',
|
||||
|
||||
188
plop/utils/schema-parser.ts
Normal file
188
plop/utils/schema-parser.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user