diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index cd019dbd..e3c102ff 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -338,7 +338,13 @@ export const exportBaseSQL = ({ const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow); - sqlScript += ` ${quotedFieldName} ${typeName}`; + // Quote multi-word type names for DBML flow to prevent @dbml/core parser issues + const quotedTypeName = + isDBMLFlow && typeName.includes(' ') + ? `"${typeName}"` + : typeName; + + sqlScript += ` ${quotedFieldName} ${quotedTypeName}`; // Add size for character types if ( diff --git a/src/lib/dbml/dbml-export/__tests__/cases/3.dbml b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml index d5fe9e9e..d815d7d3 100644 --- a/src/lib/dbml/dbml-export/__tests__/cases/3.dbml +++ b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml @@ -1,6 +1,6 @@ Table "public"."guy_table" { "id" integer [pk, not null] - "created_at" timestamp [not null] + "created_at" "timestamp without time zone" [not null] "column3" text "arrayfield" text[] "field_5" "character varying" diff --git a/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts b/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts new file mode 100644 index 00000000..79b704c1 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { generateDBMLFromDiagram } from '../dbml-export'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { Diagram } from '@/lib/domain/diagram'; +import { generateId, generateDiagramId } from '@/lib/utils'; + +describe('DBML Export - Empty Tables', () => { + it('should filter out tables with no fields', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'empty_table', + schema: 'public', + x: 0, + y: 0, + fields: [], // Empty fields array + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'another_valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'name', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Verify the DBML doesn't contain the empty table + expect(result.inlineDbml).not.toContain('empty_table'); + expect(result.standardDbml).not.toContain('empty_table'); + + // Verify the valid tables are still present + expect(result.inlineDbml).toContain('valid_table'); + expect(result.inlineDbml).toContain('another_valid_table'); + }); + + it('should handle diagram with only empty tables', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'empty_table_1', + schema: 'public', + x: 0, + y: 0, + fields: [], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'empty_table_2', + schema: 'public', + x: 0, + y: 0, + fields: [], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Should not error and should return empty DBML (or just enums if any) + expect(result.inlineDbml).toBeTruthy(); + expect(result.standardDbml).toBeTruthy(); + expect(result.error).toBeUndefined(); + }); + + it('should filter out table that becomes empty after removing invalid fields', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'table_with_only_empty_field_names', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: '', // Empty field name - will be filtered + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: '', // Empty field name - will be filtered + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Table with only empty field names should be filtered out + expect(result.inlineDbml).not.toContain( + 'table_with_only_empty_field_names' + ); + // Valid table should remain + expect(result.inlineDbml).toContain('valid_table'); + }); +}); diff --git a/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts b/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts new file mode 100644 index 00000000..35f5dd3a --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { generateDBMLFromDiagram } from '../dbml-export'; +import { importDBMLToDiagram } from '../../dbml-import/dbml-import'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { Diagram } from '@/lib/domain/diagram'; +import { generateId, generateDiagramId } from '@/lib/utils'; + +describe('DBML Export - Timestamp with Time Zone', () => { + it('should preserve "timestamp with time zone" type through export and reimport', async () => { + // Create a diagram with timestamp with time zone field + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'events', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'created_at', + type: { + id: 'timestamp_with_time_zone', + name: 'timestamp with time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'updated_at', + type: { + id: 'timestamp_without_time_zone', + name: 'timestamp without time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Export to DBML + const exportResult = generateDBMLFromDiagram(diagram); + + // Verify the DBML contains quoted multi-word types + expect(exportResult.inlineDbml).toContain('"timestamp with time zone"'); + expect(exportResult.inlineDbml).toContain( + '"timestamp without time zone"' + ); + + // Reimport the DBML + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + // Verify the types are preserved + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'events' + ); + expect(table).toBeDefined(); + + const createdAtField = table?.fields.find( + (f) => f.name === 'created_at' + ); + const updatedAtField = table?.fields.find( + (f) => f.name === 'updated_at' + ); + + expect(createdAtField?.type.name).toBe('timestamp with time zone'); + expect(updatedAtField?.type.name).toBe('timestamp without time zone'); + }); + + it('should handle time with time zone types', async () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'schedules', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'start_time', + type: { + id: 'time_with_time_zone', + name: 'time with time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'end_time', + type: { + id: 'time_without_time_zone', + name: 'time without time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const exportResult = generateDBMLFromDiagram(diagram); + + expect(exportResult.inlineDbml).toContain('"time with time zone"'); + expect(exportResult.inlineDbml).toContain('"time without time zone"'); + + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'schedules' + ); + const startTimeField = table?.fields.find( + (f) => f.name === 'start_time' + ); + const endTimeField = table?.fields.find((f) => f.name === 'end_time'); + + expect(startTimeField?.type.name).toBe('time with time zone'); + expect(endTimeField?.type.name).toBe('time without time zone'); + }); + + it('should handle double precision type', async () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'measurements', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'value', + type: { + id: 'double_precision', + name: 'double precision', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const exportResult = generateDBMLFromDiagram(diagram); + + expect(exportResult.inlineDbml).toContain('"double precision"'); + + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'measurements' + ); + const valueField = table?.fields.find((f) => f.name === 'value'); + + expect(valueField?.type.name).toBe('double precision'); + }); +}); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 5819a718..231f2011 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -807,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { }; }) ?? []; - // Remove duplicate tables (consider both schema and table name) + // Filter out empty tables and duplicates in a single pass for performance const seenTableIdentifiers = new Set(); - const uniqueTables = sanitizedTables.filter((table) => { + const tablesWithFields = sanitizedTables.filter((table) => { + // Skip tables with no fields (empty tables cause DBML export to fail) + if (table.fields.length === 0) { + return false; + } + // Create a unique identifier combining schema and table name const tableIdentifier = table.schema ? `${table.schema}.${table.name}` : table.name; + // Skip duplicate tables if (seenTableIdentifiers.has(tableIdentifier)) { - return false; // Skip duplicate + return false; } seenTableIdentifiers.add(tableIdentifier); - return true; // Keep unique table + return true; // Keep unique, non-empty table }); // Create the base filtered diagram structure const filteredDiagram: Diagram = { ...diagram, - tables: uniqueTables, + tables: tablesWithFields, relationships: diagram.relationships?.filter((rel) => { - const sourceTable = uniqueTables.find( + const sourceTable = tablesWithFields.find( (t) => t.id === rel.sourceTableId ); - const targetTable = uniqueTables.find( + const targetTable = tablesWithFields.find( (t) => t.id === rel.targetTableId ); const sourceFieldExists = sourceTable?.fields.some( @@ -931,13 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { ); // Restore schema information that may have been stripped by DBML importer - standard = restoreTableSchemas(standard, uniqueTables); + standard = restoreTableSchemas(standard, tablesWithFields); // Restore composite primary key names - standard = restoreCompositePKNames(standard, uniqueTables); + standard = restoreCompositePKNames(standard, tablesWithFields); // Restore increment attribute for auto-incrementing fields - standard = restoreIncrementAttribute(standard, uniqueTables); + standard = restoreIncrementAttribute(standard, tablesWithFields); // Prepend Enum DBML to the standard output if (enumsDBML) {