diff --git a/plop/generators/crud.ts b/plop/generators/crud.ts index ed03b69..ca5baa7 100644 --- a/plop/generators/crud.ts +++ b/plop/generators/crud.ts @@ -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', diff --git a/plop/utils/schema-parser.ts b/plop/utils/schema-parser.ts new file mode 100644 index 0000000..db90982 --- /dev/null +++ b/plop/utils/schema-parser.ts @@ -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; +}