From 99910779789a9c6ef113d06bc3de31e35b9b04d1 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 18:13:48 +0300 Subject: [PATCH] fix: handle bidirectional relationships in DBML export (#924) --- .../dbml-export/__tests__/cases/5.inline.dbml | 129 ++++++++++++++++++ .../dbml/dbml-export/__tests__/cases/5.json | 1 + .../__tests__/export-sql-dbml-cases.test.ts | 38 +++++- src/lib/dbml/dbml-export/dbml-export.ts | 17 ++- 4 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/5.json diff --git a/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml b/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml new file mode 100644 index 00000000..cd3c75cb --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml @@ -0,0 +1,129 @@ +Enum "cbhpm_entradas_tipo" { + "grupo" + "subgrupo" + "procedimento" +} + +Enum "cid_entradas_tipo" { + "capitulo" + "agrupamento" + "categoria" + "subcategoria" +} + +Enum "digital_signature_provider" { + "soluti" + "valid" +} + +Enum "impresso_posicao" { + "start" + "center" + "end" +} + +Enum "otp_provider" { + "clinic" + "soluti_bird_id" +} + +Enum "tipo_cobranca" { + "valor" + "porte" +} + +Enum "tipo_contato_movel" { + "celular" + "telefone_residencial" + "telefone_comercial" +} + +Enum "tipo_contrato" { + "trial" + "common" +} + +Enum "tipo_endereco" { + "residencial" + "comercial" + "cobranca" +} + +Enum "tipo_espectro_autista" { + "leve" + "moderado" + "severo" +} + +Enum "tipo_estado_civil" { + "nao_infomado" + "solteiro" + "casado" + "divorciado" + "viuvo" +} + +Enum "tipo_etnia" { + "nao_infomado" + "branca" + "preta" + "parda" + "amarela" + "indigena" +} + +Enum "tipo_excecao" { + "bloqueio" + "compromisso" +} + +Enum "tipo_metodo_reajuste" { + "percentual" + "valor" +} + +Enum "tipo_pessoa" { + "fisica" + "juridica" +} + +Enum "tipo_procedimento" { + "consulta" + "exame_laboratorial" + "exame_imagem" + "procedimento_clinico" + "procedimento_cirurgico" + "terapia" + "outros" +} + +Enum "tipo_relacionamento" { + "pai" + "mae" + "conjuge" + "filho_a" + "tutor_legal" + "contato_emergencia" + "outro" +} + +Enum "tipo_sexo" { + "nao_infomado" + "masculino" + "feminino" + "intersexo" +} + +Enum "tipo_status_agendamento" { + "em espera" + "faltou" + "ok" +} + +Table "public"."organizacao_cfg_impressos" { + "id_organizacao" integer [pk, not null, ref: < "public"."organizacao"."id"] +} + +Table "public"."organizacao" { + "id" integer [pk, not null] +} diff --git a/src/lib/dbml/dbml-export/__tests__/cases/5.json b/src/lib/dbml/dbml-export/__tests__/cases/5.json new file mode 100644 index 00000000..89bf8c11 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/5.json @@ -0,0 +1 @@ +{"id":"891455fe2029","name":"fish after update (Copy)","createdAt":"2025-09-17T13:41:55.984Z","updatedAt":"2025-09-17T14:43:51.846Z","databaseType":"postgresql","tables":[{"id":"mow8twxpq4yx9ti2ke1rm3oz9","name":"organizacao_cfg_impressos","schema":"public","order":3,"fields":[{"id":"ub6gos45jgjus7a0ra9xditof","name":"id_organizacao","type":{"name":"integer","id":"integer","usageLevel":1},"nullable":false,"primaryKey":true,"unique":false,"createdAt":1757509919495}],"indexes":[{"id":"5fb2k4hawkhw3pyqvruoi3g6v","name":"pk_organizacao_cfg_impressos_id_organizacao","fieldIds":["ub6gos45jgjus7a0ra9xditof"],"unique":true,"isPrimaryKey":true,"createdAt":1757509919496}],"x":-345,"y":-186,"color":"#ffe374","isView":false,"createdAt":1757022310768,"diagramId":"891455fe2029","parentAreaId":null,"expanded":true},{"id":"zc47ktc9divcdt66ms7vrspod","name":"organizacao","schema":"public","order":1,"fields":[{"id":"uby47oadej9dm37i94je7fqqu","name":"id","type":{"name":"integer","id":"integer","usageLevel":1},"nullable":false,"primaryKey":true,"unique":false,"createdAt":1757022310768}],"indexes":[{"id":"z2a9g6q13mt1y5tk0rskwum7u","name":"pk_organizacao_id","fieldIds":["uby47oadej9dm37i94je7fqqu"],"unique":true,"isPrimaryKey":true,"createdAt":1757114454215},{"id":"ykxz0xuv6w39h5v86rcsd4ba6","name":"idx_tenancy_owner_id","fieldIds":["vpqwjanwu075sgny85ahih51b"],"unique":false,"createdAt":1757023762316}],"x":845,"y":-351,"color":"#ffe374","isView":false,"createdAt":1757022310768,"diagramId":"891455fe2029","parentAreaId":null,"expanded":true}],"relationships":[{"id":"ujdpzbivadyqy1zarb8xaadeo","name":"fk_organizacao_cfg_impressos_organizacao","sourceSchema":"public","targetSchema":"public","sourceTableId":"mow8twxpq4yx9ti2ke1rm3oz9","targetTableId":"zc47ktc9divcdt66ms7vrspod","sourceFieldId":"ub6gos45jgjus7a0ra9xditof","targetFieldId":"uby47oadej9dm37i94je7fqqu","sourceCardinality":"many","targetCardinality":"one","createdAt":1758059720108,"diagramId":"891455fe2029"},{"id":"k40xbk9m8o9tu2ga5y6el9xlv","name":"fk_organizacao_id_organizacao_cfg_impressos_id_organizacao","sourceSchema":"public","targetSchema":"public","sourceTableId":"zc47ktc9divcdt66ms7vrspod","targetTableId":"mow8twxpq4yx9ti2ke1rm3oz9","sourceFieldId":"uby47oadej9dm37i94je7fqqu","targetFieldId":"ub6gos45jgjus7a0ra9xditof","sourceCardinality":"many","targetCardinality":"one","createdAt":1758059720108,"diagramId":"891455fe2029"}],"dependencies":[],"storageMode":"project","lastProjectSavedAt":"2025-09-17T14:43:51.846Z","areas":[],"creationMethod":"imported","customTypes":[{"id":"ppxncapgjqymrcs6sdisxhdom","schema":"","name":"cbhpm_entradas_tipo","kind":"enum","values":["grupo","subgrupo","procedimento"],"order":0,"diagramId":"891455fe2029"},{"id":"l720lbz86bg906xs0d0rjwnwq","schema":"","name":"cid_entradas_tipo","kind":"enum","values":["capitulo","agrupamento","categoria","subcategoria"],"order":0,"diagramId":"891455fe2029"},{"id":"fcyvfh5ilzi1pxg1mazgtr0hb","schema":"","name":"digital_signature_provider","kind":"enum","values":["soluti","valid"],"order":0,"diagramId":"891455fe2029"},{"id":"x0mcajzpi8k1lg5th3o2o7qqh","schema":"","name":"impresso_posicao","kind":"enum","values":["start","center","end"],"order":0,"diagramId":"891455fe2029"},{"id":"hymo5s2uqjl1nkq3kui5ueidc","schema":"","name":"otp_provider","kind":"enum","values":["clinic","soluti_bird_id"],"order":0,"diagramId":"891455fe2029"},{"id":"r02sp4k2vu98x58n648u1hmtu","schema":"","name":"tipo_cobranca","kind":"enum","values":["valor","porte"],"order":0,"diagramId":"891455fe2029"},{"id":"pgyjouza40hqaurziiehcsnde","schema":"","name":"tipo_contato_movel","kind":"enum","values":["celular","telefone_residencial","telefone_comercial"],"order":0,"diagramId":"891455fe2029"},{"id":"ur3khr7o25tzffhzc1s32e7ox","schema":"","name":"tipo_contrato","kind":"enum","values":["trial","common"],"order":0,"diagramId":"891455fe2029"},{"id":"u5jxtrlu22j846e6b4qw9ilrp","schema":"","name":"tipo_endereco","kind":"enum","values":["residencial","comercial","cobranca"],"order":0,"diagramId":"891455fe2029"},{"id":"tat6vdey698u7sop929llxpuv","schema":"","name":"tipo_espectro_autista","kind":"enum","values":["leve","moderado","severo"],"order":0,"diagramId":"891455fe2029"},{"id":"kq7atvktkadu3pha5ur64wf0o","schema":"","name":"tipo_estado_civil","kind":"enum","values":["nao_infomado","solteiro","casado","divorciado","viuvo"],"order":0,"diagramId":"891455fe2029"},{"id":"c9f8bvh6oekcun7aqhmzwg3cx","schema":"","name":"tipo_etnia","kind":"enum","values":["nao_infomado","branca","preta","parda","amarela","indigena"],"order":0,"diagramId":"891455fe2029"},{"id":"u80frjtpqdz31dykzv93mipme","schema":"","name":"tipo_excecao","kind":"enum","values":["bloqueio","compromisso"],"order":0,"diagramId":"891455fe2029"},{"id":"fg46jmrydqjlkm7x968gndewe","schema":"","name":"tipo_metodo_reajuste","kind":"enum","values":["percentual","valor"],"order":0,"diagramId":"891455fe2029"},{"id":"s2q0t3fs7xuo806a4nig9kkue","schema":"","name":"tipo_pessoa","kind":"enum","values":["fisica","juridica"],"order":0,"diagramId":"891455fe2029"},{"id":"561lotk0s99gnddyqp3hj26la","schema":"","name":"tipo_procedimento","kind":"enum","values":["consulta","exame_laboratorial","exame_imagem","procedimento_clinico","procedimento_cirurgico","terapia","outros"],"order":0,"diagramId":"891455fe2029"},{"id":"itq8wzzlej4744z26crl8h0fr","schema":"","name":"tipo_relacionamento","kind":"enum","values":["pai","mae","conjuge","filho_a","tutor_legal","contato_emergencia","outro"],"order":0,"diagramId":"891455fe2029"},{"id":"r80q80je96w85udol4sccb0q0","schema":"","name":"tipo_sexo","kind":"enum","values":["nao_infomado","masculino","feminino","intersexo"],"order":0,"diagramId":"891455fe2029"},{"id":"jveox9qoccr17z6q8pjcyryrl","schema":"","name":"tipo_status_agendamento","kind":"enum","values":["em espera","faltou","ok"],"order":0,"diagramId":"891455fe2029"}]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index 69bf2bec..f1fd54ca 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -14,14 +14,36 @@ const testCase = (caseNumber: string) => { // Generate DBML from the diagram const result = generateDBMLFromDiagram(diagram); - const generatedDBML = result.standardDbml; - // Read the expected DBML file - const dbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); - const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); + // Check for both regular and inline DBML files + const regularDbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); + const inlineDbmlPath = path.join( + __dirname, + 'cases', + `${caseNumber}.inline.dbml` + ); - // Compare the generated DBML with the expected DBML - expect(generatedDBML).toBe(expectedDBML); + const hasRegularDbml = fs.existsSync(regularDbmlPath); + const hasInlineDbml = fs.existsSync(inlineDbmlPath); + + // Test regular DBML if file exists + if (hasRegularDbml) { + const expectedRegularDBML = fs.readFileSync(regularDbmlPath, 'utf-8'); + expect(result.standardDbml).toBe(expectedRegularDBML); + } + + // Test inline DBML if file exists + if (hasInlineDbml) { + const expectedInlineDBML = fs.readFileSync(inlineDbmlPath, 'utf-8'); + expect(result.inlineDbml).toBe(expectedInlineDBML); + } + + // Ensure at least one DBML file exists + if (!hasRegularDbml && !hasInlineDbml) { + throw new Error( + `No DBML file found for test case ${caseNumber}. Expected either ${caseNumber}.dbml or ${caseNumber}.inline.dbml` + ); + } }; describe('DBML Export cases', () => { @@ -40,4 +62,8 @@ describe('DBML Export cases', () => { it('should handle case 4 diagram', { timeout: 30000 }, async () => { testCase('4'); }); + + it('should handle case 5 diagram', { timeout: 30000 }, async () => { + testCase('5'); + }); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 4ee8d014..e2d1e1bf 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -506,15 +506,30 @@ const deduplicateRelationships = (diagram: Diagram): Diagram => { if (!diagram.relationships) return diagram; const seenRelationships = new Set(); + const seenBidirectional = new Set(); const uniqueRelationships = diagram.relationships.filter((rel) => { // Create a unique key based on the relationship endpoints const relationshipKey = `${rel.sourceTableId}-${rel.sourceFieldId}->${rel.targetTableId}-${rel.targetFieldId}`; + // Create a normalized key that's the same for both directions + const normalizedKey = [ + `${rel.sourceTableId}-${rel.sourceFieldId}`, + `${rel.targetTableId}-${rel.targetFieldId}`, + ] + .sort() + .join('<->'); + if (seenRelationships.has(relationshipKey)) { - return false; // Skip duplicate + return false; // Skip exact duplicate + } + + if (seenBidirectional.has(normalizedKey)) { + // This is a bidirectional relationship, skip the second one + return false; } seenRelationships.add(relationshipKey); + seenBidirectional.add(normalizedKey); return true; // Keep unique relationship });