UNPKG

prismaql

Version:

A powerful tool for managing and editing Prisma schema files using a SQL-like DSL.

400 lines 19.6 kB
import pkg from '@prisma/internals'; const { getDMMF } = pkg; import pluralize from 'pluralize'; import { pascalCase } from 'change-case'; export class PrismaQlRelationCollector { models; relations = []; getRelations() { return this.relations; } async setModels(models) { this.relations = []; this.models = models; await this.parsePrismaSchema(); return this.relations; } constructor(models = []) { this.models = models; } getRelation(modelName, fieldName) { return this.relations.find(r => r.modelName === modelName && r.fieldName === fieldName) || null; } /** * Function to detect one-to-one relationships in Prisma DMMF models. * It now detects **both** sides of the relationship (forward & reverse). */ detectOneToOneRelations(models) { const relations = []; // Step 1: Identify explicit M:N join tables to exclude const joinTables = models.filter(model => { const relationFields = model.fields.filter(f => f.kind === "object" && !f.isList); const scalarFields = model.fields.filter(f => f.kind === "scalar"); // Must have exactly two foreign key relations if (relationFields.length !== 2) return false; // Must have a composite primary key return model.primaryKey?.fields?.every(f => scalarFields.some(sf => sf.name === f)); }).map(m => m.name); for (const model of models) { // Step 2: Skip explicit Many-to-Many join tables if (joinTables.includes(model.name)) continue; for (const field of model.fields) { if (field.kind !== "object" || field.isList) { continue; } if (!field.relationFromFields || field.relationFromFields.length === 0) { continue; } // Step 3: Identify the foreign key let isUniqueRelation = false; let foreignKeys = []; const relationFromFields = field.relationFromFields; if (relationFromFields.length === 1) { const fkField = model.fields.find(f => f.name === relationFromFields[0]); if (fkField?.isUnique || model.primaryKey?.fields.includes(fkField?.name)) { isUniqueRelation = true; foreignKeys.push(fkField?.name); } } else { const isCompositePrimaryKey = model.primaryKey?.fields.every(f => relationFromFields.includes(f)); const isCompositeUnique = model.uniqueIndexes?.some(idx => idx.fields.length === relationFromFields.length && idx.fields.every(f => relationFromFields.includes(f))); if (isCompositePrimaryKey || isCompositeUnique) { isUniqueRelation = true; foreignKeys = [...relationFromFields]; } } if (!isUniqueRelation) { continue; } // Step 4: Find the related model const relatedModel = models.find(m => m.name === field.type); if (!relatedModel) { continue; } // Step 5: Find the inverse field const inverseField = relatedModel.fields.find(f => f.relationName === field.relationName && f.name !== field.name); // Step 6: Determine reference keys const referenceKeys = field.relationToFields || []; // Step 7: Avoid duplicate detection const existingRelation = relations.find(r => r.modelName === model.name && r.relatedModel === relatedModel.name && r.fieldName === field.name); if (existingRelation) { continue; // Skip if already recorded } // Step 8: Push the forward relation (owning side) relations.push({ type: "1:1", fieldName: field.name, modelName: model.name, relatedModel: relatedModel.name, relationName: field.relationName, foreignKey: foreignKeys.join(", "), referenceKey: referenceKeys.join(", "), inverseField: inverseField?.name, relationDirection: "forward", constraints: [...foreignKeys] }); // Step 9: Push the backward relation (inverse side) if an inverse field exists if (inverseField) { relations.push({ type: "1:1", fieldName: inverseField.name, modelName: relatedModel.name, relatedModel: model.name, relationName: field.relationName, foreignKey: undefined, referenceKey: undefined, inverseField: field.name, relationDirection: "backward", constraints: [...referenceKeys] }); } } } return relations; } detectOneToManyRelations(models) { const relations = []; for (const model of models) { for (const field of model.fields) { if (field.kind !== "object" || !field.isList) { continue; } // Find the related model by matching the field type const relatedModel = models.find(m => m.name === field.type); if (!relatedModel) continue; // Look for the inverse field in the related model const inverseField = relatedModel.fields.find(f => f.relationName === field.relationName && !f.isList); if (!inverseField) continue; // Ensure that the inverse field has a defined foreign key (relationFromFields) if (!inverseField.relationFromFields || inverseField.relationFromFields.length === 0) { continue; } // Join fields for composite keys, if necessary const fk = inverseField.relationFromFields.join(", "); const rk = inverseField.relationToFields && inverseField.relationToFields.length > 0 ? inverseField.relationToFields.join(", ") : undefined; // Avoid duplicates in self-relations by checking if we already recorded this const existingRelation = relations.find(r => r.modelName === model.name && r.relatedModel === relatedModel.name && r.fieldName === field.name && r.relationName === field.relationName); if (existingRelation) { continue; // Skip if already recorded } // Forward relation: The "many" side (students) relations.push({ type: "1:M", fieldName: inverseField.name, // The field that holds the FK modelName: relatedModel.name, relatedModel: model.name, relationName: field.relationName, // Track relationName! foreignKey: fk, referenceKey: rk, inverseField: field.name, relationDirection: "forward", constraints: [...inverseField.relationFromFields] }); // Backward relation: The "one" side (teacher) relations.push({ type: "1:M", fieldName: field.name, modelName: model.name, relatedModel: relatedModel.name, relationName: field.relationName, // Track relationName! foreignKey: undefined, referenceKey: undefined, inverseField: inverseField.name, relationDirection: "backward", constraints: [...inverseField.relationFromFields] }); } } return relations; } getManyToManyTableName(modelA, modelB, relationName) { return getManyToManyTableName(modelA, modelB, relationName); } detectManyToManyRelations(models) { const relations = []; const seenRelations = new Set(); // Prevent duplicate relations for (const model of models) { for (const field of model.fields) { // Skip non-list and non-object fields if (field.kind !== "object" || !field.isList) { continue; } // Find the related model const relatedModel = models.find(m => m.name === field.type); if (!relatedModel) continue; // Find all inverse fields (not just the first match) const inverseFields = relatedModel.fields.filter(f => f.relationName === field.relationName && f.isList); if (inverseFields.length === 0) continue; // Ensure this is truly an implicit M:N relation (no foreign keys) const hasExplicitForeignKey = models.some(m => m.fields.some(f => f.relationName === field.relationName && (f.relationFromFields || []).length > 0)); if (hasExplicitForeignKey) { continue; // Skip 1:M relations like `TeacherStudents` } for (const inverseField of inverseFields) { // **Fix 1: Avoid adding same field as inverse** if (field.name === inverseField.name) { continue; // Skip redundant self-mapping } // **Fix 2: Ensure only unique relations are added** const relationKey = [model.name, relatedModel.name, field.relationName] .sort() // Ensures consistency (A-B == B-A) .join("|"); if (seenRelations.has(relationKey)) { continue; // Avoid duplicate bidirectional relations } seenRelations.add(relationKey); const isExplicit = models.some(m => m.fields.some(f => f.relationName === field.relationName && (f.relationFromFields || []).length > 0)); const tableName = this.getManyToManyTableName(model.name, relatedModel.name, isExplicit ? field.relationName : undefined); // Add relation for the current field relations.push({ type: "M:N", fieldName: field.name, modelName: model.name, relatedModel: relatedModel.name, relationName: field.relationName, foreignKey: undefined, referenceKey: undefined, inverseField: inverseField.name, relationDirection: "bidirectional", relationTable: tableName, // Prisma implicit table naming convention constraints: [] }); if (inverseField.name) { // Add relation for the inverse field relations.push({ type: "M:N", fieldName: inverseField.name, modelName: relatedModel.name, relatedModel: model.name, relationName: field.relationName, foreignKey: undefined, referenceKey: undefined, inverseField: field.name, relationDirection: "bidirectional", relationTable: tableName, // Prisma implicit table naming convention constraints: [] }); } } } } return relations; } detectExplicitManyToManyRelations(models) { const relations = []; for (const model of models) { // Step 1: Identify potential join tables const relationFields = model.fields.filter(f => f.kind === "object" && !f.isList); const scalarFields = model.fields.filter(f => f.kind === "scalar"); // A join table must have **exactly two foreign key relations** if (relationFields.length !== 2) { continue; } // Step 2: Ensure both relations are linked via foreign keys const [relation1, relation2] = relationFields; if (!relation1.relationFromFields?.length || !relation2.relationFromFields?.length) { continue; } // Step 3: Find the related models const model1 = models.find(m => m.name === relation1.type); const model2 = models.find(m => m.name === relation2.type); if (!model1 || !model2) { continue; } // Step 4: Ensure that **both foreign keys** form a composite primary key const compositePK = model.primaryKey?.fields?.every(f => scalarFields.some(sf => sf.name === f)); if (!compositePK) { continue; // If the table does not use a composite key, it's not an explicit M:N relation } // Step 5: Register the **Explicit Many-to-Many Relation** relations.push({ type: "M:N", fieldName: pluralize(model1.name), // Example: "posts" modelName: model1.name, relatedModel: model2.name, relationName: relation1.relationName, foreignKey: relation1.relationFromFields.join(", "), referenceKey: relation1.relationToFields?.join(", "), inverseField: pluralize(model2.name.toLowerCase()), // Example: "categories" relationDirection: "bidirectional", relationTable: model.name, // The join table constraints: [...relation1.relationFromFields, ...relation2.relationFromFields] }); relations.push({ type: "M:N", fieldName: pluralize(model2.name), modelName: model2.name, relatedModel: model1.name, relationName: relation2.relationName, foreignKey: relation2.relationFromFields.join(", "), referenceKey: relation2.relationToFields?.join(", "), inverseField: pluralize(model1.name.toLowerCase()), relationDirection: "bidirectional", relationTable: model.name, // The join table constraints: [...relation1.relationFromFields, ...relation2.relationFromFields] }); // Add the join table model itself to the relations if it has relations relations.push({ type: "M:N", fieldName: undefined, modelName: model.name, relatedModel: `${model1.name}, ${model2.name}`, relationName: `${relation1.relationName}, ${relation2.relationName}`, foreignKey: `${relation1.relationFromFields.join(", ")}, ${relation2.relationFromFields.join(", ")}`, referenceKey: `${relation1.relationToFields?.join(", ")}, ${relation2.relationToFields?.join(", ")}`, inverseField: `${pluralize(model1.name)}, ${pluralize(model2.name)}`, relationDirection: "bidirectional", relationTable: model.name, constraints: [...relation1.relationFromFields, ...relation2.relationFromFields] }); } return relations; } deduplicateRelations(relations) { const seen = new Set(); const result = []; for (const r of relations) { const key = [ r.type, r.modelName, r.relatedModel, r.fieldName, r.relationName, r.foreignKey || '', r.referenceKey || '', r.inverseField || '', r.relationTable || '', r.relationDirection || '', (r.constraints || []).join(',') ].join('|'); if (!seen.has(key)) { seen.add(key); result.push(r); } } return result; } async collectRelations(models) { // 1) Detect explicit M:N relations const explicitM2MRelations = this.detectExplicitManyToManyRelations(models); // Collect join table names to exclude them from 1:1 and 1:M detection const explicitJoinTableNames = new Set(explicitM2MRelations .filter(r => r.modelName === r.relationTable) .map(r => r.modelName)); // 2) Detect implicit M:N relations const implicitM2MRelations = this.detectManyToManyRelations(models); // 3) Filter models to exclude join tables for 1:1 and 1:M detection const modelsForOneX = models.filter(m => !explicitJoinTableNames.has(m.name)); // 4) Detect 1:1 and 1:M relations on filtered models const oneToOneRelations = this.detectOneToOneRelations(modelsForOneX); const oneToManyRelations = this.detectOneToManyRelations(modelsForOneX); // 5) Combine all relations into one array let relations = [ ...explicitM2MRelations, ...implicitM2MRelations, ...oneToOneRelations, ...oneToManyRelations ]; relations = this.deduplicateRelations(relations); return relations; } async parsePrismaSchema(schema) { const dmmf = schema ? await getDMMF({ datamodel: schema }) : null; const models = dmmf ? dmmf.datamodel.models : this.models; const relations = this.collectRelations(models); this.relations = await relations; } } export const getManyToManyTableName = (modelA, modelB, relationName) => { // If the relation is explicit (relationName is set), use it if (relationName) return relationName; // In implicit M:N relations, Prisma automatically names tables as _ModelAToModelB (in alphabetical order) const [first, second] = [modelA, modelB].sort(); return `_${pascalCase(first)}To${pascalCase(second)}`; }; export const getManyToManyModelName = (modelA, modelB, relationName) => { // If the relation is explicit (relationName is set), use it if (relationName) return relationName; // In implicit M:N relations, Prisma automatically names tables as _ModelAToModelB (in alphabetical order) const [first, second] = [modelA, modelB].sort(); return `${pascalCase(first)}To${pascalCase(second)}`; }; //# sourceMappingURL=field-relation-collector.js.map