Files
chartdb/src/lib/dbml/dbml-import/__tests__/dbml-schema-handling.test.ts
2025-09-17 14:27:53 +03:00

437 lines
16 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import { generateDBMLFromDiagram } from '../../dbml-export/dbml-export';
import { applyDBMLChanges } from '../../apply-dbml/apply-dbml';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
describe('DBML Schema Handling - Fantasy Realm Database', () => {
describe('MySQL - No Schema Support', () => {
it('should not add public schema for MySQL databases', async () => {
// Fantasy realm DBML with tables that would typically get 'public' schema
const dbmlContent = `
Table "wizards" {
"id" bigint [pk]
"name" varchar(100)
"magic_level" int
"Yes" varchar(10) // Reserved DBML keyword
"No" varchar(10) // Reserved DBML keyword
}
Table "dragons" {
"id" bigint [pk]
"name" varchar(100)
"treasure_count" int
"is_friendly" boolean
}
Table "spells" {
"id" bigint [pk]
"spell_name" varchar(200)
"wizard_id" bigint
"power_level" int
}
Ref: "spells"."wizard_id" > "wizards"."id"
`;
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.MYSQL,
});
// Verify no 'public' schema was added
expect(diagram.tables).toBeDefined();
diagram.tables?.forEach((table) => {
expect(table.schema).toBe('');
console.log(
`✓ Table "${table.name}" has no schema (MySQL behavior)`
);
});
// Check specific tables
const wizardsTable = diagram.tables?.find(
(t) => t.name === 'wizards'
);
expect(wizardsTable).toBeDefined();
expect(wizardsTable?.schema).toBe('');
// Check that reserved keywords are preserved as field names
const yesField = wizardsTable?.fields.find((f) => f.name === 'Yes');
const noField = wizardsTable?.fields.find((f) => f.name === 'No');
expect(yesField).toBeDefined();
expect(noField).toBeDefined();
});
it('should preserve IDs when re-importing DBML (no false changes)', async () => {
// Create initial diagram
const initialDBML = `
Table "kingdoms" {
"id" bigint [pk]
"name" varchar(100)
"ruler" varchar(100)
"Yes" varchar(10) // Acceptance status
"No" varchar(10) // Rejection status
}
Table "knights" {
"id" bigint [pk]
"name" varchar(100)
"kingdom_id" bigint
"honor_points" int
}
Ref: "knights"."kingdom_id" > "kingdoms"."id"
`;
// Import initial DBML
const sourceDiagram = await importDBMLToDiagram(initialDBML, {
databaseType: DatabaseType.MYSQL,
});
// Export to DBML
const exported = generateDBMLFromDiagram(sourceDiagram);
// Re-import the exported DBML (simulating edit mode)
const reimportedDiagram = await importDBMLToDiagram(
exported.inlineDbml,
{
databaseType: DatabaseType.MYSQL,
}
);
// Apply DBML changes (should preserve IDs)
const targetDiagram: Diagram = {
...sourceDiagram,
tables: reimportedDiagram.tables,
relationships: reimportedDiagram.relationships,
customTypes: reimportedDiagram.customTypes,
};
const resultDiagram = applyDBMLChanges({
sourceDiagram,
targetDiagram,
});
// Verify IDs are preserved
expect(resultDiagram.tables?.length).toBe(
sourceDiagram.tables?.length
);
sourceDiagram.tables?.forEach((sourceTable, idx) => {
const resultTable = resultDiagram.tables?.[idx];
expect(resultTable?.id).toBe(sourceTable.id);
expect(resultTable?.name).toBe(sourceTable.name);
// Check field IDs are preserved
sourceTable.fields.forEach((sourceField, fieldIdx) => {
const resultField = resultTable?.fields[fieldIdx];
expect(resultField?.id).toBe(sourceField.id);
expect(resultField?.name).toBe(sourceField.name);
});
});
console.log('✓ All IDs preserved after DBML round-trip');
});
});
describe('PostgreSQL - Schema Support', () => {
it('should handle schemas correctly for PostgreSQL', async () => {
// Fantasy realm with multiple schemas
const dbmlContent = `
Table "public"."heroes" {
"id" bigint [pk]
"name" varchar(100)
"class" varchar(50)
}
Table "private"."secret_quests" {
"id" bigint [pk]
"quest_name" varchar(200)
"hero_id" bigint
}
Table "artifacts" {
"id" bigint [pk]
"name" varchar(100)
"power" int
}
Ref: "private"."secret_quests"."hero_id" > "public"."heroes"."id"
`;
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
// Check schemas are preserved correctly
const heroesTable = diagram.tables?.find(
(t) => t.name === 'heroes'
);
expect(heroesTable?.schema).toBe(''); // 'public' should be converted to empty
const secretQuestsTable = diagram.tables?.find(
(t) => t.name === 'secret_quests'
);
expect(secretQuestsTable?.schema).toBe('private'); // Other schemas preserved
const artifactsTable = diagram.tables?.find(
(t) => t.name === 'artifacts'
);
expect(artifactsTable?.schema).toBe(''); // No schema = empty string
});
it('should handle reserved keywords for PostgreSQL', async () => {
const dbmlContent = `
Table "magic_items" {
"id" bigint [pk]
"name" varchar(100)
"Order" int // SQL keyword
"Yes" varchar(10) // DBML keyword
"No" varchar(10) // DBML keyword
}
`;
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
const exported = generateDBMLFromDiagram(diagram);
expect(exported.standardDbml).toContain('Order');
expect(exported.standardDbml).toContain('Yes');
expect(exported.standardDbml).toContain('No');
});
});
describe('Public Schema Handling - The Core Fix', () => {
it('should strip public schema for MySQL to prevent ID mismatch', async () => {
// This test verifies the core fix - that 'public' schema is converted to empty string
const dbmlWithPublicSchema = `
Table "public"."enchanted_items" {
"id" bigint [pk]
"item_name" varchar(100)
"power" int
}
Table "public"."spell_books" {
"id" bigint [pk]
"title" varchar(200)
"author" varchar(100)
}
`;
const mysqlDiagram = await importDBMLToDiagram(
dbmlWithPublicSchema,
{
databaseType: DatabaseType.MYSQL,
}
);
// For MySQL, 'public' schema should be stripped
mysqlDiagram.tables?.forEach((table) => {
expect(table.schema).toBe('');
console.log(
`✓ MySQL: Table "${table.name}" has no schema (public was stripped)`
);
});
// Now test with PostgreSQL - public should also be stripped (it's the default)
const pgDiagram = await importDBMLToDiagram(dbmlWithPublicSchema, {
databaseType: DatabaseType.POSTGRESQL,
});
pgDiagram.tables?.forEach((table) => {
expect(table.schema).toBe('');
console.log(
`✓ PostgreSQL: Table "${table.name}" has no schema (public is default)`
);
});
});
it('should preserve non-public schemas', async () => {
const dbmlWithCustomSchema = `
Table "fantasy"."magic_users" {
"id" bigint [pk]
"name" varchar(100)
"class" varchar(50)
}
Table "adventure"."quests" {
"id" bigint [pk]
"title" varchar(200)
"reward" int
}
`;
const diagram = await importDBMLToDiagram(dbmlWithCustomSchema, {
databaseType: DatabaseType.POSTGRESQL,
});
// Non-public schemas should be preserved
const magicTable = diagram.tables?.find(
(t) => t.name === 'magic_users'
);
const questTable = diagram.tables?.find((t) => t.name === 'quests');
expect(magicTable?.schema).toBe('fantasy');
expect(questTable?.schema).toBe('adventure');
console.log('✓ Custom schemas preserved correctly');
});
});
describe('Edge Cases - The Dungeon of Bugs', () => {
it('should handle tables with names that need quoting', async () => {
const dbmlContent = `
Table "dragons_lair" {
"id" bigint [pk]
"treasure_amount" decimal
}
Table "wizard_tower" {
"id" bigint [pk]
"floor_count" int
}
Table "quest_log" {
"id" bigint [pk]
"quest_name" varchar(200)
}
`;
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.MYSQL,
});
// Tables should be imported correctly
expect(diagram.tables?.length).toBe(3);
expect(
diagram.tables?.find((t) => t.name === 'dragons_lair')
).toBeDefined();
expect(
diagram.tables?.find((t) => t.name === 'wizard_tower')
).toBeDefined();
expect(
diagram.tables?.find((t) => t.name === 'quest_log')
).toBeDefined();
});
it('should handle the Work_Order_Page_Debug case with Yes/No fields', async () => {
// This is the exact case that was causing the original bug
const dbmlContent = `
Table "Work_Order_Page_Debug" {
"ID" bigint [pk, not null]
"Work_Order_For" varchar(255)
"Quan_to_Make" int
"Text_Gen" text
"Gen_Info" text
"Yes" varchar(255)
"No" varchar(255)
}
`;
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.MYSQL,
});
const table = diagram.tables?.find(
(t) => t.name === 'Work_Order_Page_Debug'
);
expect(table).toBeDefined();
// Check Yes and No fields are preserved
const yesField = table?.fields.find((f) => f.name === 'Yes');
const noField = table?.fields.find((f) => f.name === 'No');
expect(yesField).toBeDefined();
expect(noField).toBeDefined();
expect(yesField?.name).toBe('Yes');
expect(noField?.name).toBe('No');
// Export and verify it doesn't cause errors
const exported = generateDBMLFromDiagram(diagram);
expect(exported.standardDbml).toContain('"Yes"');
expect(exported.standardDbml).toContain('"No"');
// Re-import should work without errors
const reimported = await importDBMLToDiagram(exported.inlineDbml, {
databaseType: DatabaseType.MYSQL,
});
expect(reimported.tables?.length).toBe(1);
});
});
describe('Round-trip Testing - The Eternal Cycle', () => {
it('should maintain data integrity through multiple import/export cycles', async () => {
const originalDBML = `
Table "guild_members" {
"id" bigint [pk]
"name" varchar(100)
"level" int
"Yes" varchar(10) // Active status
"No" varchar(10) // Inactive status
"Order" int // SQL keyword - rank order
}
Table "guild_quests" {
"id" bigint [pk]
"quest_name" varchar(200)
"assigned_to" bigint
"difficulty" int
}
Ref: "guild_quests"."assigned_to" > "guild_members"."id"
`;
let currentDiagram = await importDBMLToDiagram(originalDBML, {
databaseType: DatabaseType.MYSQL,
});
// Store original IDs
const originalTableIds = currentDiagram.tables?.map((t) => ({
name: t.name,
id: t.id,
}));
// Perform 3 round-trips
for (let cycle = 1; cycle <= 3; cycle++) {
console.log(`🔄 Round-trip cycle ${cycle}`);
// Export
const exported = generateDBMLFromDiagram(currentDiagram);
// Re-import
const reimported = await importDBMLToDiagram(
exported.inlineDbml,
{
databaseType: DatabaseType.MYSQL,
}
);
// Apply changes
const targetDiagram: Diagram = {
...currentDiagram,
tables: reimported.tables,
relationships: reimported.relationships,
customTypes: reimported.customTypes,
};
currentDiagram = applyDBMLChanges({
sourceDiagram: currentDiagram,
targetDiagram,
});
// Verify IDs are still the same as original
originalTableIds?.forEach((original) => {
const currentTable = currentDiagram.tables?.find(
(t) => t.name === original.name
);
expect(currentTable?.id).toBe(original.id);
});
}
console.log('✓ Data integrity maintained through 3 cycles');
});
});
});