diff --git a/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts b/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts index 7d0d124b..0275bca9 100644 --- a/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts @@ -957,4 +957,247 @@ describe('DBML Export - Issue Fixes', () => { '(email, created_at) [name: "idx_email_created"]' ); }); + + it('should handle tables with multiple relationships correctly', () => { + const diagram: Diagram = { + id: 'test-diagram', + name: 'Test', + databaseType: DatabaseType.POSTGRESQL, + createdAt: new Date(), + updatedAt: new Date(), + tables: [ + { + id: 'users', + name: 'users', + x: 0, + y: 0, + fields: [ + { + id: 'users_id', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + ], + indexes: [], + color: 'blue', + isView: false, + createdAt: Date.now(), + }, + { + id: 'posts', + name: 'posts', + x: 0, + y: 0, + fields: [ + { + id: 'posts_id', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + { + id: 'posts_user_id', + name: 'user_id', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + ], + indexes: [], + color: 'blue', + isView: false, + createdAt: Date.now(), + }, + { + id: 'reviews', + name: 'reviews', + x: 0, + y: 0, + fields: [ + { + id: 'reviews_id', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + { + id: 'reviews_user_id', + name: 'user_id', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + ], + indexes: [], + color: 'blue', + isView: false, + createdAt: Date.now(), + }, + { + id: 'user_activities', + name: 'user_activities', + x: 0, + y: 0, + fields: [ + { + id: 'activities_id', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + { + id: 'activities_entity_id', + name: 'entity_id', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + nullable: false, + unique: false, + collation: null, + default: null, + characterMaximumLength: null, + createdAt: Date.now(), + }, + { + id: 'activities_type', + name: 'activity_type', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + nullable: true, + unique: false, + collation: null, + default: null, + characterMaximumLength: '50', + createdAt: Date.now(), + }, + ], + indexes: [], + color: 'blue', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [ + { + id: 'rel1', + name: 'fk_posts_user', + sourceTableId: 'posts', + sourceFieldId: 'posts_user_id', + targetTableId: 'users', + targetFieldId: 'users_id', + sourceCardinality: 'many', + targetCardinality: 'one', + createdAt: Date.now(), + }, + { + id: 'rel2', + name: 'fk_reviews_user', + sourceTableId: 'reviews', + sourceFieldId: 'reviews_user_id', + targetTableId: 'users', + targetFieldId: 'users_id', + sourceCardinality: 'many', + targetCardinality: 'one', + createdAt: Date.now(), + }, + { + id: 'rel3', + name: 'fk_activities_posts', + sourceTableId: 'user_activities', + sourceFieldId: 'activities_entity_id', + targetTableId: 'posts', + targetFieldId: 'posts_id', + sourceCardinality: 'many', + targetCardinality: 'one', + createdAt: Date.now(), + }, + { + id: 'rel4', + name: 'fk_activities_reviews', + sourceTableId: 'user_activities', + sourceFieldId: 'activities_entity_id', + targetTableId: 'reviews', + targetFieldId: 'reviews_id', + sourceCardinality: 'many', + targetCardinality: 'one', + createdAt: Date.now(), + }, + ], + }; + + const result = generateDBMLFromDiagram(diagram); + + // Debug output removed + // console.log('Inline DBML:', result.inlineDbml); + + // Check standard DBML output + expect(result.standardDbml).toContain('Table "users" {'); + expect(result.standardDbml).toContain('Table "posts" {'); + expect(result.standardDbml).toContain('Table "reviews" {'); + expect(result.standardDbml).toContain('Table "user_activities" {'); + + // Check that the entity_id field in user_activities has multiple relationships in inline DBML + // The field should have both references in a single bracket + expect(result.inlineDbml).toContain( + '"entity_id" int [not null, ref: < "posts"."id", ref: < "reviews"."id"]' + ); + + // Check that standard DBML has separate Ref entries for each relationship + expect(result.standardDbml).toContain( + 'Ref "fk_0_fk_posts_user":"users"."id" < "posts"."user_id"' + ); + expect(result.standardDbml).toContain( + 'Ref "fk_1_fk_reviews_user":"users"."id" < "reviews"."user_id"' + ); + expect(result.standardDbml).toContain( + 'Ref "fk_2_fk_activities_posts":"posts"."id" < "user_activities"."entity_id"' + ); + expect(result.standardDbml).toContain( + 'Ref "fk_3_fk_activities_reviews":"reviews"."id" < "user_activities"."entity_id"' + ); + + // No automatic comment is added for fields with multiple relationships + + // Check proper formatting - closing brace should be on a new line + expect(result.inlineDbml).toMatch( + /Table "user_activities" \{\s*\n\s*"id".*\n\s*"entity_id".*\]\s*\n\s*"activity_type".*\n\s*\}/ + ); + + // Ensure no closing brace appears on the same line as a field with inline refs + expect(result.inlineDbml).not.toMatch(/\[.*ref:.*\]\}/); + }); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index b05c6cea..c2ca7b2c 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -286,9 +286,14 @@ const convertToInlineRefs = (dbml: string): string => { // Create a map for faster table lookup const tableMap = new Map(Object.entries(tables)); - // 1. Add inline refs to table contents + // 1. First, collect all refs per field + const fieldRefs = new Map< + string, + { table: string; refs: string[]; relatedTables: string[] } + >(); + refs.forEach((ref) => { - let targetTableName, fieldNameToModify, inlineRefSyntax; + let targetTableName, fieldNameToModify, inlineRefSyntax, relatedTable; if (ref.direction === '<') { targetTableName = ref.targetSchema @@ -299,6 +304,7 @@ const convertToInlineRefs = (dbml: string): string => { ? `"${ref.sourceSchema}"."${ref.sourceTable}"."${ref.sourceField}"` : `"${ref.sourceTable}"."${ref.sourceField}"`; inlineRefSyntax = `ref: < ${sourceRef}`; + relatedTable = ref.sourceTable; } else { targetTableName = ref.sourceSchema ? `${ref.sourceSchema}.${ref.sourceTable}` @@ -308,13 +314,32 @@ const convertToInlineRefs = (dbml: string): string => { ? `"${ref.targetSchema}"."${ref.targetTable}"."${ref.targetField}"` : `"${ref.targetTable}"."${ref.targetField}"`; inlineRefSyntax = `ref: > ${targetRef}`; + relatedTable = ref.targetTable; } - const tableData = tableMap.get(targetTableName); + const fieldKey = `${targetTableName}.${fieldNameToModify}`; + const existing = fieldRefs.get(fieldKey) || { + table: targetTableName, + refs: [], + relatedTables: [], + }; + existing.refs.push(inlineRefSyntax); + existing.relatedTables.push(relatedTable); + fieldRefs.set(fieldKey, existing); + }); + + // 2. Apply all refs to fields + fieldRefs.forEach((fieldData, fieldKey) => { + // fieldKey might be "schema.table.field" or just "table.field" + const lastDotIndex = fieldKey.lastIndexOf('.'); + const tableName = fieldKey.substring(0, lastDotIndex); + const fieldName = fieldKey.substring(lastDotIndex + 1); + const tableData = tableMap.get(tableName); + if (tableData) { // Updated pattern to capture field definition and all existing attributes in brackets const fieldPattern = new RegExp( - `^([ \t]*"${fieldNameToModify}"[^\\n]*?)(?:\\s*(\\[[^\\]]*\\]))*\\s*(//.*)?$`, + `^([ \t]*"${fieldName}"[^\\n]*?)(?:\\s*(\\[[^\\]]*\\]))*\\s*(//.*)?$`, 'gm' ); let newContent = tableData.content; @@ -322,11 +347,6 @@ const convertToInlineRefs = (dbml: string): string => { newContent = newContent.replace( fieldPattern, (lineMatch, fieldPart, existingBrackets, commentPart) => { - // Avoid adding duplicate refs - if (lineMatch.includes('ref:')) { - return lineMatch; - } - // Collect all attributes from existing brackets const allAttributes: string[] = []; if (existingBrackets) { @@ -344,8 +364,8 @@ const convertToInlineRefs = (dbml: string): string => { } } - // Add the new ref - allAttributes.push(inlineRefSyntax); + // Add all refs for this field + allAttributes.push(...fieldData.refs); // Combine all attributes into a single bracket const combinedAttributes = allAttributes.join(', '); @@ -353,6 +373,7 @@ const convertToInlineRefs = (dbml: string): string => { // Preserve original spacing from fieldPart const leadingSpaces = fieldPart.match(/^(\s*)/)?.[1] || ''; const fieldDefWithoutSpaces = fieldPart.trim(); + return `${leadingSpaces}${fieldDefWithoutSpaces} [${combinedAttributes}]${commentPart || ''}`; } ); @@ -360,7 +381,7 @@ const convertToInlineRefs = (dbml: string): string => { // Update the table content if modified if (newContent !== tableData.content) { tableData.content = newContent; - tableMap.set(targetTableName, tableData); + tableMap.set(tableName, tableData); } } }); @@ -376,9 +397,18 @@ const convertToInlineRefs = (dbml: string): string => { reconstructedDbml += dbml.substring(lastIndex, tableData.start); // Preserve the original table definition format but with updated content const originalTableDef = tableData.fullMatch; + + // Ensure the content ends with proper whitespace before the closing brace + let content = tableData.content; + // Check if content ends with a field that has inline refs + if (content.match(/\[.*ref:.*\]\s*$/)) { + // Ensure there's a newline before the closing brace + content = content.trimEnd() + '\n'; + } + const updatedTableDef = originalTableDef.replace( /{[^}]*}/, - `{${tableData.content}}` + `{${content}}` ); reconstructedDbml += updatedTableDef; lastIndex = tableData.end;