diff --git a/apps/desktop/src/components/diagram/CreateDraftTableDialog.vue b/apps/desktop/src/components/diagram/CreateDraftTableDialog.vue new file mode 100644 index 000000000..3e4452ef2 --- /dev/null +++ b/apps/desktop/src/components/diagram/CreateDraftTableDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/apps/desktop/src/components/diagram/DiagramInspector.vue b/apps/desktop/src/components/diagram/DiagramInspector.vue new file mode 100644 index 000000000..dc84b1a0b --- /dev/null +++ b/apps/desktop/src/components/diagram/DiagramInspector.vue @@ -0,0 +1,721 @@ + + + diff --git a/apps/desktop/src/components/diagram/DiagramSyncDialog.vue b/apps/desktop/src/components/diagram/DiagramSyncDialog.vue new file mode 100644 index 000000000..e4848d98c --- /dev/null +++ b/apps/desktop/src/components/diagram/DiagramSyncDialog.vue @@ -0,0 +1,199 @@ + + + diff --git a/apps/desktop/src/components/diagram/DiagramToolbar.vue b/apps/desktop/src/components/diagram/DiagramToolbar.vue new file mode 100644 index 000000000..8ac473bd2 --- /dev/null +++ b/apps/desktop/src/components/diagram/DiagramToolbar.vue @@ -0,0 +1,230 @@ + + + diff --git a/apps/desktop/src/components/diagram/LayerNode.vue b/apps/desktop/src/components/diagram/LayerNode.vue new file mode 100644 index 000000000..a9764b3a7 --- /dev/null +++ b/apps/desktop/src/components/diagram/LayerNode.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/desktop/src/components/diagram/LayerPanel.vue b/apps/desktop/src/components/diagram/LayerPanel.vue new file mode 100644 index 000000000..4d75c297e --- /dev/null +++ b/apps/desktop/src/components/diagram/LayerPanel.vue @@ -0,0 +1,450 @@ + + + diff --git a/apps/desktop/src/components/diagram/MatchPanel.vue b/apps/desktop/src/components/diagram/MatchPanel.vue new file mode 100644 index 000000000..f6feb9b08 --- /dev/null +++ b/apps/desktop/src/components/diagram/MatchPanel.vue @@ -0,0 +1,163 @@ + + + diff --git a/apps/desktop/src/components/diagram/RelationshipEdge.vue b/apps/desktop/src/components/diagram/RelationshipEdge.vue new file mode 100644 index 000000000..c2765f171 --- /dev/null +++ b/apps/desktop/src/components/diagram/RelationshipEdge.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/apps/desktop/src/components/diagram/ResizerHandle.vue b/apps/desktop/src/components/diagram/ResizerHandle.vue new file mode 100644 index 000000000..3d6c17901 --- /dev/null +++ b/apps/desktop/src/components/diagram/ResizerHandle.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue index 5af29d711..d6e8ba402 100644 --- a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue +++ b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue @@ -1,30 +1,271 @@ + + +
+ {{ attribute.label }} +
+
+
+ {{ relationship.label }} +
+
+ {{ entity.name }} +
+ +
+ + +
+ + + + diff --git a/apps/desktop/src/components/diagram/TableNode.vue b/apps/desktop/src/components/diagram/TableNode.vue new file mode 100644 index 000000000..a29a6e322 --- /dev/null +++ b/apps/desktop/src/components/diagram/TableNode.vue @@ -0,0 +1,94 @@ + + + diff --git a/apps/desktop/src/components/diagram/ZoomControls.vue b/apps/desktop/src/components/diagram/ZoomControls.vue new file mode 100644 index 000000000..0a2fe4399 --- /dev/null +++ b/apps/desktop/src/components/diagram/ZoomControls.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts b/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts new file mode 100644 index 000000000..0817517c5 --- /dev/null +++ b/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DiagramTable } from "@/lib/diagram/erDiagram"; + +const mocks = vi.hoisted(() => ({ + buildDropTableSql: vi.fn(async (options: { schema?: string; tableName: string; cascade?: boolean }) => { + const qualifiedName = options.schema ? `"${options.schema}"."${options.tableName}"` : `"${options.tableName}"`; + return `DROP TABLE ${qualifiedName}${options.cascade ? " CASCADE" : ""};`; + }), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + return { + Dialog: passthrough, + DialogContent: passthrough, + DialogFooter: passthrough, + DialogHeader: passthrough, + DialogTitle: passthrough, + }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h("button", attrs, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/lib/backend/api", () => ({ + buildCreateTableSql: vi.fn(), + buildTableStructureChangeSql: vi.fn(), + executeBatch: vi.fn(), +})); + +vi.mock("@/lib/database/dbAdminSql", () => ({ + buildDropTableSql: mocks.buildDropTableSql, + supportsDropTableCascade: vi.fn(() => true), +})); + +import DiagramSyncDialog from "../DiagramSyncDialog.vue"; + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + mocks.buildDropTableSql.mockClear(); +}); + +async function flushAsyncUpdates() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +describe("DiagramSyncDialog", () => { + it("previews live table deletion without silently enabling CASCADE", async () => { + const liveTable: DiagramTable = { + name: "orders", + columns: [], + foreignKeys: [], + origin: "live", + pendingDrop: true, + }; + const open = ref(false); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(DiagramSyncDialog, { + open: open.value, + tables: [liveTable], + connectionId: "connection-1", + database: "app", + schema: "public", + databaseType: "postgres", + "onUpdate:open": (value: boolean) => (open.value = value), + }); + }, + }), + ); + app.mount(host); + mountedApps.push({ app, host }); + + open.value = true; + await flushAsyncUpdates(); + + expect(mocks.buildDropTableSql).toHaveBeenCalledWith({ + databaseType: "postgres", + schema: "public", + tableName: "orders", + cascade: false, + }); + expect(host.textContent).toContain('DROP TABLE "public"."orders";'); + expect(host.textContent).not.toContain("CASCADE"); + }); +}); diff --git a/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts b/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts new file mode 100644 index 000000000..6b717d209 --- /dev/null +++ b/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ZoomControls from "@/components/diagram/ZoomControls.vue"; + +const vueFlowMock = vi.hoisted(() => ({ + zoomIn: vi.fn(), + zoomOut: vi.fn(), + fitView: vi.fn(async () => {}), +})); + +vi.mock("@vue-flow/core", () => ({ + useVueFlow: () => vueFlowMock, +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +const mountedApps: App[] = []; + +function mountZoom(props: { canUndo?: boolean; canRedo?: boolean } = {}) { + const emits: string[] = []; + const container = document.createElement("div"); + document.body.append(container); + const app = createApp(ZoomControls, { + canUndo: props.canUndo ?? true, + canRedo: props.canRedo ?? true, + onUndo: () => emits.push("undo"), + onRedo: () => emits.push("redo"), + }); + mountedApps.push(app); + app.mount(container); + return { container, emits }; +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + vueFlowMock.zoomIn.mockClear(); + vueFlowMock.zoomOut.mockClear(); + vueFlowMock.fitView.mockClear(); +}); + +describe("ZoomControls", () => { + it("emits undo/redo and calls vue-flow zoom helpers", async () => { + const { container, emits } = mountZoom(); + await nextTick(); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBeGreaterThanOrEqual(6); + + buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + buttons[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); + buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); + buttons[3].dispatchEvent(new MouseEvent("click", { bubbles: true })); + buttons[4].dispatchEvent(new MouseEvent("click", { bubbles: true })); + buttons[5].dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(emits).toEqual(["undo", "redo"]); + expect(vueFlowMock.zoomIn).toHaveBeenCalled(); + expect(vueFlowMock.zoomOut).toHaveBeenCalled(); + expect(vueFlowMock.fitView).toHaveBeenCalledTimes(2); + }); + + it("disables undo/redo when props are false", async () => { + const { container } = mountZoom({ canUndo: false, canRedo: false }); + await nextTick(); + const buttons = container.querySelectorAll("button"); + expect((buttons[0] as HTMLButtonElement).disabled).toBe(true); + expect((buttons[1] as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 69731b744..36794615f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2878,6 +2878,7 @@ export default { selectSchema: "Select schema", searchTables: "Search tables, columns, keys...", refresh: "Refresh diagram", + refreshConfirm: "Refreshing reloads table metadata and resets the canvas layout and undo history. Saved modeled relationships are kept. Continue?", loading: "Reading relationships...", loadingProgress: "Reading relationships... {loaded}/{total}", partialError: "Skipped metadata for {count} tables that failed to load", @@ -2889,6 +2890,10 @@ export default { tableMode: "Table View", engineeringMode: "Engineering ER", modelRelationships: "Model Relationships", + layers: "Layers", + autoMatch: "Auto Match", + autoLayout: "Auto Layout", + matchRelationshipsCount: "{count} matches", copyJoinSql: "Copy SQL", customRelationshipsCount: "{count} modeled", relationshipName: "Name", @@ -2898,10 +2903,15 @@ export default { targetTable: "Target table", targetColumn: "Target column", cardinality: "Cardinality", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "Source 1 · Target N", + cardinalityManyToOne: "Source N · Target 1", + cardinalityOneToOne: "Source 1 · Target 1", + cardinalityManyToMany: "Source N · Target N", + cardinalityDirected: "Source {source} · Target {target}", + matchPickCardinality: "Confirm a match after choosing cardinality (1:1 / 1:N / N:1 / N:N).", + manualAddRelationship: "Manual add", + confirmMatch: "Confirm", + ignoreMatch: "Ignore", addRelationship: "Add relationship", removeRelationship: "Remove relationship", relationshipIncomplete: "Select both source and target columns", @@ -2913,11 +2923,125 @@ export default { relatedTables: "Related Tables", moreColumns: "+ {count} columns", exportSvg: "Export SVG", + exportPng: "Export PNG", + exportJson: "Export JSON", + exportDbml: "Export DBML", + exportMermaid: "Export Mermaid", + export: "Export", exportedSvg: "SVG exported", + exportedFormat: "{format} exported", exportSvgFailed: "Failed to export SVG: {message}", + exportFailed: "Failed to export {format}: {message}", zoomIn: "Zoom in", zoomOut: "Zoom out", + fitView: "Fit view", + resetZoom: "Reset zoom", + undo: "Undo", + redo: "Redo", resetLayout: "Reset layout", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", + addLayer: "Add Layer", + noLayersYet: "No layers yet. Click + to create one.", + layoutFree: "Free", + layoutAuto: "Auto", + layerLock: "Lock layer (skip auto-layout inside)", + layerUnlock: "Unlock layer (allow auto-layout inside)", + layerLocked: "Locked", + layerUnlocked: "Unlocked", + hideLayer: "Hide layer", + showLayer: "Show layer", + deleteLayer: "Delete layer", + layerHiddenCannotFocus: "Layer is hidden — show it to focus on the canvas", + createTable: "New table", + createTableNotSupported: "Creating tables is not supported for this database", + create: "Create", + syncToDatabase: "Sync to database", + structureSyncNotSupported: "Table structure sync is not supported for this database", + syncDraftCount: "{count} change(s) to sync (create / alter / drop)", + executeSync: "Execute", + syncing: "Syncing…", + buildingSql: "Building SQL…", + noSqlYet: "No SQL yet", + copySql: "Copy SQL", + tableName: "Table name", + tableNameRequired: "Table name is required", + tableNameExists: "A table with this name already exists", + assignLayer: "Layer", + noLayer: "No layer", + withDefaultIdPk: "Include default id primary key", + inspectorTable: "Table", + inspectorRelationship: "Relationship", + fieldComment: "Comment", + fields: "Fields", + tabFields: "Fields", + tabIndexes: "Indexes", + addIndex: "Add index", + indexName: "Index name", + indexUnique: "UNIQUE", + indexColumns: "Columns", + noIndexesYet: "No indexes yet", + indexesNotSupported: "Creating indexes is not supported for this database from the diagram editor", + addColumnNotSupported: "Adding columns is not supported for this database", + dropColumnNotSupported: "Dropping columns is not supported for this database", + dropTableNotSupported: "Dropping tables is not supported for this database from the diagram editor", + addField: "Add field", + deleteField: "Delete field", + undoDropField: "Undo drop field", + noFieldsYet: "No fields yet — add one to sync", + deleteDraftTable: "Delete draft table", + deleteLiveTable: "Delete table", + deleteLiveTableHint: "Marks the table for DROP TABLE on the next sync. Undo via history if needed.", + deleteLiveTableConfirm: "Mark this table for DROP TABLE when you sync to the database?", + liveTableReadOnly: "Live table — edit structure in the table editor", + liveTableAddColumnsHint: "Add columns (ALTER ADD) or mark existing ones for drop (ALTER DROP), then sync. Editing existing column types remains in the table structure editor.", + liveTableIndexesReadOnly: "Edit indexes for live tables in the table structure editor", + pendingColumnsBadge: "Pending changes", + pendingDropColumnBadge: "Drop", + pendingDropTableBadge: "Drop table", + source: "Source", + target: "Target", + emptyDesignHint: "No tables yet. Create a layer and tables, edit fields in the inspector, then sync to the database.", + removeFromLayer: "Remove from layer", + addTables: "Add table(s)", + tabSelectExisting: "Select existing", + tabCreateTable: "New table", + multiSelect: "Multi select", + closeMultiSelect: "Close", + selectAll: "Select all", + clearSelection: "Clear", + tablesInOtherLayers: "Tables in other layers", + filterTables: "Filter tables...", + noMatchingTables: "No matching tables", + addSelectedCount: "Add {count} selected", + layerNameTooLong: "Layer name must be less than 50 characters", + layerNameInvalid: "Layer name may include letters, numbers, Chinese, spaces, underscores, hyphens, and periods", + layerNameExists: "Layer name already exists", + total: "total", + confirmed: "confirmed", + pending: "pending", + conflicts: "conflicts", + conflict: "Conflict", + ignored: "ignored", + confirmAll: "Confirm all", + ignoreAll: "Ignore all", + clearAll: "Clear all", + confidenceHigh: "High", + confidenceMedium: "Medium", + confidenceLow: "Low", + noInferred: "No inferred relationships", + editRelationship: "Edit", + saveRelationship: "Save", + deleteRelationship: "Delete", + confirmDeleteRelationship: "Delete this relationship?", + confirmDeleteRelationshipAction: "Delete", + confirmRelationship: "Confirm", + ignoreRelationship: "Ignore", + relationshipKindFk: "Foreign key", + relationshipKindCustom: "Custom", + relationshipKindInferred: "Inferred", + relationshipReadOnlyFk: "This relationship comes from a database foreign key and cannot be edited here.", + relationshipUpdated: "Relationship updated", }, etcd: { prefixPlaceholder: "Search Key path, e.g. /app/ or service", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8c1531c86..fc8b6986f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2758,10 +2758,10 @@ export default withEnglishFallback({ targetTable: "Tabla de destino", targetColumn: "Columna de destino", cardinality: "Cardinalidad", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "Origen 1 · Destino N", + cardinalityManyToOne: "Origen N · Destino 1", + cardinalityManyToMany: "Origen N · Destino N", + cardinalityOneToOne: "Origen 1 · Destino 1", addRelationship: "Agregar relación", removeRelationship: "Eliminar relación", relationshipIncomplete: "Selecciona ambas columnas de origen y destino", @@ -2769,6 +2769,130 @@ export default withEnglishFallback({ relationshipExists: "Esta relación ya existe", relationshipAdded: "Relación agregada", noJoinSql: "No hay SQL de relación para copiar", + refreshConfirm: "Al actualizar se recargará la estructura de las tablas y se restablecerá el diseño del lienzo y el historial de deshacer. Las relaciones de modelado guardadas localmente no se verán afectadas. ¿Desea continuar?", + layers: "Capas", + autoMatch: "Coincidencia automática", + autoLayout: "Diseño automático", + matchRelationshipsCount: "{count} coincidencias", + cardinalityDirected: "Origen {source} · Destino {target}", + matchPickCardinality: "Antes de confirmar la coincidencia, seleccione la cardinalidad (1:1 / 1:N / N:1 / N:N).", + manualAddRelationship: "Añadir manualmente", + confirmMatch: "Confirmar", + ignoreMatch: "Ignorar", + exportPng: "Exportar PNG", + exportJson: "Exportar JSON", + exportDbml: "Exportar DBML", + exportMermaid: "Exportar Mermaid", + export: "Exportar", + exportedFormat: "{format} exportado", + exportFailed: "Error al exportar {format}: {message}", + fitView: "Ajustar al lienzo", + resetZoom: "Restablecer zoom", + undo: "Deshacer", + redo: "Rehacer", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", + addLayer: "Añadir capa", + noLayersYet: "Aún no hay capas, haga clic en + para crear", + layoutFree: "Libre", + layoutAuto: "Automático", + layerLock: "Bloquear capa (sin diseño automático dentro de la capa)", + layerUnlock: "Desbloquear capa (permitir diseño automático dentro de la capa)", + layerLocked: "Bloqueada", + layerUnlocked: "Desbloqueada", + hideLayer: "Ocultar capa", + showLayer: "Mostrar capa", + deleteLayer: "Eliminar capa", + layerHiddenCannotFocus: "La capa está oculta. Muéstrela primero para ubicarla en el lienzo.", + createTable: "Crear tabla", + create: "Crear", + syncToDatabase: "Sincronizar con la base de datos", + syncDraftCount: "Se sincronizarán {count} cambios (crear/modificar/eliminar tablas)", + executeSync: "Ejecutar", + syncing: "Sincronizando…", + buildingSql: "Generando SQL…", + noSqlYet: "Aún no hay SQL", + copySql: "Copiar SQL", + tableName: "Nombre de la tabla", + tableNameRequired: "Por favor, ingrese el nombre de la tabla", + tableNameExists: "Ya existe una tabla con el mismo nombre", + assignLayer: "Capa asignada", + noLayer: "Sin capa", + withDefaultIdPk: "Incluir clave primaria id predeterminada", + inspectorTable: "Tabla", + inspectorRelationship: "Relación", + fieldComment: "Comentario", + fields: "Campos", + tabFields: "Campos", + tabIndexes: "Índices", + addIndex: "Agregar índice", + indexName: "Nombre del índice", + indexUnique: "UNIQUE", + indexColumns: "Columnas", + noIndexesYet: "Aún no hay índices", + indexesNotSupported: "La base de datos actual no admite la creación de índices en el editor de diagramas", + addField: "Agregar campo", + deleteField: "Eliminar campo", + undoDropField: "Deshacer eliminación de campo", + noFieldsYet: "Aún no hay campos. Agregue uno antes de sincronizar", + deleteDraftTable: "Eliminar tabla de borrador", + deleteLiveTable: "Eliminar tabla", + deleteLiveTableHint: "Marcar como pendiente de eliminación. La próxima vez que se sincronice con la base de datos se ejecutará DROP TABLE. Puede deshacer para restaurar.", + deleteLiveTableConfirm: "¿Confirmar que desea marcar esta tabla para eliminación? Al sincronizar se ejecutará DROP TABLE.", + liveTableReadOnly: "Tabla en la base de datos — Modifíquela en el editor de estructura de tabla", + liveTableAddColumnsHint: "Puede agregar campos (ALTER ADD) o marcar campos existentes para eliminar (ALTER DROP) y luego sincronizar. Para cambios como tipos, use el editor de estructura de tabla.", + liveTableIndexesReadOnly: "Los índices de tablas en la base de datos deben modificarse en el editor de estructura de tabla", + pendingColumnsBadge: "Cambios pendientes", + pendingDropColumnBadge: "Pendiente de eliminar", + pendingDropTableBadge: "Tabla pendiente de eliminar", + source: "Origen", + target: "Destino", + emptyDesignHint: "Aún no hay tablas. Puede crear capas y tablas, editar campos en el panel de propiedades y sincronizar con la base de datos al finalizar.", + removeFromLayer: "Quitar de la capa", + addTables: "Agregar tablas", + tabSelectExisting: "Seleccionar existente", + tabCreateTable: "Crear tabla", + multiSelect: "Selección múltiple", + closeMultiSelect: "Cerrar", + selectAll: "Seleccionar todo", + clearSelection: "Limpiar", + tablesInOtherLayers: "Tablas en otras capas", + filterTables: "Filtrar tablas...", + noMatchingTables: "No hay tablas coincidentes", + addSelectedCount: "Agregar {count} elementos seleccionados", + layerNameTooLong: "El nombre de la capa no puede superar los 50 caracteres", + layerNameInvalid: "El nombre de la capa puede contener caracteres chinos, letras, números, espacios, guiones bajos, guiones y puntos", + layerNameExists: "El nombre de la capa ya existe", + total: "Total", + confirmed: "Confirmado", + pending: "Pendiente", + conflicts: "Conflictos", + conflict: "Conflicto", + ignored: "Ignorado", + confirmAll: "Confirmar todo", + ignoreAll: "Ignorar todo", + clearAll: "Limpiar todo", + confidenceHigh: "Alta", + confidenceMedium: "Media", + confidenceLow: "Baja", + noInferred: "No hay relaciones inferidas", + editRelationship: "Editar", + saveRelationship: "Guardar", + deleteRelationship: "Eliminar", + confirmDeleteRelationship: "¿Confirmar eliminación de esta relación?", + confirmDeleteRelationshipAction: "Confirmar eliminación", + confirmRelationship: "Confirmar", + ignoreRelationship: "Ignorar", + relationshipKindFk: "Clave externa", + relationshipKindCustom: "Personalizada", + relationshipKindInferred: "Inferida", + relationshipReadOnlyFk: "Esta relación proviene de una restricción de clave externa de la base de datos y no se puede modificar aquí.", + relationshipUpdated: "Relación actualizada", + createTableNotSupported: "La base de datos actual no admite la creación de tablas", + structureSyncNotSupported: "La base de datos actual no admite sincronizar la estructura de tablas desde el diagrama", + addColumnNotSupported: "La base de datos actual no admite agregar columnas", + dropColumnNotSupported: "La base de datos actual no admite eliminar columnas", + dropTableNotSupported: "La base de datos actual no admite eliminar tablas desde el diagrama", }, etcd: { prefixPlaceholder: "Buscar ruta de Key, p. ej. /app/ o servicio", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index aad9b4cee..73ee77146 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2747,10 +2747,10 @@ export default withEnglishFallback({ targetTable: "Tabella destinazione", targetColumn: "Colonna destinazione", cardinality: "Cardinalita", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "Origine 1 · Destinazione N", + cardinalityManyToOne: "Origine N · Destinazione 1", + cardinalityManyToMany: "Origine N · Destinazione N", + cardinalityOneToOne: "Origine 1 · Destinazione 1", addRelationship: "Aggiungi relazione", removeRelationship: "Rimuovi relazione", relationshipIncomplete: "Seleziona sia la colonna sorgente che quella di destinazione", @@ -2767,6 +2767,130 @@ export default withEnglishFallback({ zoomIn: "Ingrandisci", zoomOut: "Rimpicciolisci", resetLayout: "Reimposta layout", + refreshConfirm: "L'aggiornamento ricaricherà la struttura delle tabelle e reimposterà il layout della tela e la cronologia delle azioni annullate; le relazioni di modellazione salvate localmente non saranno influenzate. Continuare?", + layers: "Livelli", + autoMatch: "Corrispondenza automatica", + autoLayout: "Layout automatico", + matchRelationshipsCount: "{count} corrispondenze", + cardinalityDirected: "Origine {source} · Destinazione {target}", + matchPickCardinality: "Prima di confermare la corrispondenza, selezionare la cardinalità (1:1 / 1:N / N:1 / N:N).", + manualAddRelationship: "Aggiunta manuale", + confirmMatch: "Conferma", + ignoreMatch: "Ignora", + exportPng: "Esporta PNG", + exportJson: "Esporta JSON", + exportDbml: "Esporta DBML", + exportMermaid: "Esporta Mermaid", + export: "Esporta", + exportedFormat: "{format} esportato", + exportFailed: "Esportazione di {format} non riuscita: {message}", + fitView: "Adatta alla tela", + resetZoom: "Reimposta zoom", + undo: "Annulla", + redo: "Ripeti", + fullscreen: "Schermo intero", + exitFullscreen: "Esci da schermo intero", + addLayer: "Aggiungi livello", + noLayersYet: "Nessun livello ancora, fare clic su + per crearne uno", + layoutFree: "Libera", + layoutAuto: "Auto", + layerLock: "Blocca livello (nessun layout automatico all'interno del livello)", + layerUnlock: "Sblocca livello (consenti layout automatico all'interno del livello)", + layerLocked: "Bloccato", + layerUnlocked: "Sbloccato", + hideLayer: "Nascondi livello", + showLayer: "Mostra livello", + deleteLayer: "Elimina livello", + layerHiddenCannotFocus: "Il livello è nascosto; mostrarlo prima di centrarlo sulla tela.", + createTable: "Crea tabella", + create: "Crea", + syncToDatabase: "Sincronizza con il database", + syncDraftCount: "Verranno sincronizzate {count} modifiche (nuove / modifica tabella / eliminazione tabella)", + executeSync: "Esegui", + syncing: "Sincronizzazione in corso…", + buildingSql: "Generazione SQL in corso…", + noSqlYet: "Nessun SQL al momento", + copySql: "Copia SQL", + tableName: "Nome tabella", + tableNameRequired: "Inserire il nome della tabella", + tableNameExists: "Esiste già una tabella con lo stesso nome", + assignLayer: "Livello di appartenenza", + noLayer: "Nessun livello", + withDefaultIdPk: "Includi chiave primaria id predefinita", + inspectorTable: "Tabella", + inspectorRelationship: "Relazione", + fieldComment: "Commento", + fields: "Campi", + tabFields: "Campi", + tabIndexes: "Indici", + addIndex: "Aggiungi indice", + indexName: "Nome indice", + indexUnique: "UNIQUE", + indexColumns: "Colonne", + noIndexesYet: "Nessun indice ancora", + indexesNotSupported: "Il database corrente non supporta la creazione di indici nell'editor di diagrammi", + addField: "Aggiungi campo", + deleteField: "Elimina campo", + undoDropField: "Annulla eliminazione campo", + noFieldsYet: "Nessun campo ancora; aggiungere campi per sincronizzare", + deleteDraftTable: "Elimina tabella bozza", + deleteLiveTable: "Elimina tabella", + deleteLiveTableHint: "Marcata per l'eliminazione; DROP TABLE verrà eseguito alla prossima 'Sincronizzazione con il database'. È possibile annullare l'operazione.", + deleteLiveTableConfirm: "Confermi di marcare questa tabella per l'eliminazione? Durante la sincronizzazione verrà eseguito DROP TABLE.", + liveTableReadOnly: "Tabella già nel database — modificare nell'editor della struttura della tabella", + liveTableAddColumnsHint: "È possibile aggiungere campi (ALTER ADD) o marcare per l'eliminazione campi esistenti (ALTER DROP), quindi sincronizzare. Per modifiche come i tipi, utilizzare l'editor della struttura della tabella.", + liveTableIndexesReadOnly: "Per gli indici di tabelle già nel database, modificarli nell'editor della struttura della tabella.", + pendingColumnsBadge: "Modifiche in attesa di sincronizzazione", + pendingDropColumnBadge: "Da eliminare", + pendingDropTableBadge: "Tabella da eliminare", + source: "Origine", + target: "Destinazione", + emptyDesignHint: "Nessuna tabella ancora. È possibile creare livelli e tabelle, modificare i campi nel pannello delle proprietà e quindi sincronizzare con il database.", + removeFromLayer: "Rimuovi dal livello", + addTables: "Aggiungi tabelle", + tabSelectExisting: "Seleziona esistente", + tabCreateTable: "Crea tabella", + multiSelect: "Selezione multipla", + closeMultiSelect: "Chiudi", + selectAll: "Seleziona tutto", + clearSelection: "Cancella selezione", + tablesInOtherLayers: "Tabelle in altri livelli", + filterTables: "Filtra tabelle...", + noMatchingTables: "Nessuna tabella corrispondente", + addSelectedCount: "Aggiungi {count} elementi selezionati", + layerNameTooLong: "Il nome del livello non può superare 50 caratteri", + layerNameInvalid: "Il nome del livello può contenere caratteri cinesi, lettere, numeri, spazi, trattini bassi, trattini e punti", + layerNameExists: "Il nome del livello esiste già", + total: "Totale", + confirmed: "Confermato", + pending: "In attesa di conferma", + conflicts: "Conflitti", + conflict: "Conflitto", + ignored: "Ignorato", + confirmAll: "Conferma tutto", + ignoreAll: "Ignora tutto", + clearAll: "Cancella tutto", + confidenceHigh: "Alta", + confidenceMedium: "Media", + confidenceLow: "Bassa", + noInferred: "Nessuna relazione inferita", + editRelationship: "Modifica", + saveRelationship: "Salva", + deleteRelationship: "Elimina", + confirmDeleteRelationship: "Confermi l'eliminazione di questa relazione?", + confirmDeleteRelationshipAction: "Conferma eliminazione", + confirmRelationship: "Conferma", + ignoreRelationship: "Ignora", + relationshipKindFk: "Chiave esterna", + relationshipKindCustom: "Personalizzata", + relationshipKindInferred: "Inferita", + relationshipReadOnlyFk: "Questa relazione deriva da un vincolo di chiave esterna del database; non può essere modificata qui.", + relationshipUpdated: "Relazione aggiornata", + createTableNotSupported: "Il database corrente non supporta la creazione di nuove tabelle", + structureSyncNotSupported: "Il database corrente non supporta la sincronizzazione della struttura delle tabelle dal diagramma", + addColumnNotSupported: "Il database corrente non supporta l'aggiunta di colonne", + dropColumnNotSupported: "Il database corrente non supporta l'eliminazione di colonne", + dropTableNotSupported: "Il database corrente non supporta l'eliminazione di tabelle dal diagramma", }, etcd: { prefixPlaceholder: "Cerca percorso Key, ad es. /app/ o servizio", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 95e5aed52..8e8a33a80 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2813,10 +2813,10 @@ export default withEnglishFallback({ targetTable: "ターゲットテーブル", targetColumn: "ターゲット列", cardinality: "カーディナリティ", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "ソース 1 · ターゲット N", + cardinalityManyToOne: "ソース N · ターゲット 1", + cardinalityManyToMany: "ソース N · ターゲット N", + cardinalityOneToOne: "ソース 1 · ターゲット 1", addRelationship: "関係を追加", removeRelationship: "関係を削除", relationshipIncomplete: "ソース列とターゲット列を選択してください", @@ -2833,6 +2833,130 @@ export default withEnglishFallback({ zoomIn: "拡大", zoomOut: "縮小", resetLayout: "レイアウトをリセット", + refreshConfirm: "更新するとテーブル構造が再読み込みされ、キャンバスレイアウトと元に戻す履歴がリセットされます。ローカルに保存されたモデリング関係には影響しません。続行しますか?", + layers: "レイヤー", + autoMatch: "自動マッチング", + autoLayout: "自動レイアウト", + matchRelationshipsCount: "{count} 件の一致", + cardinalityDirected: "ソース {source} · ターゲット {target}", + matchPickCardinality: "一致を確認する前に基数(1:1 / 1:N / N:1 / N:N)を選択してください。", + manualAddRelationship: "手動追加", + confirmMatch: "確認", + ignoreMatch: "無視", + exportPng: "PNG エクスポート", + exportJson: "JSON エクスポート", + exportDbml: "DBML エクスポート", + exportMermaid: "Mermaid エクスポート", + export: "エクスポート", + exportedFormat: "{format} をエクスポートしました", + exportFailed: "{format} のエクスポートに失敗しました:{message}", + fitView: "キャンバスにフィット", + resetZoom: "ズームをリセット", + undo: "元に戻す", + redo: "やり直す", + fullscreen: "全画面", + exitFullscreen: "全画面を終了", + addLayer: "レイヤーを追加", + noLayersYet: "レイヤーがありません。+ をクリックして作成", + layoutFree: "フリー", + layoutAuto: "自動", + layerLock: "レイヤーをロック(レイヤー内で自動レイアウトしない)", + layerUnlock: "レイヤーのロックを解除(レイヤー内で自動レイアウトを許可)", + layerLocked: "ロック済み", + layerUnlocked: "未ロック", + hideLayer: "レイヤーを非表示", + showLayer: "レイヤーを表示", + deleteLayer: "レイヤーを削除", + layerHiddenCannotFocus: "レイヤーは非表示です。表示してからキャンバスに移動してください", + createTable: "テーブルを作成", + create: "作成", + syncToDatabase: "データベースに同期", + syncDraftCount: "{count} 件の変更を同期します(新規作成 / 変更 / 削除)", + executeSync: "実行", + syncing: "同期中…", + buildingSql: "SQL を生成中…", + noSqlYet: "SQL はまだありません", + copySql: "SQL をコピー", + tableName: "テーブル名", + tableNameRequired: "テーブル名を入力してください", + tableNameExists: "同名のテーブルが既に存在します", + assignLayer: "所属レイヤー", + noLayer: "レイヤーなし", + withDefaultIdPk: "デフォルトの id 主キーを含める", + inspectorTable: "テーブル", + inspectorRelationship: "リレーションシップ", + fieldComment: "コメント", + fields: "フィールド", + tabFields: "フィールド", + tabIndexes: "インデックス", + addIndex: "インデックスを追加", + indexName: "インデックス名", + indexUnique: "UNIQUE", + indexColumns: "列", + noIndexesYet: "インデックスはまだありません", + indexesNotSupported: "現在のデータベースはダイアグラムエディタでのインデックス作成をサポートしていません", + addField: "フィールドを追加", + deleteField: "フィールドを削除", + undoDropField: "フィールド削除を元に戻す", + noFieldsYet: "フィールドがまだありません。追加後に同期できます", + deleteDraftTable: "下書きテーブルを削除", + deleteLiveTable: "テーブルを削除", + deleteLiveTableHint: "削除待ちとしてマークされ、次回「データベースに同期」時に DROP TABLE を実行します。元に戻すことで復元できます。", + deleteLiveTableConfirm: "このテーブルを削除としてマークしますか?同期時に DROP TABLE が実行されます。", + liveTableReadOnly: "登録済みテーブル — テーブル構造エディタで変更してください", + liveTableAddColumnsHint: "フィールドの追加(ALTER ADD)または既存フィールドの削除マーク(ALTER DROP)が可能です。その後同期します。型変更などはテーブル構造エディタを使用してください。", + liveTableIndexesReadOnly: "登録済みテーブルのインデックスはテーブル構造エディタで変更してください", + pendingColumnsBadge: "同期待ちの変更", + pendingDropColumnBadge: "削除待ち", + pendingDropTableBadge: "削除待ちテーブル", + source: "ソース", + target: "ターゲット", + emptyDesignHint: "テーブルがまだありません。レイヤーとテーブルを作成し、プロパティパネルでフィールドを編集して、完了後にデータベースに同期できます。", + removeFromLayer: "レイヤーから削除", + addTables: "テーブルを追加", + tabSelectExisting: "既存を選択", + tabCreateTable: "新規テーブル", + multiSelect: "複数選択", + closeMultiSelect: "閉じる", + selectAll: "すべて選択", + clearSelection: "クリア", + tablesInOtherLayers: "他のレイヤーのテーブル", + filterTables: "テーブルをフィルタ...", + noMatchingTables: "一致するテーブルがありません", + addSelectedCount: "選択した {count} 件を追加", + layerNameTooLong: "レイヤー名は50文字以内にしてください", + layerNameInvalid: "レイヤー名には中国語、英字、数字、スペース、アンダースコア、ハイフン、ピリオドを使用できます", + layerNameExists: "レイヤー名が既に存在します", + total: "合計", + confirmed: "確認済み", + pending: "確認待ち", + conflicts: "競合", + conflict: "競合", + ignored: "無視済み", + confirmAll: "すべて確認", + ignoreAll: "すべて無視", + clearAll: "すべてクリア", + confidenceHigh: "高", + confidenceMedium: "中", + confidenceLow: "低", + noInferred: "推論された関係はありません", + editRelationship: "編集", + saveRelationship: "保存", + deleteRelationship: "削除", + confirmDeleteRelationship: "このリレーションシップを削除しますか?", + confirmDeleteRelationshipAction: "削除を確認", + confirmRelationship: "確認", + ignoreRelationship: "無視", + relationshipKindFk: "外部キー", + relationshipKindCustom: "カスタム", + relationshipKindInferred: "推論", + relationshipReadOnlyFk: "このリレーションシップはデータベースの外部キー制約に基づいており、ここでは変更できません。", + relationshipUpdated: "リレーションシップが更新されました", + createTableNotSupported: "現在のデータベースは新しいテーブルの作成をサポートしていません", + structureSyncNotSupported: "現在のデータベースは図からテーブル構造を同期することをサポートしていません", + addColumnNotSupported: "現在のデータベースは列の追加をサポートしていません", + dropColumnNotSupported: "現在のデータベースは列の削除をサポートしていません", + dropTableNotSupported: "現在のデータベースは図からテーブルを削除することをサポートしていません", }, etcd: { prefixPlaceholder: "Key パスを検索(例: /app/、service)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e8c38aafe..06963fc9f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2749,10 +2749,10 @@ export default withEnglishFallback({ targetTable: "Tabela de destino", targetColumn: "Coluna de destino", cardinality: "Cardinalidade", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "Origem 1 · Destino N", + cardinalityManyToOne: "Origem N · Destino 1", + cardinalityManyToMany: "Origem N · Destino N", + cardinalityOneToOne: "Origem 1 · Destino 1", addRelationship: "Adicionar relacionamento", removeRelationship: "Remover relacionamento", relationshipIncomplete: "Selecione as colunas de origem e destino", @@ -2769,6 +2769,130 @@ export default withEnglishFallback({ zoomIn: "Aumentar zoom", zoomOut: "Diminuir zoom", resetLayout: "Redefinir layout", + refreshConfirm: "Atualizar recarregará a estrutura da tabela e redefinirá o layout do canvas e o histórico de desfazer. Os relacionamentos de modelagem salvos localmente não serão afetados. Continuar?", + layers: "Camadas", + autoMatch: "Correspondência automática", + autoLayout: "Layout automático", + matchRelationshipsCount: "{count} correspondências", + cardinalityDirected: "Origem {source} · Destino {target}", + matchPickCardinality: "Selecione a cardinalidade antes de confirmar a correspondência (1:1 / 1:N / N:1 / N:N).", + manualAddRelationship: "Adicionar manualmente", + confirmMatch: "Confirmar", + ignoreMatch: "Ignorar", + exportPng: "Exportar PNG", + exportJson: "Exportar JSON", + exportDbml: "Exportar DBML", + exportMermaid: "Exportar Mermaid", + export: "Exportar", + exportedFormat: "{format} exportado", + exportFailed: "Falha ao exportar {format}: {message}", + fitView: "Ajustar ao canvas", + resetZoom: "Redefinir zoom", + undo: "Desfazer", + redo: "Refazer", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", + addLayer: "Adicionar camada", + noLayersYet: "Nenhuma camada ainda, clique em + para criar", + layoutFree: "Livre", + layoutAuto: "Automático", + layerLock: "Bloquear camada (sem layout automático dentro da camada)", + layerUnlock: "Desbloquear camada (permite layout automático dentro da camada)", + layerLocked: "Bloqueada", + layerUnlocked: "Desbloqueada", + hideLayer: "Ocultar camada", + showLayer: "Mostrar camada", + deleteLayer: "Excluir camada", + layerHiddenCannotFocus: "A camada está oculta. Mostre-a antes de centralizar no canvas.", + createTable: "Nova tabela", + create: "Criar", + syncToDatabase: "Sincronizar com o banco de dados", + syncDraftCount: "Serão sincronizadas {count} alterações (nova tabela / alterar tabela / excluir tabela)", + executeSync: "Executar", + syncing: "Sincronizando…", + buildingSql: "Gerando SQL…", + noSqlYet: "Nenhum SQL ainda", + copySql: "Copiar SQL", + tableName: "Nome da tabela", + tableNameRequired: "Informe o nome da tabela", + tableNameExists: "Já existe uma tabela com esse nome", + assignLayer: "Camada", + noLayer: "Sem camada", + withDefaultIdPk: "Incluir chave primária id padrão", + inspectorTable: "Tabela", + inspectorRelationship: "Relacionamento", + fieldComment: "Comentário", + fields: "Campos", + tabFields: "Campos", + tabIndexes: "Índices", + addIndex: "Adicionar índice", + indexName: "Nome do índice", + indexUnique: "UNIQUE", + indexColumns: "Colunas", + noIndexesYet: "Nenhum índice ainda", + indexesNotSupported: "O banco de dados atual não suporta criar índices no editor de diagramas", + addField: "Adicionar campo", + deleteField: "Excluir campo", + undoDropField: "Desfazer exclusão do campo", + noFieldsYet: "Nenhum campo ainda. Adicione antes de sincronizar", + deleteDraftTable: "Excluir tabela de rascunho", + deleteLiveTable: "Excluir tabela", + deleteLiveTableHint: "Marcada para exclusão. O DROP TABLE será executado na próxima sincronização com o banco de dados. Você pode desfazer para restaurar.", + deleteLiveTableConfirm: "Confirmar marcação desta tabela para exclusão? O DROP TABLE será executado durante a sincronização.", + liveTableReadOnly: "Tabela no banco — edite no editor de estrutura da tabela", + liveTableAddColumnsHint: "Você pode adicionar campos (ALTER ADD) ou marcar campos existentes para exclusão (ALTER DROP) e depois sincronizar. Para alterar tipos, use o editor de estrutura da tabela.", + liveTableIndexesReadOnly: "Índices de tabelas no banco devem ser alterados no editor de estrutura da tabela", + pendingColumnsBadge: "Alterações pendentes de sincronização", + pendingDropColumnBadge: "A excluir", + pendingDropTableBadge: "Tabela a excluir", + source: "Origem", + target: "Destino", + emptyDesignHint: "Nenhuma tabela ainda. Você pode criar camadas e tabelas, editar campos no painel de propriedades e sincronizar com o banco de dados ao concluir.", + removeFromLayer: "Remover da camada", + addTables: "Adicionar tabelas", + tabSelectExisting: "Selecionar existente", + tabCreateTable: "Nova tabela", + multiSelect: "Seleção múltipla", + closeMultiSelect: "Fechar", + selectAll: "Selecionar tudo", + clearSelection: "Limpar seleção", + tablesInOtherLayers: "Tabelas em outras camadas", + filterTables: "Filtrar tabelas...", + noMatchingTables: "Nenhuma tabela correspondente", + addSelectedCount: "Adicionar {count} selecionados", + layerNameTooLong: "O nome da camada não pode exceder 50 caracteres", + layerNameInvalid: "O nome da camada pode conter caracteres chineses, letras, números, espaços, sublinhados, hífens e pontos finais.", + layerNameExists: "O nome da camada já existe", + total: "Total", + confirmed: "Confirmado", + pending: "Pendente", + conflicts: "Conflitos", + conflict: "Conflito", + ignored: "Ignorado", + confirmAll: "Confirmar tudo", + ignoreAll: "Ignorar tudo", + clearAll: "Limpar tudo", + confidenceHigh: "Alta", + confidenceMedium: "Média", + confidenceLow: "Baixa", + noInferred: "Nenhum relacionamento inferido ainda", + editRelationship: "Editar", + saveRelationship: "Salvar", + deleteRelationship: "Excluir", + confirmDeleteRelationship: "Confirmar exclusão deste relacionamento?", + confirmDeleteRelationshipAction: "Confirmar exclusão", + confirmRelationship: "Confirmar", + ignoreRelationship: "Ignorar", + relationshipKindFk: "Chave estrangeira", + relationshipKindCustom: "Personalizado", + relationshipKindInferred: "Inferido", + relationshipReadOnlyFk: "Este relacionamento vem de uma restrição de chave estrangeira do banco de dados e não pode ser editado aqui.", + relationshipUpdated: "Relacionamento atualizado", + createTableNotSupported: "O banco de dados atual não suporta criar nova tabela", + structureSyncNotSupported: "O banco de dados atual não suporta sincronizar a estrutura da tabela a partir do diagrama", + addColumnNotSupported: "O banco de dados atual não suporta adicionar coluna", + dropColumnNotSupported: "O banco de dados atual não suporta excluir coluna", + dropTableNotSupported: "O banco de dados atual não suporta excluir tabela do diagrama", }, etcd: { prefixPlaceholder: "Buscar caminho da Key, por exemplo /app/ ou serviço", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c1be3c660..b8b0f1db0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2878,6 +2878,7 @@ export default withEnglishFallback({ selectSchema: "选择模式", searchTables: "搜索表/字段/外键...", refresh: "刷新关系图", + refreshConfirm: "刷新将重新加载表结构并重置画布布局与撤回历史,本地已保存的建模关系不受影响。确定继续?", loading: "正在读取关系...", loadingProgress: "正在读取关系... {loaded}/{total}", partialError: "{count} 张表的元数据读取失败,已跳过", @@ -2889,6 +2890,10 @@ export default withEnglishFallback({ tableMode: "表结构图", engineeringMode: "工程 ER 图", modelRelationships: "建模关系", + layers: "Layers", + autoMatch: "自动匹配", + autoLayout: "自动布局", + matchRelationshipsCount: "{count} 条匹配", copyJoinSql: "复制 SQL", customRelationshipsCount: "{count} 条建模", relationshipName: "名称", @@ -2898,10 +2903,15 @@ export default withEnglishFallback({ targetTable: "目标表", targetColumn: "目标字段", cardinality: "基数", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "源 1 · 目标 N", + cardinalityManyToOne: "源 N · 目标 1", + cardinalityOneToOne: "源 1 · 目标 1", + cardinalityManyToMany: "源 N · 目标 N", + cardinalityDirected: "源 {source} · 目标 {target}", + matchPickCardinality: "确认匹配前请选择基数(1:1 / 1:N / N:1 / N:N)。", + manualAddRelationship: "手动补录", + confirmMatch: "确认", + ignoreMatch: "忽略", addRelationship: "添加关系", removeRelationship: "删除关系", relationshipIncomplete: "请选择源字段和目标字段", @@ -2913,11 +2923,125 @@ export default withEnglishFallback({ relatedTables: "相关表", moreColumns: "+ {count} 个字段", exportSvg: "导出 SVG", + exportPng: "导出 PNG", + exportJson: "导出 JSON", + exportDbml: "导出 DBML", + exportMermaid: "导出 Mermaid", + export: "导出", exportedSvg: "SVG 已导出", + exportedFormat: "{format} 已导出", exportSvgFailed: "导出 SVG 失败:{message}", + exportFailed: "导出 {format} 失败:{message}", zoomIn: "放大", zoomOut: "缩小", + fitView: "适应画布", + resetZoom: "重置缩放", + undo: "撤回", + redo: "前进", resetLayout: "重置布局", + fullscreen: "全屏", + exitFullscreen: "退出全屏", + addLayer: "新增层", + noLayersYet: "暂无图层,点击 + 创建", + layoutFree: "自由", + layoutAuto: "自动", + layerLock: "锁定层(层内不自动布局)", + layerUnlock: "解锁层(层内允许自动布局)", + layerLocked: "已锁定", + layerUnlocked: "未锁定", + hideLayer: "隐藏层", + showLayer: "显示层", + deleteLayer: "删除层", + layerHiddenCannotFocus: "层已隐藏,请先显示后再定位到画布", + createTable: "新建表", + createTableNotSupported: "当前数据库不支持新建表", + create: "创建", + syncToDatabase: "同步到数据库", + structureSyncNotSupported: "当前数据库不支持从图同步表结构", + syncDraftCount: "将同步 {count} 项变更(新建 / 改表 / 删表)", + executeSync: "执行", + syncing: "同步中…", + buildingSql: "正在生成 SQL…", + noSqlYet: "暂无 SQL", + copySql: "复制 SQL", + tableName: "表名", + tableNameRequired: "请填写表名", + tableNameExists: "已存在同名表", + assignLayer: "所属层", + noLayer: "不分层", + withDefaultIdPk: "包含默认 id 主键", + inspectorTable: "表", + inspectorRelationship: "关系", + fieldComment: "注释", + fields: "字段", + tabFields: "字段", + tabIndexes: "索引", + addIndex: "添加索引", + indexName: "索引名", + indexUnique: "UNIQUE", + indexColumns: "列", + noIndexesYet: "还没有索引", + indexesNotSupported: "当前数据库不支持在图编辑器中创建索引", + addColumnNotSupported: "当前数据库不支持添加列", + dropColumnNotSupported: "当前数据库不支持删除列", + dropTableNotSupported: "当前数据库不支持从图删除表", + addField: "添加字段", + deleteField: "删除字段", + undoDropField: "撤销删除字段", + noFieldsYet: "还没有字段,添加后才能同步", + deleteDraftTable: "删除草稿表", + deleteLiveTable: "删除表", + deleteLiveTableHint: "标记为待删除,下次「同步到数据库」时执行 DROP TABLE。可用撤销恢复。", + deleteLiveTableConfirm: "确认将此表标记为删除?同步时将执行 DROP TABLE。", + liveTableReadOnly: "已入库表 — 请在表结构编辑器中修改", + liveTableAddColumnsHint: "可新增字段(ALTER ADD)或标记已有字段删除(ALTER DROP),然后同步。改类型等请用表结构编辑器。", + liveTableIndexesReadOnly: "已入库表索引请在表结构编辑器中修改", + pendingColumnsBadge: "待同步变更", + pendingDropColumnBadge: "待删", + pendingDropTableBadge: "待删表", + source: "源", + target: "目标", + emptyDesignHint: "还没有表。可新建层与表,在属性面板编辑字段,完成后同步到数据库。", + removeFromLayer: "从层中移除", + addTables: "添加表", + tabSelectExisting: "选择已有", + tabCreateTable: "新建表", + multiSelect: "多选", + closeMultiSelect: "关闭", + selectAll: "全选", + clearSelection: "清空", + tablesInOtherLayers: "其他层中的表", + filterTables: "筛选表...", + noMatchingTables: "无匹配的表", + addSelectedCount: "添加已选 {count} 项", + layerNameTooLong: "图层名称不能超过 50 个字符", + layerNameInvalid: "图层名称可含中文、字母、数字、空格、下划线、连字符和句点", + layerNameExists: "图层名称已存在", + total: "总计", + confirmed: "已确认", + pending: "待确认", + conflicts: "冲突", + conflict: "冲突", + ignored: "已忽略", + confirmAll: "全部确认", + ignoreAll: "全部忽略", + clearAll: "清除全部", + confidenceHigh: "高", + confidenceMedium: "中", + confidenceLow: "低", + noInferred: "暂无推断关系", + editRelationship: "编辑", + saveRelationship: "保存", + deleteRelationship: "删除", + confirmDeleteRelationship: "确认删除此关系?", + confirmDeleteRelationshipAction: "确认删除", + confirmRelationship: "确认", + ignoreRelationship: "忽略", + relationshipKindFk: "外键", + relationshipKindCustom: "自定义", + relationshipKindInferred: "推断", + relationshipReadOnlyFk: "该关系来自数据库外键约束,无法在此修改。", + relationshipUpdated: "关系已更新", }, etcd: { prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2e961718f..82fb0b103 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2626,10 +2626,10 @@ export default withEnglishFallback({ targetTable: "目標資料表", targetColumn: "目標欄位", cardinality: "基數", - cardinalityOneToMany: "1:N", - cardinalityManyToOne: "N:1", - cardinalityManyToMany: "N:N", - cardinalityOneToOne: "1:1", + cardinalityOneToMany: "來源 1 · 目標 N", + cardinalityManyToOne: "來源 N · 目標 1", + cardinalityManyToMany: "來源 N · 目標 N", + cardinalityOneToOne: "來源 1 · 目標 1", addRelationship: "新增關係", removeRelationship: "移除關係", relationshipIncomplete: "請同時選擇來源和目標欄位", @@ -2637,6 +2637,130 @@ export default withEnglishFallback({ relationshipExists: "此關係已存在", relationshipAdded: "關係已新增", noJoinSql: "沒有可複製的關係 SQL", + refreshConfirm: "重新整理將重新載入表結構並重設畫布佈局與復原歷史,本機已儲存的建模關係不受影響。確定繼續?", + layers: "圖層", + autoMatch: "自動匹配", + autoLayout: "自動佈局", + matchRelationshipsCount: "{count} 條匹配", + cardinalityDirected: "來源 {source} · 目標 {target}", + matchPickCardinality: "確認匹配前請選擇基數(1:1 / 1:N / N:1 / N:N)。", + manualAddRelationship: "手動補錄", + confirmMatch: "確認", + ignoreMatch: "忽略", + exportPng: "匯出 PNG", + exportJson: "匯出 JSON", + exportDbml: "匯出 DBML", + exportMermaid: "匯出 Mermaid", + export: "匯出", + exportedFormat: "{format} 已匯出", + exportFailed: "匯出 {format} 失敗:{message}", + fitView: "適應畫布", + resetZoom: "重設縮放", + undo: "復原", + redo: "重做", + fullscreen: "全螢幕", + exitFullscreen: "退出全螢幕", + addLayer: "新增圖層", + noLayersYet: "尚無圖層,點擊 + 建立", + layoutFree: "自由", + layoutAuto: "自動", + layerLock: "鎖定圖層(圖層內不自動佈局)", + layerUnlock: "解鎖圖層(圖層內允許自動佈局)", + layerLocked: "已鎖定", + layerUnlocked: "未鎖定", + hideLayer: "隱藏圖層", + showLayer: "顯示圖層", + deleteLayer: "刪除圖層", + layerHiddenCannotFocus: "圖層已隱藏,請先顯示後再定位到畫布", + createTable: "新增資料表", + create: "建立", + syncToDatabase: "同步到資料庫", + syncDraftCount: "將同步 {count} 項變更(新增 / 修改表 / 刪除表)", + executeSync: "執行", + syncing: "同步中...", + buildingSql: "正在產生 SQL...", + noSqlYet: "暫無 SQL", + copySql: "複製 SQL", + tableName: "資料表名稱", + tableNameRequired: "請填寫資料表名稱", + tableNameExists: "已存在相同名稱的資料表", + assignLayer: "所屬圖層", + noLayer: "不分圖層", + withDefaultIdPk: "包含預設 id 主鍵", + inspectorTable: "資料表", + inspectorRelationship: "關係", + fieldComment: "註解", + fields: "欄位", + tabFields: "欄位", + tabIndexes: "索引", + addIndex: "新增索引", + indexName: "索引名稱", + indexUnique: "UNIQUE", + indexColumns: "欄", + noIndexesYet: "尚無索引", + indexesNotSupported: "目前資料庫不支援在圖形編輯器中建立索引", + addField: "新增欄位", + deleteField: "刪除欄位", + undoDropField: "復原刪除欄位", + noFieldsYet: "尚無欄位,新增後才能同步", + deleteDraftTable: "刪除草稿資料表", + deleteLiveTable: "刪除資料表", + deleteLiveTableHint: "標記為待刪除,下次「同步到資料庫」時執行 DROP TABLE。可用復原恢復。", + deleteLiveTableConfirm: "確認將此資料表標記為刪除?同步時將執行 DROP TABLE。", + liveTableReadOnly: "已入庫資料表 — 請在資料表結構編輯器中修改", + liveTableAddColumnsHint: "可新增欄位(ALTER ADD)或標記已有欄位刪除(ALTER DROP),然後同步。變更類型等請使用資料表結構編輯器。", + liveTableIndexesReadOnly: "已入庫資料表索引請在資料表結構編輯器中修改", + pendingColumnsBadge: "待同步變更", + pendingDropColumnBadge: "待刪除", + pendingDropTableBadge: "待刪除資料表", + source: "來源", + target: "目標", + emptyDesignHint: "尚無資料表。可新增圖層與資料表,在屬性面板編輯欄位,完成後同步到資料庫。", + removeFromLayer: "從圖層中移除", + addTables: "新增資料表", + tabSelectExisting: "選擇既有", + tabCreateTable: "新增資料表", + multiSelect: "多選", + closeMultiSelect: "關閉", + selectAll: "全選", + clearSelection: "清除", + tablesInOtherLayers: "其他圖層中的資料表", + filterTables: "篩選資料表...", + noMatchingTables: "無匹配的資料表", + addSelectedCount: "新增已選 {count} 項", + layerNameTooLong: "圖層名稱不能超過 50 個字元", + layerNameInvalid: "圖層名稱可含中文、字母、數字、空格、底線、連字符和句點", + layerNameExists: "圖層名稱已存在", + total: "總計", + confirmed: "已確認", + pending: "待確認", + conflicts: "衝突", + conflict: "衝突", + ignored: "已忽略", + confirmAll: "全部確認", + ignoreAll: "全部忽略", + clearAll: "清除全部", + confidenceHigh: "高", + confidenceMedium: "中", + confidenceLow: "低", + noInferred: "暫無推斷關係", + editRelationship: "編輯", + saveRelationship: "儲存", + deleteRelationship: "刪除", + confirmDeleteRelationship: "確認刪除此關係?", + confirmDeleteRelationshipAction: "確認刪除", + confirmRelationship: "確認", + ignoreRelationship: "忽略", + relationshipKindFk: "外鍵", + relationshipKindCustom: "自訂", + relationshipKindInferred: "推斷", + relationshipReadOnlyFk: "該關係來自資料庫外鍵約束,無法在此修改。", + relationshipUpdated: "關係已更新", + createTableNotSupported: "目前資料庫不支援建立新資料表", + structureSyncNotSupported: "目前資料庫不支援從圖同步資料表結構", + addColumnNotSupported: "目前資料庫不支援新增欄位", + dropColumnNotSupported: "目前資料庫不支援刪除欄位", + dropTableNotSupported: "目前資料庫不支援從圖刪除資料表", }, redis: { setDatabaseAlias: "設定資料庫別名", diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 8e403fc53..42f478993 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -288,12 +288,17 @@ describe("tableStructureEditorState", () => { expect(getDefaultLengthForType("mysql", "float")).toBe("10,2"); }); - it("uses TEXT for a new native SQLite column without changing compatible defaults", () => { + it("uses TEXT for SQLite-family columns and dialect defaults elsewhere", () => { expect(DATA_TYPE_OPTIONS.sqlite).toContain("text"); + expect(DATA_TYPE_OPTIONS.duckdb).toContain("TEXT"); + expect(DATA_TYPE_OPTIONS.h2).toContain("VARCHAR"); expect(defaultNewColumnDataType("sqlite")).toBe("text"); - expect(defaultNewColumnDataType("rqlite")).toBe("varchar(255)"); - expect(defaultNewColumnDataType("turso")).toBe("varchar(255)"); + expect(defaultNewColumnDataType("rqlite")).toBe("text"); + expect(defaultNewColumnDataType("turso")).toBe("text"); + expect(defaultNewColumnDataType("duckdb")).toBe("TEXT"); expect(defaultNewColumnDataType("mysql")).toBe("varchar(255)"); + expect(defaultNewColumnDataType("h2").toLowerCase()).toContain("varchar"); + expect(defaultNewColumnDataType("clickhouse")).toBe("String"); }); it("requires a SQLite rebuild only for a retained existing column type change", () => { diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 1bc0b78a8..e5eabff26 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -158,6 +158,7 @@ export const listCompletionObjects = forward("listCompletionObjects"); export const completionAssistantSearch = forward("completionAssistantSearch"); export const getObjectSource = forward("getObjectSource"); export const getColumns = forward("getColumns"); +export const getAllColumns = forward("getAllColumns"); export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const listDataTypes = forward("listDataTypes"); export const listIndexes = forward("listIndexes"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 64fa30d32..b0e454ed8 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -744,6 +744,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database: return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`); } +export interface TableColumnsResult { + table_name: string; + columns: ColumnInfo[]; + error?: string; +} + +export async function getAllColumns(connectionId: string, database: string, schema: string): Promise { + return get(`/api/schema/all-columns?${qs({ connection_id: connectionId, database, schema })}`); +} + export async function listDataTypes(connectionId: string, database: string): Promise { return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 61334bd44..86546fc0b 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1042,6 +1042,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database: }); } +export interface TableColumnsResult { + table_name: string; + columns: ColumnInfo[]; + error?: string; +} + +export async function getAllColumns(connectionId: string, database: string, schema: string): Promise { + return invoke("get_all_columns", { connectionId, database, schema }); +} + export async function listDataTypes(connectionId: string, database: string): Promise { return invoke("list_data_types", { connectionId, database }); } diff --git a/apps/desktop/src/lib/diagram/cardinality.ts b/apps/desktop/src/lib/diagram/cardinality.ts new file mode 100644 index 000000000..3c5e9214d --- /dev/null +++ b/apps/desktop/src/lib/diagram/cardinality.ts @@ -0,0 +1,35 @@ +export type CardinalityChoice = "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; + +export type CardinalityPair = { + sourceCardinality: "1" | "N"; + targetCardinality: "1" | "N"; +}; + +export function cardinalityPairFromChoice(choice: CardinalityChoice): CardinalityPair { + if (choice === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" }; + if (choice === "one-to-many") return { sourceCardinality: "1", targetCardinality: "N" }; + if (choice === "many-to-many") return { sourceCardinality: "N", targetCardinality: "N" }; + return { sourceCardinality: "N", targetCardinality: "1" }; +} + +export function cardinalityChoiceFromPair(pair: Partial | null | undefined): CardinalityChoice { + if (!pair) return "many-to-one"; + const s = pair.sourceCardinality; + const t = pair.targetCardinality; + if (s === "1" && t === "1") return "one-to-one"; + if (s === "1" && t === "N") return "one-to-many"; + if (s === "N" && t === "N") return "many-to-many"; + return "many-to-one"; +} + +/** Resolve endpoint cardinalities from a relationship; FK/inferred without explicit pair defaults to N:1. */ +export function edgeCardinalityPair(rel: Partial | object | null | undefined): CardinalityPair { + if (rel && typeof rel === "object" && "sourceCardinality" in rel && "targetCardinality" in rel) { + const sourceCardinality = (rel as Partial).sourceCardinality; + const targetCardinality = (rel as Partial).targetCardinality; + if (sourceCardinality && targetCardinality) { + return { sourceCardinality, targetCardinality }; + } + } + return { sourceCardinality: "N", targetCardinality: "1" }; +} diff --git a/apps/desktop/src/lib/diagram/diagram-constants.ts b/apps/desktop/src/lib/diagram/diagram-constants.ts new file mode 100644 index 000000000..918459ffc --- /dev/null +++ b/apps/desktop/src/lib/diagram/diagram-constants.ts @@ -0,0 +1,52 @@ +/** provide/inject key for the currently hovered relationship edge id */ +export const DIAGRAM_HOVERED_EDGE_KEY = "diagramHoveredEdgeId"; +/** provide/inject key for table/layer obstacle rects used by edge routing */ +export const DIAGRAM_EDGE_OBSTACLES_KEY = "diagramEdgeObstacles"; + +export const CARD_WIDTH = 360; +export const CARD_HEADER_HEIGHT = 44; +export const COLUMN_ROW_HEIGHT = 24; +export const CARD_BOTTOM_PADDING = 12; +/** Fixed width for the data-type column inside a table card */ +export const COLUMN_TYPE_WIDTH = 112; +/** Display truncation caps (CSS truncate + char limit) */ +export const COLUMN_NAME_MAX_CHARS = 36; +export const COLUMN_TYPE_MAX_CHARS = 22; +export const TABLE_NAME_MAX_CHARS = 40; + +export const GAP_X = 80; +export const GAP_Y = 60; +export const MARGIN = 40; + +/** Handle inset from card edge (0 = centered on border) */ +export const EDGE_HANDLE_OUTSET = 0; +/** Extra clearance before orthogonal bends around a node */ +export const EDGE_ROUTE_OFFSET = 36; +/** Idle / hover stroke widths for relationship edges */ +export const EDGE_STROKE_IDLE = 1.6; +export const EDGE_STROKE_HOVER = 3.5; +/** Delay before hover opens edge detail popover / highlight */ +export const EDGE_POPOVER_OPEN_DELAY_MS = 400; +/** Delay before hover popover closes after leaving edge / popover */ +export const EDGE_POPOVER_CLOSE_DELAY_MS = 220; + +/** Padding used by ELK nested layer boxes */ +export const LAYER_PADDING = 30; +export const LAYER_HEADER_HEIGHT = 40; + +/** Tighter padding for Size-to-Fit / empty layer chrome */ +export const LAYER_CONTENT_PADDING = 20; +export const EMPTY_LAYER_WIDTH = 240; +export const EMPTY_LAYER_HEIGHT = LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING; + +export function tableCardHeight(columnCount: number): number { + return CARD_HEADER_HEIGHT + columnCount * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING; +} + +/** LTR wrap column count from available canvas width (at least 1). */ +export function columnsPerRowForWidth(viewportWidth?: number): number { + if (!viewportWidth || viewportWidth <= 0) return 6; + const usable = viewportWidth - MARGIN * 2; + const cols = Math.floor(usable / (CARD_WIDTH + GAP_X)); + return Math.max(1, cols < 1 ? 1 : cols); +} diff --git a/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts b/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts new file mode 100644 index 000000000..c6b7eb81a --- /dev/null +++ b/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts @@ -0,0 +1,75 @@ +/** + * Thin column factories for the ER diagram. + * + * Dialect capability / SQL truth lives in the shared table-structure stack: + * - `tableStructureCapabilities` (UI gates) + * - `tableStructureEditorState` (type options / defaults) + * - Rust `table_structure_sql` via `buildCreateTableSql` / `buildTableStructureChangeSql` + * + * Do not add ER-specific dialect matrices or SQL generation here. + * Adding a dialect: update that shared stack (same checklist as TableStructureEditor). + */ +import type { ColumnInfo, DatabaseType } from "@/types/database"; +import { getTableStructureCapabilities, type TableStructureDialect } from "@/lib/table/tableStructureCapabilities"; +import { defaultNewColumnDataType, getDataTypeOptions } from "@/lib/table/tableStructureEditorState"; + +export interface DiagramDialectAdapter { + databaseType: DatabaseType | undefined; + createDefaultIdColumn(): ColumnInfo; + createEmptyColumn(name?: string): ColumnInfo; +} + +const DEFAULT_ID_TYPE_BY_DIALECT: Partial> = { + mysql: "bigint", + postgres: "bigint", + sqlserver: "bigint", + h2: "bigint", + informix: "bigint", + oracle: "NUMBER", + sqlite: "INTEGER", + duckdb: "INTEGER", + clickhouse: "UInt64", +}; + +function resolveDefaultIdType(dialect: TableStructureDialect, dataTypeOptions: readonly string[]): string { + const preferred = DEFAULT_ID_TYPE_BY_DIALECT[dialect]; + if (preferred) { + if (dataTypeOptions.length === 0) return preferred; + const matched = dataTypeOptions.find((type) => type.trim().toLowerCase() === preferred.toLowerCase()); + if (matched) return matched; + } + const integerLike = dataTypeOptions.find((type) => /^(bigint|int|integer|number|uint64|int64)/i.test(type.trim())); + if (integerLike) return integerLike; + return preferred ?? dataTypeOptions[0] ?? "bigint"; +} + +export function resolveDiagramDialectAdapter(databaseType?: DatabaseType): DiagramDialectAdapter { + const caps = getTableStructureCapabilities(databaseType); + const dataTypeOptions = getDataTypeOptions(databaseType); + + return { + databaseType, + createDefaultIdColumn() { + return { + name: "id", + data_type: resolveDefaultIdType(caps.dialect, dataTypeOptions), + is_nullable: false, + column_default: null, + is_primary_key: true, + comment: null, + extra: null, + }; + }, + createEmptyColumn(name = "column_1") { + return { + name, + data_type: defaultNewColumnDataType(databaseType, dataTypeOptions), + is_nullable: true, + column_default: null, + is_primary_key: false, + comment: null, + extra: null, + }; + }, + }; +} diff --git a/apps/desktop/src/lib/diagram/draft-storage.ts b/apps/desktop/src/lib/diagram/draft-storage.ts new file mode 100644 index 000000000..b6ce9fcee --- /dev/null +++ b/apps/desktop/src/lib/diagram/draft-storage.ts @@ -0,0 +1,185 @@ +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +import type { ColumnInfo } from "@/types/database"; +import type { DiagramPosition, DiagramTable } from "./erDiagram"; +import { isLiveTable, needsDiagramSync } from "./erDiagram"; +import type { DiagramLayer } from "@/types/diagram"; + +export interface LiveTablePatch { + tableName: string; + pendingColumns: ColumnInfo[]; + droppedColumnNames?: string[]; + pendingDrop?: boolean; +} + +function storageKey(kind: "draft-tables" | "layers" | "positions" | "live-patches", connectionId: string, database: string, schema: string): string { + return ["dbx", "diagram", kind, "v1", connectionId, database, schema].join(":"); +} + +function isValidColumn(value: unknown): value is ColumnInfo { + if (!value || typeof value !== "object") return false; + const col = value as Partial; + return typeof col.name === "string" && typeof col.data_type === "string" && typeof col.is_nullable === "boolean"; +} + +function sanitizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.length > 0); +} + +function sanitizePositions(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [name, value] of Object.entries(raw as Record)) { + if (!name || typeof value !== "object" || value == null || Array.isArray(value)) continue; + const x = (value as { x?: unknown }).x; + const y = (value as { y?: unknown }).y; + if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) continue; + out[name] = { x, y }; + } + return out; +} + +function parseJson(raw: string | null, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export function loadDraftTables(connectionId: string, database: string, schema: string): DiagramTable[] { + if (!connectionId || !database) return []; + const key = storageKey("draft-tables", connectionId, database, schema); + const list = parseJson(safeLocalStorageGet(key), []); + return list + .filter((t) => t && typeof t.name === "string" && Array.isArray(t.columns)) + .map((t) => ({ + ...t, + foreignKeys: t.foreignKeys || [], + origin: "draft" as const, + syncStatus: t.syncStatus === "error" ? "error" : "pending", + })); +} + +export function saveDraftTables(tables: DiagramTable[], connectionId: string, database: string, schema: string): void { + if (!connectionId || !database) return; + const key = storageKey("draft-tables", connectionId, database, schema); + const drafts = tables.filter((t) => (t.origin ?? "live") === "draft"); + safeLocalStorageSet(key, JSON.stringify(drafts)); +} + +export function loadPersistedLayers(connectionId: string, database: string, schema: string): DiagramLayer[] { + if (!connectionId || !database) return []; + const key = storageKey("layers", connectionId, database, schema); + return parseJson(safeLocalStorageGet(key), []); +} + +export function savePersistedLayers(layers: DiagramLayer[], connectionId: string, database: string, schema: string): void { + if (!connectionId || !database) return; + const key = storageKey("layers", connectionId, database, schema); + safeLocalStorageSet(key, JSON.stringify(layers)); +} + +export function loadPersistedPositions(connectionId: string, database: string, schema: string): Record { + if (!connectionId || !database) return {}; + const key = storageKey("positions", connectionId, database, schema); + return sanitizePositions(parseJson(safeLocalStorageGet(key), {})); +} + +export function savePersistedPositions(positions: Record, connectionId: string, database: string, schema: string): void { + if (!connectionId || !database) return; + const key = storageKey("positions", connectionId, database, schema); + safeLocalStorageSet(key, JSON.stringify(sanitizePositions(positions))); +} + +/** True when at least one known table name has a finite saved position. */ +export function hasUsablePersistedPositions(saved: Record, tableNames: string[]): boolean { + for (const name of tableNames) { + const pos = saved[name]; + if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) return true; + } + return false; +} + +export function loadLiveTablePatches(connectionId: string, database: string, schema: string): LiveTablePatch[] { + if (!connectionId || !database) return []; + const key = storageKey("live-patches", connectionId, database, schema); + const list = parseJson(safeLocalStorageGet(key), []); + if (!Array.isArray(list)) return []; + return list + .filter((item): item is LiveTablePatch => { + if (!item || typeof item !== "object") return false; + const patch = item as Partial; + if (typeof patch.tableName !== "string") return false; + const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns : []; + if (!pendingColumns.every(isValidColumn)) return false; + const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames); + const pendingDrop = patch.pendingDrop === true; + return pendingColumns.length > 0 || droppedColumnNames.length > 0 || pendingDrop; + }) + .map((patch) => { + const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns.filter(isValidColumn) : []; + const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames); + const next: LiveTablePatch = { + tableName: patch.tableName, + pendingColumns: pendingColumns.map((col) => ({ ...col })), + }; + if (droppedColumnNames.length) next.droppedColumnNames = droppedColumnNames; + if (patch.pendingDrop === true) next.pendingDrop = true; + return next; + }); +} + +export function saveLiveTablePatches(tables: DiagramTable[], connectionId: string, database: string, schema: string): void { + if (!connectionId || !database) return; + const key = storageKey("live-patches", connectionId, database, schema); + const patches: LiveTablePatch[] = []; + for (const table of tables) { + if (!isLiveTable(table) || !needsDiagramSync(table)) continue; + const pendingNames = new Set(table.pendingColumnNames ?? []); + const pendingColumns = table.columns.filter((col) => pendingNames.has(col.name)); + const droppedColumnNames = [...(table.droppedColumnNames ?? [])]; + if (pendingColumns.length === 0 && droppedColumnNames.length === 0 && !table.pendingDrop) continue; + const patch: LiveTablePatch = { + tableName: table.name, + pendingColumns: pendingColumns.map((col) => ({ ...col })), + }; + if (droppedColumnNames.length) patch.droppedColumnNames = droppedColumnNames; + if (table.pendingDrop) patch.pendingDrop = true; + patches.push(patch); + } + safeLocalStorageSet(key, JSON.stringify(patches)); +} + +/** Merge saved pending adds/drops onto live tables loaded from DB metadata. */ +export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[]): DiagramTable[] { + if (patches.length === 0) return tables; + const byName = new Map(patches.map((p) => [p.tableName, p])); + return tables.map((table) => { + if (!isLiveTable(table)) return table; + const patch = byName.get(table.name); + if (!patch) return table; + + const existing = new Set(table.columns.map((c) => c.name.toLowerCase())); + const pendingColumns = patch.pendingColumns.filter((col) => !existing.has(col.name.toLowerCase())); + const columnNames = new Set([...existing, ...pendingColumns.map((col) => col.name.toLowerCase())]); + const droppedColumnNames = (patch.droppedColumnNames ?? []).filter((name) => columnNames.has(name.toLowerCase())); + + let next: DiagramTable = table; + if (pendingColumns.length > 0) { + next = { + ...next, + columns: [...next.columns, ...pendingColumns.map((col) => ({ ...col }))], + pendingColumnNames: pendingColumns.map((col) => col.name), + }; + } + if (droppedColumnNames.length > 0) { + next = { ...next, droppedColumnNames }; + } + if (patch.pendingDrop) { + next = { ...next, pendingDrop: true }; + } + return next; + }); +} diff --git a/apps/desktop/src/lib/diagram/draft-table.ts b/apps/desktop/src/lib/diagram/draft-table.ts new file mode 100644 index 000000000..7ccbf275a --- /dev/null +++ b/apps/desktop/src/lib/diagram/draft-table.ts @@ -0,0 +1,177 @@ +import type { ColumnInfo, DatabaseType } from "@/types/database"; +import type { DiagramTable } from "./erDiagram"; +import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isPendingColumn } from "./erDiagram"; +import type { BuildTableStructureChangeSqlOptions, EditableStructureColumn, EditableStructureIndex } from "@/lib/table/tableStructureEditorSql"; +import { generateUniqueIndexName } from "@/lib/table/tableStructureEditorState"; +import { resolveDiagramDialectAdapter } from "./diagram-dialect-adapter"; + +export function createDefaultIdColumn(databaseType?: DatabaseType): ColumnInfo { + return resolveDiagramDialectAdapter(databaseType).createDefaultIdColumn(); +} + +export function createEmptyColumn(name = "column_1", databaseType?: DatabaseType): ColumnInfo { + return resolveDiagramDialectAdapter(databaseType).createEmptyColumn(name); +} + +export function createDraftTable(name: string, options?: { withDefaultId?: boolean; databaseType?: DatabaseType }): DiagramTable { + const withDefaultId = options?.withDefaultId !== false; + const adapter = resolveDiagramDialectAdapter(options?.databaseType); + return { + name: name.trim(), + columns: withDefaultId ? [adapter.createDefaultIdColumn()] : [], + foreignKeys: [], + indexes: [], + origin: "draft", + syncStatus: "pending", + }; +} + +export function createDraftIndex(tableName: string, columns: string[], existingIndexes: EditableStructureIndex[] = []): EditableStructureIndex { + const existingNames = existingIndexes.map((index) => index.name); + const name = generateUniqueIndexName(tableName, columns, existingNames) || `idx_${tableName}`; + return { + id: `idx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + columns: [...columns], + isUnique: false, + isPrimary: false, + filter: "", + indexType: "", + includedColumns: [], + comment: "", + markedForDrop: false, + }; +} + +export function columnToEditable(column: ColumnInfo, index: number): EditableStructureColumn { + return { + id: `col-${index}-${column.name}`, + name: column.name, + dataType: column.data_type, + enumValues: column.enum_values ?? undefined, + isNullable: column.is_nullable, + defaultValue: column.column_default ?? "", + comment: column.comment ?? "", + isPrimaryKey: column.is_primary_key, + extra: {}, + characterSet: column.character_set ?? undefined, + collation: column.collation ?? undefined, + markedForDrop: false, + }; +} + +export function draftTableToCreateSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions { + return { + databaseType, + schema: schema || undefined, + tableName: table.name, + columns: table.columns.map(columnToEditable), + indexes: editableStructureIndexes(table).filter((index) => !index.markedForDrop), + foreignKeys: [], + triggers: [], + }; +} + +/** Live table pending adds/drops → ALTER ADD / DROP COLUMN. */ +export function liveTableToAlterSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions { + const pending = new Set(table.pendingColumnNames ?? []); + const dropped = new Set(table.droppedColumnNames ?? []); + return { + databaseType, + schema: schema || undefined, + tableName: table.name, + columns: table.columns.map((column, index) => { + const editable = columnToEditable(column, index); + if (pending.has(column.name)) { + return editable; + } + return { + ...editable, + original: { ...column }, + originalPosition: index + 1, + markedForDrop: dropped.has(column.name), + }; + }), + indexes: [], + foreignKeys: [], + triggers: [], + }; +} + +export function nextUniqueColumnName(columns: ColumnInfo[], base = "column"): string { + const names = new Set(columns.map((c) => c.name.toLowerCase())); + let i = 1; + while (names.has(`${base}_${i}`.toLowerCase())) i += 1; + return `${base}_${i}`; +} + +export function validateDraftTable(table: DiagramTable): string[] { + const errors: string[] = []; + if (!table.name.trim()) errors.push("Table name is required"); + if (table.columns.length === 0) errors.push(`Table "${table.name}" needs at least one column`); + const seen = new Set(); + for (const col of table.columns) { + if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty column name`); + const key = col.name.toLowerCase(); + if (seen.has(key)) errors.push(`Table "${table.name}" has duplicate column "${col.name}"`); + seen.add(key); + if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`); + } + const columnNames = new Set(table.columns.map((c) => c.name.toLowerCase())); + for (const index of editableStructureIndexes(table)) { + if (index.markedForDrop) continue; + if (!index.name.trim()) errors.push(`Table "${table.name}" has an index with an empty name`); + if (index.columns.length === 0) errors.push(`Index "${index.name || "(unnamed)"}" on "${table.name}" needs at least one column`); + for (const col of index.columns) { + if (!columnNames.has(col.toLowerCase())) { + errors.push(`Index "${index.name}" references missing column "${col}"`); + } + } + } + return errors; +} + +/** Validate pending ADD columns and DROP column marks on a live table before ALTER sync. */ +export function validateLivePendingColumns(table: DiagramTable): string[] { + const errors: string[] = []; + if (!hasPendingColumns(table) && !hasDroppedColumns(table)) return errors; + const pendingNames = table.pendingColumnNames ?? []; + const existingNames = new Set(table.columns.filter((col) => !isPendingColumn(table, col.name)).map((col) => col.name.toLowerCase())); + const seenPending = new Set(); + for (const name of pendingNames) { + const col = table.columns.find((c) => c.name === name); + if (!col) { + errors.push(`Table "${table.name}" pending column "${name}" is missing`); + continue; + } + if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty pending column name`); + const key = col.name.toLowerCase(); + if (seenPending.has(key)) errors.push(`Table "${table.name}" has duplicate pending column "${col.name}"`); + seenPending.add(key); + if (existingNames.has(key)) { + errors.push(`Table "${table.name}" pending column conflicts with existing "${col.name}"`); + } + if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`); + } + const seenDropped = new Set(); + for (const name of table.droppedColumnNames ?? []) { + const key = name.toLowerCase(); + if (seenDropped.has(key)) { + errors.push(`Table "${table.name}" has duplicate dropped column "${name}"`); + continue; + } + seenDropped.add(key); + if (isPendingColumn(table, name)) { + errors.push(`Table "${table.name}" cannot drop pending column "${name}"`); + continue; + } + if (!table.columns.some((col) => col.name === name)) { + errors.push(`Table "${table.name}" dropped column "${name}" is missing`); + } + } + return errors; +} + +export function hasLiveColumnChanges(table: DiagramTable): boolean { + return hasPendingColumns(table) || hasDroppedColumns(table); +} diff --git a/apps/desktop/src/lib/diagram/edge-obstacle-router.ts b/apps/desktop/src/lib/diagram/edge-obstacle-router.ts new file mode 100644 index 000000000..93bc1f39d --- /dev/null +++ b/apps/desktop/src/lib/diagram/edge-obstacle-router.ts @@ -0,0 +1,356 @@ +import { Position } from "@vue-flow/core"; +import { EDGE_ROUTE_OFFSET } from "./diagram-constants"; + +export type Point = { x: number; y: number }; + +export type ObstacleRect = { + id: string; + x: number; + y: number; + width: number; + height: number; + kind: "table" | "layer"; + /** For layers: tables contained (used to skip same-layer fill) */ + tableNames?: string[]; +}; + +export type RouteInput = { + source: Point; + target: Point; + sourcePosition: Position; + targetPosition: Position; + obstacles: ObstacleRect[]; + /** Endpoint table ids to ignore as obstacles */ + endpointIds: [string, string]; + offset?: number; +}; + +const AXIS_EPS = 0.5; + +function nearlyEqual(a: number, b: number): boolean { + return Math.abs(a - b) <= AXIS_EPS; +} + +function inflate(rect: ObstacleRect, pad: number): ObstacleRect { + return { + ...rect, + x: rect.x - pad, + y: rect.y - pad, + width: rect.width + pad * 2, + height: rect.height + pad * 2, + }; +} + +function segmentIntersectsRect(a: Point, b: Point, rect: ObstacleRect): boolean { + const minX = Math.min(a.x, b.x); + const maxX = Math.max(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxY = Math.max(a.y, b.y); + + const rx2 = rect.x + rect.width; + const ry2 = rect.y + rect.height; + + if (maxX < rect.x || minX > rx2 || maxY < rect.y || minY > ry2) return false; + + if (nearlyEqual(a.x, b.x)) { + const x = a.x; + return x >= rect.x && x <= rx2 && maxY >= rect.y && minY <= ry2; + } + if (nearlyEqual(a.y, b.y)) { + const y = a.y; + return y >= rect.y && y <= ry2 && maxX >= rect.x && minX <= rx2; + } + return true; +} + +export function pathHitsObstacles(points: Point[], obstacles: ObstacleRect[]): boolean { + for (let i = 0; i < points.length - 1; i++) { + for (const rect of obstacles) { + if (segmentIntersectsRect(points[i], points[i + 1], rect)) return true; + } + } + return false; +} + +function relevantObstacles(input: RouteInput, pad: number): ObstacleRect[] { + const [srcId, tgtId] = input.endpointIds; + return input.obstacles + .filter((o) => { + if (o.kind === "table") { + return o.id !== srcId && o.id !== tgtId; + } + const names = o.tableNames || []; + if (names.includes(srcId) || names.includes(tgtId)) return false; + return true; + }) + .map((o) => inflate(o, pad)); +} + +/** Table rects for the edge endpoints (source/target), optionally inflated. */ +export function endpointRectsFromObstacles(obstacles: ObstacleRect[], endpointIds: [string, string], pad = 0): ObstacleRect[] { + const [srcId, tgtId] = endpointIds; + return obstacles.filter((o) => o.kind === "table" && (o.id === srcId || o.id === tgtId)).map((o) => (pad > 0 ? inflate(o, pad) : { ...o })); +} + +export function stubOut(point: Point, position: Position, offset: number): Point { + if (position === Position.Left) return { x: point.x - offset, y: point.y }; + if (position === Position.Top) return { x: point.x, y: point.y - offset }; + if (position === Position.Bottom) return { x: point.x, y: point.y + offset }; + return { x: point.x + offset, y: point.y }; +} + +/** Point just outside the target handle before the final inbound stub. */ +export function stubIn(point: Point, position: Position, offset: number): Point { + if (position === Position.Right) return { x: point.x + offset, y: point.y }; + if (position === Position.Top) return { x: point.x, y: point.y - offset }; + if (position === Position.Bottom) return { x: point.x, y: point.y + offset }; + return { x: point.x - offset, y: point.y }; +} + +function pointOnRectBorder(p: Point, rect: ObstacleRect): boolean { + const rx2 = rect.x + rect.width; + const ry2 = rect.y + rect.height; + const onVertical = (nearlyEqual(p.x, rect.x) || nearlyEqual(p.x, rx2)) && p.y >= rect.y - AXIS_EPS && p.y <= ry2 + AXIS_EPS; + const onHorizontal = (nearlyEqual(p.y, rect.y) || nearlyEqual(p.y, ry2)) && p.x >= rect.x - AXIS_EPS && p.x <= rx2 + AXIS_EPS; + return onVertical || onHorizontal; +} + +/** + * True when a segment runs along/through an endpoint table beyond a short handle stub. + * Short outward/inward stubs (length ≤ stubLen) that touch the border are allowed. + */ +export function pathSkimsEndpoints(points: Point[], endpointRects: ObstacleRect[], stubLen = EDGE_ROUTE_OFFSET): boolean { + if (endpointRects.length === 0 || points.length < 2) return false; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + for (const rect of endpointRects) { + if (!segmentIntersectsRect(a, b, rect)) continue; + const stubOk = segLen <= stubLen + AXIS_EPS && (pointOnRectBorder(a, rect) || pointOnRectBorder(b, rect)); + if (stubOk) continue; + return true; + } + } + return false; +} + +/** + * Build candidate orthogonal polylines with exit/entry stubs; return the shortest that + * clears non-endpoint obstacles and does not skim endpoint tables on middle segments. + */ +export function routeOrthogonalAroundObstacles(input: RouteInput): Point[] | null { + const offset = input.offset ?? EDGE_ROUTE_OFFSET; + const obstacles = relevantObstacles(input, 6); + const endpoints = endpointRectsFromObstacles(input.obstacles, input.endpointIds, 0); + const { source: s, target: t } = input; + const so = stubOut(s, input.sourcePosition, offset); + const si = stubIn(t, input.targetPosition, offset); + + // Corridors from stubOut → stubIn (handles attached outside) + const corridors: Point[][] = [ + [so, { x: si.x, y: so.y }, si], + [so, { x: so.x, y: si.y }, si], + [so, { x: so.x + offset, y: so.y }, { x: so.x + offset, y: si.y }, si], + [so, { x: so.x - offset, y: so.y }, { x: so.x - offset, y: si.y }, si], + [so, { x: so.x, y: so.y + offset }, { x: si.x, y: so.y + offset }, si], + [so, { x: so.x, y: so.y - offset }, { x: si.x, y: so.y - offset }, si], + [so, { x: si.x + offset, y: so.y }, { x: si.x + offset, y: si.y }, si], + [so, { x: si.x - offset, y: so.y }, { x: si.x - offset, y: si.y }, si], + [so, { x: so.x, y: Math.min(so.y, si.y) - offset }, { x: si.x, y: Math.min(so.y, si.y) - offset }, si], + [so, { x: so.x, y: Math.max(so.y, si.y) + offset }, { x: si.x, y: Math.max(so.y, si.y) + offset }, si], + [so, { x: Math.min(so.x, si.x) - offset, y: so.y }, { x: Math.min(so.x, si.x) - offset, y: si.y }, si], + [so, { x: Math.max(so.x, si.x) + offset, y: so.y }, { x: Math.max(so.x, si.x) + offset, y: si.y }, si], + ]; + + let best: Point[] | null = null; + let bestLen = Infinity; + for (const corridor of corridors) { + const cleaned = collapseColinearPoints(dedupePoints([s, ...corridor, t])); + if (cleaned.length < 2) continue; + if (pathHitsObstacles(cleaned, obstacles)) continue; + if (pathSkimsEndpoints(cleaned, endpoints, offset)) continue; + const len = polylineLength(cleaned); + if (len < bestLen) { + bestLen = len; + best = cleaned; + } + } + + return best; +} + +export function polylineLength(points: Point[]): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + } + return total; +} + +export function dedupePoints(points: Point[]): Point[] { + const out: Point[] = []; + for (const p of points) { + const last = out[out.length - 1]; + if (!last || !nearlyEqual(last.x, p.x) || !nearlyEqual(last.y, p.y)) { + out.push({ x: p.x, y: p.y }); + } + } + return out; +} + +/** Collapse consecutive collinear points on axis-aligned polylines. */ +export function collapseColinearPoints(points: Point[]): Point[] { + if (points.length <= 2) return points.map((p) => ({ ...p })); + const out: Point[] = [{ ...points[0] }]; + for (let i = 1; i < points.length - 1; i++) { + const prev = out[out.length - 1]; + const cur = points[i]; + const next = points[i + 1]; + const colinearH = nearlyEqual(prev.y, cur.y) && nearlyEqual(cur.y, next.y); + const colinearV = nearlyEqual(prev.x, cur.x) && nearlyEqual(cur.x, next.x); + if (colinearH || colinearV) continue; + out.push({ ...cur }); + } + out.push({ ...points[points.length - 1] }); + return dedupePoints(out); +} + +function isOrthogonalPolyline(points: Point[]): boolean { + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + if (!nearlyEqual(a.x, b.x) && !nearlyEqual(a.y, b.y)) return false; + } + return true; +} + +/** Orthogonal elbow from a → b (try both corners; prefer shorter). */ +function orthogonalConnect(a: Point, b: Point): Point[] { + if (nearlyEqual(a.x, b.x) || nearlyEqual(a.y, b.y)) return [a, b]; + const viaH: Point[] = [a, { x: b.x, y: a.y }, b]; + const viaV: Point[] = [a, { x: a.x, y: b.y }, b]; + const len = (pts: Point[]) => pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + Math.hypot(p.x - pts[i - 1].x, p.y - pts[i - 1].y)), 0); + return len(viaH) <= len(viaV) ? viaH : viaV; +} + +/** + * Infer Vue Flow handle ids from ELK waypoint exit/entry directions. + */ +export function handlesFromWaypoints(waypoints: Point[]): { sourceHandle: string; targetHandle: string } | null { + if (waypoints.length < 2) return null; + const a = waypoints[0]; + const b = waypoints[1]; + const c = waypoints[waypoints.length - 2]; + const d = waypoints[waypoints.length - 1]; + + const outDx = b.x - a.x; + const outDy = b.y - a.y; + let sourceHandle: string; + if (Math.abs(outDx) >= Math.abs(outDy)) { + sourceHandle = outDx >= 0 ? "right" : "left"; + } else { + sourceHandle = outDy >= 0 ? "bottom" : "top"; + } + + const inDx = d.x - c.x; + const inDy = d.y - c.y; + let targetHandle: string; + if (Math.abs(inDx) >= Math.abs(inDy)) { + // Arriving with +dx means coming from the left → hit left side + targetHandle = inDx >= 0 ? "left-target" : "right-target"; + } else { + targetHandle = inDy >= 0 ? "top-target" : "bottom-target"; + } + + return { sourceHandle, targetHandle }; +} + +export type AlignWaypointsOptions = { + obstacles?: ObstacleRect[]; + endpointIds?: [string, string]; +}; + +/** + * Attach live Vue Flow handle endpoints to ELK interior bends with orthogonal elbows. + * Does NOT translate the whole polyline by source delta (that caused diagonals / V shapes). + * Returns null when the result is unusable (caller should fall back to obstacle router). + */ +export function alignWaypointsToEndpoints(waypoints: Point[], sourceX: number, sourceY: number, targetX: number, targetY: number, options?: AlignWaypointsOptions): Point[] | null { + if (waypoints.length < 2) return null; + + const source = { x: sourceX, y: sourceY }; + const target = { x: targetX, y: targetY }; + const interior = waypoints.slice(1, -1); + + let merged: Point[]; + if (interior.length === 0) { + merged = dedupePoints(orthogonalConnect(source, target)); + } else { + const head = orthogonalConnect(source, interior[0]); + const tail = orthogonalConnect(interior[interior.length - 1], target); + merged = dedupePoints([...head.slice(0, -1), ...interior, ...tail.slice(1)]); + } + + if (merged.length < 2 || !isOrthogonalPolyline(merged)) return null; + + const cleaned = collapseColinearPoints(merged); + + if (options?.obstacles?.length && options.endpointIds) { + const obstacles = relevantObstacles( + { + source, + target, + sourcePosition: Position.Right, + targetPosition: Position.Left, + obstacles: options.obstacles, + endpointIds: options.endpointIds, + }, + 4, + ); + if (pathHitsObstacles(cleaned, obstacles)) return null; + const endpoints = endpointRectsFromObstacles(options.obstacles, options.endpointIds, 0); + if (pathSkimsEndpoints(cleaned, endpoints, EDGE_ROUTE_OFFSET)) return null; + } + + return cleaned; +} + +export function pointsToSvgPath(points: Point[]): string { + if (points.length === 0) return ""; + return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" "); +} + +/** + * Point at fraction `t` (0..1) along the polyline by arc length. + * Out-of-range t is clamped. + */ +export function pointAlongPolyline(points: Point[], t: number): Point { + if (points.length === 0) return { x: 0, y: 0 }; + if (points.length === 1) return { ...points[0] }; + const clamped = Math.min(1, Math.max(0, t)); + let total = 0; + const segs: number[] = []; + for (let i = 0; i < points.length - 1; i++) { + const d = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y); + segs.push(d); + total += d; + } + if (total === 0) return { ...points[0] }; + let remain = total * clamped; + for (let i = 0; i < segs.length; i++) { + if (remain <= segs[i]) { + const ratio = segs[i] === 0 ? 0 : remain / segs[i]; + return { + x: points[i].x + (points[i + 1].x - points[i].x) * ratio, + y: points[i].y + (points[i + 1].y - points[i].y) * ratio, + }; + } + remain -= segs[i]; + } + return { ...points[points.length - 1] }; +} + +export function midpointAlongPolyline(points: Point[]): Point { + return pointAlongPolyline(points, 0.5); +} diff --git a/apps/desktop/src/lib/diagram/elk-layout.ts b/apps/desktop/src/lib/diagram/elk-layout.ts new file mode 100644 index 000000000..26659ffd7 --- /dev/null +++ b/apps/desktop/src/lib/diagram/elk-layout.ts @@ -0,0 +1,407 @@ +import ELK from "elkjs/lib/elk.bundled.js"; +import type { LayoutOptions, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram"; +import { CARD_WIDTH, COLUMN_ROW_HEIGHT, CARD_HEADER_HEIGHT, CARD_BOTTOM_PADDING, LAYER_PADDING, LAYER_HEADER_HEIGHT } from "./diagram-constants"; +import { handlesFromWaypoints, type Point } from "./edge-obstacle-router"; + +const elk = new ELK(); + +export interface ElkNode { + id: string; + width: number; + height: number; + x?: number; + y?: number; + children?: ElkNode[]; + labels?: { text: string }[]; + layoutOptions?: Record; +} + +export interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; + sections?: { + id?: string; + startPoint: { x: number; y: number }; + endPoint: { x: number; y: number }; + bendPoints?: { x: number; y: number }[]; + }[]; +} + +export interface ElkGraph { + id: string; + children: ElkNode[]; + edges: ElkEdge[]; + layoutOptions?: Record; +} + +export interface LayerLayoutInfo { + layerId: string; + layerName: string; + color: string; + x: number; + y: number; + width: number; + height: number; +} + +function calculateTableHeight(tableColumns: unknown[]): number { + return CARD_HEADER_HEIGHT + tableColumns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING; +} + +export async function computeLayout(nodes: DiagramNode[], edges: DiagramEdge[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> { + const elkGraph = buildElkGraph(nodes, edges); + const elkOptions = buildElkOptions(options); + + const result = (await elk.layout({ + ...elkGraph, + layoutOptions: elkOptions, + } as Parameters[0])) as ElkGraph; + + return extractLayoutResult(result, nodes, edges); +} + +/** + * Layout with layers. + * - auto layers: nested in ELK hierarchy (children get relative coords on write-back) + * - free layers: tables keep existing absolute positions; layer box is size-to-fit after + * - unassigned tables: laid out at root with auto layers + */ +export async function computeLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> { + const visibleLayers = layers.filter((l) => l.visible && l.tableNames.length > 0); + const autoLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "auto"); + const freeLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "free"); + + if (visibleLayers.length === 0) { + const result = await computeLayout(nodes, edges, options); + return { ...result, layerLayouts: [] }; + } + + const freeTableIds = new Set(freeLayers.flatMap((l) => l.tableNames)); + const nodesForElk = nodes.filter((n) => !freeTableIds.has(n.id)); + const edgesForElk = edges.filter((e) => !freeTableIds.has(e.source) && !freeTableIds.has(e.target)); + + const positionMap = new Map(); + for (const node of nodes) { + positionMap.set(node.id, { ...node.position }); + } + + const layerLayouts: LayerLayoutInfo[] = []; + /** Edges that went through ELK, keyed by id (may include waypoints). */ + const elkEdgeById = new Map(); + + if (nodesForElk.length > 0) { + const elkGraph = buildHierarchicalElkGraph(nodesForElk, edgesForElk, autoLayers); + const elkOptions = buildElkOptions(options); + const result = (await elk.layout({ + ...elkGraph, + layoutOptions: elkOptions, + } as Parameters[0])) as ElkGraph; + + const extracted = extractHierarchicalLayoutResult(result, nodesForElk, edgesForElk, autoLayers); + for (const node of extracted.nodes) { + positionMap.set(node.id, { ...node.position }); + } + layerLayouts.push(...extracted.layerLayouts); + for (const edge of extracted.edges) { + elkEdgeById.set(edge.id, edge); + } + } + + // Free layers: keep table positions; compute size-to-fit boxes + for (const layer of freeLayers) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let hasAny = false; + + for (const tableName of layer.tableNames) { + const node = nodes.find((n) => n.id === tableName); + const pos = positionMap.get(tableName) || node?.position; + if (!pos) continue; + hasAny = true; + const height = calculateTableHeight(node?.data?.table?.columns || []); + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + CARD_WIDTH); + maxY = Math.max(maxY, pos.y + height); + } + + if (!hasAny) continue; + + const x = minX - LAYER_PADDING; + const y = minY - LAYER_HEADER_HEIGHT - LAYER_PADDING; + const width = Math.max(160, maxX - minX + LAYER_PADDING * 2); + const height = Math.max(100, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_PADDING * 2); + + layerLayouts.push({ + layerId: layer.id, + layerName: layer.name, + color: layer.color, + x, + y, + width, + height, + }); + } + + const newNodes = nodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) || node.position, + })); + + const newEdges = edges.map((edge) => { + const fromElk = elkEdgeById.get(edge.id); + if (!fromElk) return edge; + return { + ...edge, + waypoints: fromElk.waypoints, + sourceHandle: fromElk.sourceHandle, + targetHandle: fromElk.targetHandle, + }; + }); + + return { + nodes: newNodes, + edges: newEdges, + layerLayouts, + }; +} + +function buildElkGraph(nodes: DiagramNode[], edges: DiagramEdge[]): ElkGraph { + const elkNodes: ElkNode[] = nodes.map((node) => ({ + id: node.id, + width: CARD_WIDTH, + height: calculateTableHeight(node.data?.table?.columns || []), + })); + + const elkEdges: ElkEdge[] = edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })); + + return { + id: "diagram", + children: elkNodes, + edges: elkEdges, + }; +} + +function buildHierarchicalElkGraph(nodes: DiagramNode[], edges: DiagramEdge[], autoLayers: DiagramLayer[]): ElkGraph { + const elkLayers: ElkNode[] = []; + const tableIdSet = new Set(); + + for (const layer of autoLayers) { + const layerTables = nodes.filter((n) => layer.tableNames.includes(n.id)); + if (layerTables.length === 0) continue; + + const elkTableNodes = layerTables.map((tableNode) => ({ + id: tableNode.id, + width: CARD_WIDTH, + height: calculateTableHeight(tableNode.data?.table?.columns || []), + })); + + layerTables.forEach((n) => tableIdSet.add(n.id)); + + elkLayers.push({ + id: layer.id, + width: 0, + height: 0, + children: elkTableNodes, + labels: [{ text: layer.name }], + layoutOptions: { + "elk.padding": `[top=${LAYER_HEADER_HEIGHT + LAYER_PADDING},left=${LAYER_PADDING},bottom=${LAYER_PADDING},right=${LAYER_PADDING}]`, + }, + }); + } + + const unassignedNodes = nodes.filter((n) => !tableIdSet.has(n.id)); + const unassignedElkNodes = unassignedNodes.map((node) => ({ + id: node.id, + width: CARD_WIDTH, + height: calculateTableHeight(node.data?.table?.columns || []), + })); + + return { + id: "diagram", + children: [...elkLayers, ...unassignedElkNodes], + edges: edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; +} + +function buildElkOptions(options: LayoutOptions): Record { + const directionMap: Record = { + LR: "RIGHT", + TB: "DOWN", + RL: "LEFT", + BT: "UP", + }; + + return { + "elk.algorithm": "layered", + "elk.direction": directionMap[options.direction || "LR"], + "elk.layered.edgeRouting": "ORTHOGONAL", + "elk.layered.nodePlacement": "BRANDES_KOEPF", + "elk.layered.crossingMinimization": "LAYER_SWEEP", + "elk.layered.layering.strategy": "NETWORK_SIMPLEX", + "elk.layered.separateConnectedComponents": "true", + "elk.spacing.nodeNode": "60", + "elk.spacing.layerLayer": "80", + "elk.padding": `${LAYER_PADDING}`, + "elk.layered.edgeSpacing": "20", + "elk.layered.nodeSpacing": "50", + "elk.layered.bendPointSpacing": "10", + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + }; +} + +function extractLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[]): { nodes: DiagramNode[]; edges: DiagramEdge[] } { + const nodePositionMap = new Map(); + + for (const child of result.children || []) { + if (child.x !== undefined && child.y !== undefined) { + nodePositionMap.set(child.id, { x: child.x, y: child.y }); + } + } + + const edgeBendPoints = new Map(); + for (const edge of result.edges || []) { + if (edge.sections) { + const points: { x: number; y: number }[][] = []; + for (const section of edge.sections) { + const sectionPoints: { x: number; y: number }[] = []; + sectionPoints.push(section.startPoint); + if (section.bendPoints) { + sectionPoints.push(...section.bendPoints); + } + sectionPoints.push(section.endPoint); + points.push(sectionPoints); + } + edgeBendPoints.set(edge.id, points); + } + } + + const newNodes = originalNodes.map((node) => { + const position = nodePositionMap.get(node.id); + return { + ...node, + position: position || node.position, + }; + }); + + const newEdges = originalEdges.map((edge) => { + const sections = edgeBendPoints.get(edge.id); + const waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined; + const handles = waypoints ? handlesFromWaypoints(waypoints) : null; + return { + ...edge, + waypoints, + sourceHandle: handles?.sourceHandle, + targetHandle: handles?.targetHandle, + }; + }); + + return { + nodes: newNodes, + edges: newEdges, + }; +} + +/** + * Write back ABSOLUTE positions for tables (parent absolute + child relative from ELK). + * Adapter converts to relative for Vue Flow parentNode. + */ +function extractHierarchicalLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[], layers: DiagramLayer[]): { nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] } { + const absolutePositions = new Map(); + const layerLayouts: LayerLayoutInfo[] = []; + + for (const child of result.children || []) { + const childX = child.x || 0; + const childY = child.y || 0; + const layer = layers.find((l) => l.id === child.id); + + if (layer) { + layerLayouts.push({ + layerId: layer.id, + layerName: layer.name, + color: layer.color, + x: childX, + y: childY, + width: child.width || 320, + height: child.height || 200, + }); + + // Children coords from ELK are relative to the layer node + for (const nested of child.children || []) { + absolutePositions.set(nested.id, { + x: childX + (nested.x || 0), + y: childY + (nested.y || 0), + }); + } + } else { + absolutePositions.set(child.id, { x: childX, y: childY }); + } + } + + const edgeBendPoints = new Map(); + for (const edge of result.edges || []) { + if (edge.sections) { + const points: { x: number; y: number }[][] = []; + for (const section of edge.sections) { + const sectionPoints: { x: number; y: number }[] = []; + sectionPoints.push(section.startPoint); + if (section.bendPoints) { + sectionPoints.push(...section.bendPoints); + } + sectionPoints.push(section.endPoint); + points.push(sectionPoints); + } + edgeBendPoints.set(edge.id, points); + } + } + + const newNodes = originalNodes.map((node) => { + const position = absolutePositions.get(node.id); + return { + ...node, + position: position || node.position, + }; + }); + + const newEdges = originalEdges.map((edge) => { + const sections = edgeBendPoints.get(edge.id); + let waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined; + + if (waypoints?.length) { + const srcLayer = layers.find((l) => l.tableNames.includes(edge.source)); + const tgtLayer = layers.find((l) => l.tableNames.includes(edge.target)); + // Same-layer edges: ELK section coords are relative to the layer compound node + if (srcLayer && tgtLayer && srcLayer.id === tgtLayer.id) { + const layout = layerLayouts.find((l) => l.layerId === srcLayer.id); + if (layout) { + waypoints = waypoints.map((p) => ({ x: p.x + layout.x, y: p.y + layout.y })); + } + } + } + + const handles = waypoints ? handlesFromWaypoints(waypoints) : null; + return { + ...edge, + waypoints, + sourceHandle: handles?.sourceHandle, + targetHandle: handles?.targetHandle, + }; + }); + + return { + nodes: newNodes, + edges: newEdges, + layerLayouts, + }; +} diff --git a/apps/desktop/src/lib/diagram/engineeringDiagram.ts b/apps/desktop/src/lib/diagram/engineeringDiagram.ts index 3c31e1aa2..d99812f18 100644 --- a/apps/desktop/src/lib/diagram/engineeringDiagram.ts +++ b/apps/desktop/src/lib/diagram/engineeringDiagram.ts @@ -65,7 +65,9 @@ export interface EngineeringRelationshipNode { id: string; label: string; sourceTable: string; + sourceColumn: string; targetTable: string; + targetColumn: string; sourceCardinality: "1" | "N"; targetCardinality: "1" | "N"; x: number; @@ -283,6 +285,13 @@ function normalizeDiagram(diagram: Omit): Engineer }; } +function attributeCenter(attribute: EngineeringAttributeNode): DiagramPosition { + return { + x: attribute.x + attribute.width / 2, + y: attribute.y + attribute.height / 2, + }; +} + export function buildEngineeringDiagram(tables: DiagramTable[], relationships: DiagramRelationship[], positions: Record): EngineeringDiagram { const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)])); const rows = orderedTableRows(tables, positions); @@ -321,21 +330,26 @@ export function buildEngineeringDiagram(tables: DiagramTable[], relationships: D nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y; }); const entityMap = new Map(entities.map((entity) => [entity.name, entity])); + const attributeMap = new Map(attributes.map((attr) => [`${attr.tableName}:${attr.columnName}`, attr])); const orderedEntities = tables.map((table) => entityMap.get(table.name)).filter((entity): entity is EngineeringEntityNode => entity !== undefined); const relationshipNodes: EngineeringRelationshipNode[] = relationships.flatMap((relationship) => { - const source = entityMap.get(relationship.sourceTable); - const target = entityMap.get(relationship.targetTable); - if (!source || !target) return []; + const sourceEntity = entityMap.get(relationship.sourceTable); + const targetEntity = entityMap.get(relationship.targetTable); + const sourceAttr = attributeMap.get(`${relationship.sourceTable}:${relationship.sourceColumn}`); + const targetAttr = attributeMap.get(`${relationship.targetTable}:${relationship.targetColumn}`); + if (!sourceEntity || !targetEntity) return []; - const sourceCenter = entityCenter(source); - const targetCenter = entityCenter(target); + const sourceCenter = sourceAttr ? attributeCenter(sourceAttr) : entityCenter(sourceEntity); + const targetCenter = targetAttr ? attributeCenter(targetAttr) : entityCenter(targetEntity); return [ { id: relationship.id, label: relationshipLabel(relationship), sourceTable: relationship.sourceTable, + sourceColumn: relationship.sourceColumn, targetTable: relationship.targetTable, + targetColumn: relationship.targetColumn, sourceCardinality: relationship.sourceCardinality ?? "N", targetCardinality: relationship.targetCardinality ?? "1", x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2, diff --git a/apps/desktop/src/lib/diagram/erDiagram.ts b/apps/desktop/src/lib/diagram/erDiagram.ts index ecdde3927..f8f4221aa 100644 --- a/apps/desktop/src/lib/diagram/erDiagram.ts +++ b/apps/desktop/src/lib/diagram/erDiagram.ts @@ -1,16 +1,84 @@ import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database"; +import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql"; + +export type DiagramTableOrigin = "live" | "draft"; + +/** Live metadata (`IndexInfo`) or draft editor indexes (`EditableStructureIndex`). */ +export type DiagramTableIndex = IndexInfo | EditableStructureIndex; + +export function isEditableStructureIndex(index: DiagramTableIndex): index is EditableStructureIndex { + return "isUnique" in index || "markedForDrop" in index; +} + +export function editableStructureIndexes(table: DiagramTable): EditableStructureIndex[] { + return (table.indexes ?? []).filter(isEditableStructureIndex); +} export interface DiagramTable { name: string; columns: ColumnInfo[]; foreignKeys: ForeignKeyInfo[]; - indexes?: IndexInfo[]; + /** Unique/PK indexes used for FK cardinality; drafts also sync via buildCreateTableSql */ + indexes?: DiagramTableIndex[]; + /** live = from DB metadata; draft = local design not yet synced */ + origin?: DiagramTableOrigin; + syncStatus?: "pending" | "synced" | "error"; + /** + * Live tables only: column names not yet in DB (subset of `columns`). + * Editable in Inspector; synced via ALTER ADD COLUMN. + */ + pendingColumnNames?: string[]; + /** + * Live tables only: existing column names marked for DROP COLUMN on sync. + * Columns remain in `columns` until sync/reload. + */ + droppedColumnNames?: string[]; + /** Live tables only: marked for DROP TABLE on sync (hidden from canvas). */ + pendingDrop?: boolean; +} + +export function isDraftTable(table: DiagramTable): boolean { + return (table.origin ?? "live") === "draft"; +} + +export function isLiveTable(table: DiagramTable): boolean { + return !isDraftTable(table); +} + +export function hasPendingColumns(table: DiagramTable): boolean { + return (table.pendingColumnNames?.length ?? 0) > 0; +} + +export function isPendingColumn(table: DiagramTable, columnName: string): boolean { + return (table.pendingColumnNames ?? []).some((name) => name === columnName); +} + +export function hasDroppedColumns(table: DiagramTable): boolean { + return (table.droppedColumnNames?.length ?? 0) > 0; +} + +export function isDroppedColumn(table: DiagramTable, columnName: string): boolean { + return (table.droppedColumnNames ?? []).some((name) => name === columnName); +} + +/** Tables that need sync: draft creates, live column adds/drops, or pending DROP TABLE. */ +export function needsDiagramSync(table: DiagramTable): boolean { + return isDraftTable(table) || hasPendingColumns(table) || hasDroppedColumns(table) || !!table.pendingDrop; +} + +/** Soft-deleted tables stay in state for Sync but must not be re-added to layers/canvas pickers. */ +export function isDiagramTableAssignable(table: DiagramTable): boolean { + return !table.pendingDrop; +} + +export function filterAssignableDiagramTables(tables: DiagramTable[]): DiagramTable[] { + return tables.filter(isDiagramTableAssignable); } export interface DiagramRelationship { id: string; name: string; - kind: "foreign-key" | "custom"; + kind: "foreign-key" | "custom" | "inferred"; sourceTable: string; sourceColumn: string; targetTable: string; @@ -43,6 +111,7 @@ export interface DiagramPosition { export interface DiagramLayoutOptions { columnsPerRow?: number; cardWidth?: number; + /** When set, every row advances by this height (legacy/tests). When omitted, row height follows tallest table in the row. */ rowHeight?: number; gapX?: number; gapY?: number; @@ -57,6 +126,7 @@ function columnExists(table: DiagramTable | undefined, columnName: string): bool return !!table?.columns.some((column) => column.name === columnName); } +/** True when every unique-key column appears among the FK source columns (unique ⊆ FK). */ function sourceColumnsContainUniqueKey(sourceColumns: string[], keyColumns: string[]): boolean { if (keyColumns.length === 0) return false; const sourceColumnSet = new Set(sourceColumns); @@ -68,11 +138,31 @@ function foreignKeySourceColumns(table: DiagramTable, foreignKey: ForeignKeyInfo return table.foreignKeys.filter((candidate) => candidate.name === foreignKey.name && candidate.ref_table === foreignKey.ref_table).map((candidate) => candidate.column); } -function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" { +function diagramIndexIsUnique(index: DiagramTableIndex): boolean { + if ("isUnique" in index) return !!(index.isUnique || index.isPrimary); + return !!(index.is_unique || index.is_primary); +} + +function diagramIndexFilter(index: DiagramTableIndex): string { + return (index.filter ?? "").trim(); +} + +function diagramIndexMarkedForDrop(index: DiagramTableIndex): boolean { + return "markedForDrop" in index && !!index.markedForDrop; +} + +export function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" { const sourceColumns = foreignKeySourceColumns(table, foreignKey); const primaryKeyColumns = table.columns.filter((column) => column.is_primary_key).map((column) => column.name); if (sourceColumnsContainUniqueKey(sourceColumns, primaryKeyColumns)) return "1"; - return table.indexes?.some((index) => (index.is_unique || index.is_primary) && !index.filter?.trim() && sourceColumnsContainUniqueKey(sourceColumns, index.columns)) ? "1" : "N"; + + if (sourceColumns.length === 1) { + const col = table.columns.find((column) => column.name === sourceColumns[0]); + if (col?.is_unique) return "1"; + } + + const hasUniqueIndex = (table.indexes ?? []).some((index) => diagramIndexIsUnique(index) && !diagramIndexFilter(index) && !diagramIndexMarkedForDrop(index) && sourceColumnsContainUniqueKey(sourceColumns, index.columns)); + return hasUniqueIndex ? "1" : "N"; } function customRelationshipId(relationship: Omit): string { @@ -114,7 +204,60 @@ export function buildDiagramRelationships(tables: DiagramTable[], customRelation kind: "custom" as const, })); - return [...foreignKeyRelationships, ...custom]; + return deduplicateRelationships([...foreignKeyRelationships, ...custom]); +} + +export function deduplicateRelationships(relationships: DiagramRelationship[]): DiagramRelationship[] { + const seen = new Set(); + const unique: DiagramRelationship[] = []; + + for (const rel of relationships) { + const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(rel); + } + } + + return unique; +} + +export interface InferredRelationshipInput { + id: string; + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; + confidence?: "high" | "medium"; + strategy?: string; +} + +export function toDiagramRelationship(input: InferredRelationshipInput): DiagramRelationship { + return { + id: input.id, + name: `${input.sourceTable}_${input.sourceColumn}_${input.targetTable}_${input.targetColumn}`, + kind: "inferred", + sourceTable: input.sourceTable, + sourceColumn: input.sourceColumn, + targetTable: input.targetTable, + targetColumn: input.targetColumn, + sourceCardinality: "N", + targetCardinality: "1", + }; +} + +export function mergeRelationshipsWithInferred(existing: DiagramRelationship[], inferred: InferredRelationshipInput[]): DiagramRelationship[] { + const existingIds = new Set(existing.map((r) => r.id)); + const merged: DiagramRelationship[] = [...existing]; + + for (const inf of inferred) { + if (!existingIds.has(inf.id)) { + merged.push(toDiagramRelationship(inf)); + existingIds.add(inf.id); + } + } + + return deduplicateRelationships(merged); } export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] { @@ -131,24 +274,32 @@ export function filterDiagramTables(tables: DiagramTable[], query: string): Diag export function layoutDiagramTables(tables: Pick[], options: DiagramLayoutOptions = {}): Record { const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1)))); const cardWidth = options.cardWidth ?? 260; - const rowHeight = options.rowHeight ?? 220; const gapX = options.gapX ?? 56; const gapY = options.gapY ?? 40; const margin = options.margin ?? 40; + const fixedRowHeight = options.rowHeight; - return Object.fromEntries( - tables.map((table, index) => { - const col = index % columnsPerRow; - const row = Math.floor(index / columnsPerRow); - return [ - table.name, - { - x: margin + col * (cardWidth + gapX), - y: margin + row * (rowHeight + gapY), - }, - ]; - }), - ); + const measureHeight = (table: Pick): number => { + if (fixedRowHeight != null) return fixedRowHeight; + const columnCount = table.columns?.length ?? 0; + return 44 + columnCount * 24 + 12; + }; + + const positions: Record = {}; + let y = margin; + for (let start = 0; start < tables.length; start += columnsPerRow) { + const rowTables = tables.slice(start, start + columnsPerRow); + const rowContentHeight = Math.max(...rowTables.map(measureHeight)); + rowTables.forEach((table, col) => { + positions[table.name] = { + x: margin + col * (cardWidth + gapX), + y, + }; + }); + y += rowContentHeight + gapY; + } + + return positions; } function quoteIdentifier(value: string): string { diff --git a/apps/desktop/src/lib/diagram/graph-store.ts b/apps/desktop/src/lib/diagram/graph-store.ts new file mode 100644 index 000000000..3ca91f262 --- /dev/null +++ b/apps/desktop/src/lib/diagram/graph-store.ts @@ -0,0 +1,136 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import type { DiagramNode, DiagramEdge, HistorySnapshot, LayoutOptions, DiagramLayer } from "@/types/diagram"; +import { LayoutManager } from "./layout-manager"; +import type { LayerLayoutInfo } from "./elk-layout"; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +function emptySnapshotExtras(): Pick { + return { + positions: {}, + layers: [], + tables: [], + customRelationships: [], + edgeWaypoints: {}, + edgeHandleHints: {}, + matchConfirms: [], + matchIgnores: [], + }; +} + +export const useGraphStore = defineStore("diagram-graph", () => { + const nodes = ref([]); + const edges = ref([]); + const layerLayouts = ref([]); + const historyStack = ref([]); + const redoStack = ref([]); + const maxHistorySize = 50; + const layoutManager = new LayoutManager(); + + const canUndo = computed(() => historyStack.value.length > 0); + const canRedo = computed(() => redoStack.value.length > 0); + + function snapshotFromNodesEdges(): HistorySnapshot { + return { + nodes: deepClone(nodes.value), + edges: deepClone(edges.value), + ...emptySnapshotExtras(), + positions: Object.fromEntries(nodes.value.map((n) => [n.id, { ...n.position }])), + }; + } + + function pushHistory(snapshot: HistorySnapshot) { + historyStack.value.push(deepClone(snapshot)); + if (historyStack.value.length > maxHistorySize) { + historyStack.value.shift(); + } + redoStack.value = []; + } + + /** Push current store nodes/edges (used by store-owned layout helpers). */ + function pushStoreHistory() { + pushHistory(snapshotFromNodesEdges()); + } + + function undo(current: HistorySnapshot): HistorySnapshot | null { + if (historyStack.value.length === 0) return null; + redoStack.value.push(deepClone(current)); + const prev = historyStack.value.pop()!; + nodes.value = deepClone(prev.nodes); + edges.value = deepClone(prev.edges); + return deepClone(prev); + } + + function redo(current: HistorySnapshot): HistorySnapshot | null { + if (redoStack.value.length === 0) return null; + historyStack.value.push(deepClone(current)); + const next = redoStack.value.pop()!; + nodes.value = deepClone(next.nodes); + edges.value = deepClone(next.edges); + return deepClone(next); + } + + function setNodes(newNodes: DiagramNode[]) { + nodes.value = newNodes; + } + + function setEdges(newEdges: DiagramEdge[]) { + edges.value = newEdges; + } + + function updateNodePosition(nodeId: string, position: { x: number; y: number }) { + const node = nodes.value.find((n) => n.id === nodeId); + if (node) { + pushStoreHistory(); + node.position = position; + } + } + + async function applyLayout(direction?: LayoutOptions["direction"]) { + pushStoreHistory(); + const result = await layoutManager.applyElkLayout(nodes.value, edges.value, direction); + nodes.value = result.nodes; + edges.value = result.edges; + layerLayouts.value = []; + } + + async function applyElkLayoutWithLayers(nodesParam: DiagramNode[], edgesParam: DiagramEdge[], layers: DiagramLayer[]) { + pushStoreHistory(); + const result = await layoutManager.applyElkLayoutWithLayers(nodesParam, edgesParam, layers); + nodes.value = result.nodes; + edges.value = result.edges; + layerLayouts.value = result.layerLayouts || []; + return result; + } + + function applyGridLayout() { + pushStoreHistory(); + nodes.value = layoutManager.applyGridLayout(nodes.value); + } + + function clearHistory() { + historyStack.value = []; + redoStack.value = []; + } + + return { + nodes, + edges, + layerLayouts, + undo, + redo, + canUndo, + canRedo, + pushHistory, + setNodes, + setEdges, + updateNodePosition, + applyLayout, + applyElkLayoutWithLayers, + applyGridLayout, + clearHistory, + }; +}); diff --git a/apps/desktop/src/lib/diagram/layer-store.ts b/apps/desktop/src/lib/diagram/layer-store.ts new file mode 100644 index 000000000..ecc7c7ea9 --- /dev/null +++ b/apps/desktop/src/lib/diagram/layer-store.ts @@ -0,0 +1,182 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import type { DiagramLayer, LayerLayoutMode } from "@/types/diagram"; +import { LAYER_COLORS } from "@/types/diagram"; + +function generateLayerId(): string { + return `layer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +function getNextColor(existingColors: string[]): string { + const usedColors = new Set(existingColors); + for (const color of LAYER_COLORS) { + if (!usedColors.has(color)) { + return color; + } + } + return LAYER_COLORS[existingColors.length % LAYER_COLORS.length]; +} + +function getNextLayerName(existingNames: string[]): string { + let maxIndex = 0; + const layerNameRegex = /^Layer (\d+)$/; + for (const name of existingNames) { + const match = name.match(layerNameRegex); + if (match) { + const index = parseInt(match[1], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + } + return `Layer ${maxIndex + 1}`; +} + +export const useLayerStore = defineStore("diagram-layer", () => { + const layers = ref([]); + const activeLayerId = ref(null); + + const activeLayer = computed(() => layers.value.find((l) => l.id === activeLayerId.value)); + + function getLayerByTable(tableName: string): DiagramLayer | undefined { + return layers.value.find((layer) => layer.tableNames.includes(tableName)); + } + + function getLayerColor(tableName: string): string { + const layer = getLayerByTable(tableName); + return layer?.color || "#9ca3af"; + } + + function addLayer(name?: string, position?: { x: number; y: number }, size?: { width?: number; height?: number }): DiagramLayer { + for (const layer of layers.value) { + layer.collapsed = true; + } + const newLayer: DiagramLayer = { + id: generateLayerId(), + name: name || getNextLayerName(layers.value.map((l) => l.name)), + color: getNextColor(layers.value.map((l) => l.color)), + tableNames: [], + collapsed: false, + visible: true, + layoutMode: "auto", + position: position || { x: 40, y: 40 }, + width: size?.width ?? 240, + height: size?.height ?? 52, + }; + layers.value.push(newLayer); + activeLayerId.value = newLayer.id; + return newLayer; + } + + function setLayoutMode(layerId: string, layoutMode: LayerLayoutMode) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer) { + layer.layoutMode = layoutMode; + } + } + + function updateLayerGeometry(layerId: string, geometry: { position?: { x: number; y: number }; width?: number; height?: number }) { + const layer = layers.value.find((l) => l.id === layerId); + if (!layer) return; + if (geometry.position) layer.position = geometry.position; + if (geometry.width !== undefined) layer.width = geometry.width; + if (geometry.height !== undefined) layer.height = geometry.height; + } + + function removeLayer(layerId: string) { + const index = layers.value.findIndex((l) => l.id === layerId); + if (index === -1) return; + + layers.value.splice(index, 1); + + if (activeLayerId.value === layerId) { + activeLayerId.value = layers.value[0]?.id || null; + } + } + + function renameLayer(layerId: string, newName: string) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer) { + layer.name = newName; + } + } + + function setActiveLayer(layerId: string | null) { + activeLayerId.value = layerId; + } + + function addTableToLayer(layerId: string, tableName: string) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer && !layer.tableNames.includes(tableName)) { + layer.tableNames.push(tableName); + } + } + + function removeTableFromLayer(layerId: string, tableName: string) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer) { + layer.tableNames = layer.tableNames.filter((name) => name !== tableName); + } + } + + function moveTableToLayer(tableName: string, targetLayerId: string) { + const currentLayer = getLayerByTable(tableName); + if (currentLayer && currentLayer.id !== targetLayerId) { + removeTableFromLayer(currentLayer.id, tableName); + } + addTableToLayer(targetLayerId, tableName); + } + + function toggleLayerVisibility(layerId: string) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer) { + layer.visible = !layer.visible; + } + } + + function toggleLayerCollapse(layerId: string) { + const layer = layers.value.find((l) => l.id === layerId); + if (layer) { + layer.collapsed = !layer.collapsed; + } + } + + function clearLayers() { + layers.value = []; + activeLayerId.value = null; + } + + function loadLayers(savedLayers: DiagramLayer[]) { + layers.value = savedLayers.map((layer) => ({ + ...layer, + layoutMode: layer.layoutMode ?? "auto", + })); + activeLayerId.value = layers.value[0]?.id || null; + } + + function toJSON(): DiagramLayer[] { + return JSON.parse(JSON.stringify(layers.value)); + } + + return { + layers, + activeLayerId, + activeLayer, + getLayerByTable, + getLayerColor, + addLayer, + removeLayer, + renameLayer, + setActiveLayer, + setLayoutMode, + updateLayerGeometry, + addTableToLayer, + removeTableFromLayer, + moveTableToLayer, + toggleLayerVisibility, + toggleLayerCollapse, + clearLayers, + loadLayers, + toJSON, + }; +}); diff --git a/apps/desktop/src/lib/diagram/layout-manager.ts b/apps/desktop/src/lib/diagram/layout-manager.ts new file mode 100644 index 000000000..342b4432f --- /dev/null +++ b/apps/desktop/src/lib/diagram/layout-manager.ts @@ -0,0 +1,46 @@ +import type { DiagramNode, DiagramEdge, LayoutOptions, DiagramLayer } from "@/types/diagram"; +import { computeLayout, computeLayoutWithLayers, type LayerLayoutInfo } from "./elk-layout"; + +export interface LayoutManagerOptions { + defaultDirection?: LayoutOptions["direction"]; + gridColumns?: number; +} + +export class LayoutManager { + private defaultDirection: LayoutOptions["direction"]; + private gridColumns: number; + + constructor(options: LayoutManagerOptions = {}) { + this.defaultDirection = options.defaultDirection || "LR"; + this.gridColumns = options.gridColumns || 4; + } + + async applyElkLayout(nodes: DiagramNode[], edges: DiagramEdge[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> { + return computeLayout(nodes, edges, { direction: direction || this.defaultDirection }); + } + + async applyElkLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> { + return computeLayoutWithLayers(nodes, edges, layers, { direction: direction || this.defaultDirection }); + } + + applyGridLayout(nodes: DiagramNode[]): DiagramNode[] { + const columns = Math.max(1, Math.min(this.gridColumns, Math.ceil(Math.sqrt(nodes.length)))); + const cardWidth = 270; + const rowHeight = 240; + const gapX = 64; + const gapY = 44; + const margin = 40; + + return nodes.map((node, index) => { + const col = index % columns; + const row = Math.floor(index / columns); + return { + ...node, + position: { + x: margin + col * (cardWidth + gapX), + y: margin + row * (rowHeight + gapY), + }, + }; + }); + } +} diff --git a/apps/desktop/src/lib/diagram/ltr-auto-layout.ts b/apps/desktop/src/lib/diagram/ltr-auto-layout.ts new file mode 100644 index 000000000..29fba284b --- /dev/null +++ b/apps/desktop/src/lib/diagram/ltr-auto-layout.ts @@ -0,0 +1,345 @@ +import type { DiagramLayer } from "@/types/diagram"; +import type { DiagramPosition, DiagramTable } from "@/lib/diagram/erDiagram"; +import { layoutDiagramTables } from "@/lib/diagram/erDiagram"; +import { sizeLayerToFit } from "@/lib/diagram/size-layer"; +import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, GAP_X, GAP_Y, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN, columnsPerRowForWidth, tableCardHeight } from "@/lib/diagram/diagram-constants"; + +export interface LtrAutoLayoutInput { + tables: DiagramTable[]; + positions: Record; + layers: DiagramLayer[]; + paneWidth: number; + /** Optional edges used to cluster related tables in LTR order. */ + relationships?: Array<{ sourceTable: string; targetTable: string }>; +} + +export interface LtrAutoLayoutResult { + positions: Record; + /** Mutated copies of visible layers with updated geometry (caller should apply to store). */ + layers: DiagramLayer[]; +} + +type LayerBox = { + layer: DiagramLayer; + width: number; + height: number; + /** Absolute table positions relative to a provisional origin (0,0) for the layer box. */ + localTablePositions: Record; +}; + +function tableHeightsMap(tables: DiagramTable[]): Record { + const heights: Record = {}; + for (const table of tables) { + heights[table.name] = tableCardHeight(table.columns?.length ?? 0); + } + return heights; +} + +/** Order tables so connected components stay adjacent (for friendlier LTR grids). */ +export function orderTablesByConnectivity(tables: DiagramTable[], relationships: Array<{ sourceTable: string; targetTable: string }> = []): DiagramTable[] { + if (tables.length <= 1 || relationships.length === 0) return [...tables]; + + const nameSet = new Set(tables.map((t) => t.name)); + const adj = new Map>(); + for (const name of nameSet) adj.set(name, new Set()); + + for (const rel of relationships) { + if (!nameSet.has(rel.sourceTable) || !nameSet.has(rel.targetTable)) continue; + if (rel.sourceTable === rel.targetTable) continue; + adj.get(rel.sourceTable)!.add(rel.targetTable); + adj.get(rel.targetTable)!.add(rel.sourceTable); + } + + const byName = new Map(tables.map((t) => [t.name, t])); + const visited = new Set(); + const ordered: DiagramTable[] = []; + + const visitComponent = (start: string) => { + const stack = [start]; + const component: string[] = []; + while (stack.length > 0) { + const name = stack.pop()!; + if (visited.has(name)) continue; + visited.add(name); + component.push(name); + for (const next of adj.get(name) ?? []) { + if (!visited.has(next)) stack.push(next); + } + } + component.sort((a, b) => a.localeCompare(b)); + for (const name of component) { + const table = byName.get(name); + if (table) ordered.push(table); + } + }; + + // Prefer starting from tables that have edges, then isolates (stable name order) + const withEdges = tables.filter((t) => (adj.get(t.name)?.size ?? 0) > 0).sort((a, b) => a.name.localeCompare(b.name)); + const isolates = tables.filter((t) => (adj.get(t.name)?.size ?? 0) === 0).sort((a, b) => a.name.localeCompare(b.name)); + + for (const table of withEdges) { + if (!visited.has(table.name)) visitComponent(table.name); + } + for (const table of isolates) { + if (!visited.has(table.name)) ordered.push(table); + } + + return ordered; +} + +/** + * Reflow unassigned (not in any visible layer) tables into an LTR grid, + * preserving approximate visual order (sort by y then x). + */ +export function reflowUnassignedTables(input: { tables: DiagramTable[]; positions: Record; layers: DiagramLayer[]; paneWidth: number; yOrigin?: number }): Record { + const { tables, positions, layers, paneWidth } = input; + const columnsPerRow = columnsPerRowForWidth(paneWidth); + const assigned = new Set(layers.filter((l) => l.visible).flatMap((l) => l.tableNames)); + const unassigned = tables.filter((t) => !assigned.has(t.name)); + if (unassigned.length === 0) return { ...positions }; + + const sorted = [...unassigned].sort((a, b) => { + const pa = positions[a.name] ?? { x: 0, y: 0 }; + const pb = positions[b.name] ?? { x: 0, y: 0 }; + if (pa.y !== pb.y) return pa.y - pb.y; + return pa.x - pb.x; + }); + + let layersBottom = MARGIN; + for (const layer of layers.filter((l) => l.visible)) { + if (layer.position == null || layer.height == null) continue; + layersBottom = Math.max(layersBottom, layer.position.y + layer.height); + } + + const yOrigin = input.yOrigin ?? (layers.some((l) => l.visible) ? layersBottom + GAP_Y : MARGIN); + const grid = layoutDiagramTables(sorted, { + columnsPerRow, + cardWidth: CARD_WIDTH, + gapX: GAP_X, + gapY: GAP_Y, + margin: MARGIN, + }); + + const next = { ...positions }; + const yOffset = yOrigin - MARGIN; + for (const [name, pos] of Object.entries(grid)) { + next[name] = { x: pos.x, y: pos.y + yOffset }; + } + return next; +} + +function measureFreeLocal(layer: DiagramLayer, positions: Record, heights: Record): LayerBox { + if (layer.tableNames.length === 0) { + return { + layer, + width: EMPTY_LAYER_WIDTH, + height: EMPTY_LAYER_HEIGHT, + localTablePositions: {}, + }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const absolute: Record = {}; + + for (const name of layer.tableNames) { + const position = positions[name]; + if (!position) continue; + const height = heights[name] ?? 120; + absolute[name] = { ...position }; + minX = Math.min(minX, position.x); + minY = Math.min(minY, position.y); + maxX = Math.max(maxX, position.x + CARD_WIDTH); + maxY = Math.max(maxY, position.y + height); + } + + if (!Number.isFinite(minX) || !Number.isFinite(minY)) { + return { + layer, + width: EMPTY_LAYER_WIDTH, + height: EMPTY_LAYER_HEIGHT, + localTablePositions: {}, + }; + } + + const originX = minX - LAYER_CONTENT_PADDING; + const originY = minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING; + const localTablePositions: Record = {}; + for (const [name, pos] of Object.entries(absolute)) { + localTablePositions[name] = { x: pos.x - originX, y: pos.y - originY }; + } + + return { + layer, + width: Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2), + height: Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2), + localTablePositions, + }; +} + +function measureAutoLocal(layer: DiagramLayer, tablesByName: Map, columnsPerRow: number): LayerBox { + if (layer.tableNames.length === 0) { + return { + layer, + width: EMPTY_LAYER_WIDTH, + height: EMPTY_LAYER_HEIGHT, + localTablePositions: {}, + }; + } + + const layerTables = layer.tableNames.map((name) => tablesByName.get(name)).filter((t): t is DiagramTable => !!t); + + if (layerTables.length === 0) { + return { + layer, + width: EMPTY_LAYER_WIDTH, + height: EMPTY_LAYER_HEIGHT, + localTablePositions: {}, + }; + } + + // Layout with margin 0, then offset into layer content area + const grid = layoutDiagramTables(layerTables, { + columnsPerRow, + cardWidth: CARD_WIDTH, + gapX: GAP_X, + gapY: GAP_Y, + margin: 0, + }); + + const localTablePositions: Record = {}; + for (const [name, pos] of Object.entries(grid)) { + localTablePositions[name] = { + x: pos.x + LAYER_CONTENT_PADDING, + y: pos.y + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING, + }; + } + + // Provisional size via sizeLayerToFit against local coords (origin 0,0) + const scratch: DiagramLayer = { + ...layer, + position: { x: 0, y: 0 }, + tableNames: [...layer.tableNames], + }; + const heights = tableHeightsMap(layerTables); + sizeLayerToFit(scratch, localTablePositions, heights); + + return { + layer, + width: scratch.width ?? EMPTY_LAYER_WIDTH, + height: scratch.height ?? EMPTY_LAYER_HEIGHT, + localTablePositions, + }; +} + +/** + * Auto-layout: visible layers packed at top (LTR wrap), unassigned tables + * in an LTR grid below — same column logic as initial resetLayout. + */ +export function computeLtrAutoLayout(input: LtrAutoLayoutInput): LtrAutoLayoutResult { + const { tables, positions: prevPositions, layers, paneWidth, relationships = [] } = input; + const columnsPerRow = columnsPerRowForWidth(paneWidth); + const heights = tableHeightsMap(tables); + const tablesByName = new Map(tables.map((t) => [t.name, t])); + const nextPositions: Record = { ...prevPositions }; + + const visibleLayers = layers.filter((l) => l.visible); + const assigned = new Set(visibleLayers.flatMap((l) => l.tableNames)); + const orderedTables = orderTablesByConnectivity(tables, relationships); + const unassigned = orderedTables.filter((t) => !assigned.has(t.name)); + + if (visibleLayers.length === 0) { + const grid = layoutDiagramTables(orderedTables, { + columnsPerRow, + cardWidth: CARD_WIDTH, + gapX: GAP_X, + gapY: GAP_Y, + margin: MARGIN, + }); + return { positions: { ...nextPositions, ...grid }, layers: [] }; + } + + const boxes: LayerBox[] = visibleLayers.map((layer) => { + const mode = layer.layoutMode ?? "free"; + if (mode === "auto") { + const layerTables = orderTablesByConnectivity( + layer.tableNames.map((n) => tablesByName.get(n)).filter((t): t is DiagramTable => !!t), + relationships, + ); + const scratchLayer = { ...layer, tableNames: layerTables.map((t) => t.name) }; + return measureAutoLocal(scratchLayer, tablesByName, columnsPerRow); + } + return measureFreeLocal(layer, prevPositions, heights); + }); + + const usableWidth = Math.max(CARD_WIDTH, paneWidth - MARGIN * 2); + let cursorX = MARGIN; + let cursorY = MARGIN; + let rowHeight = 0; + let layersBottom = MARGIN; + + const updatedLayers: DiagramLayer[] = []; + + for (const box of boxes) { + const { width, height, localTablePositions, layer } = box; + + if (cursorX > MARGIN && cursorX + width > MARGIN + usableWidth) { + cursorX = MARGIN; + cursorY += rowHeight + GAP_Y; + rowHeight = 0; + } + + const placed: DiagramLayer = { + ...layer, + position: { x: cursorX, y: cursorY }, + width, + height, + tableNames: [...layer.tableNames], + }; + + for (const [name, local] of Object.entries(localTablePositions)) { + nextPositions[name] = { + x: cursorX + local.x, + y: cursorY + local.y, + }; + } + + // Reconcile size from absolute table positions (auto / free with tables) + if (placed.tableNames.length > 0) { + sizeLayerToFit(placed, nextPositions, heights); + } else { + placed.width = EMPTY_LAYER_WIDTH; + placed.height = EMPTY_LAYER_HEIGHT; + placed.position = { x: cursorX, y: cursorY }; + } + + const finalW = placed.width ?? width; + const finalH = placed.height ?? height; + const finalX = placed.position?.x ?? cursorX; + const finalY = placed.position?.y ?? cursorY; + + updatedLayers.push(placed); + + cursorX = finalX + finalW + GAP_X; + rowHeight = Math.max(rowHeight, finalH); + layersBottom = Math.max(layersBottom, finalY + finalH); + } + + if (unassigned.length > 0) { + const grid = layoutDiagramTables(unassigned, { + columnsPerRow, + cardWidth: CARD_WIDTH, + gapX: GAP_X, + gapY: GAP_Y, + margin: MARGIN, + }); + const yOffset = layersBottom + GAP_Y - MARGIN; + for (const [name, pos] of Object.entries(grid)) { + nextPositions[name] = { x: pos.x, y: pos.y + yOffset }; + } + } + + return { positions: nextPositions, layers: updatedLayers }; +} diff --git a/apps/desktop/src/lib/diagram/match-engine.ts b/apps/desktop/src/lib/diagram/match-engine.ts new file mode 100644 index 000000000..1c3dd7ad5 --- /dev/null +++ b/apps/desktop/src/lib/diagram/match-engine.ts @@ -0,0 +1,131 @@ +import type { DiagramTable, DiagramRelationship } from "./erDiagram"; +import type { InferredRelationship, MatchResult } from "@/types/diagram"; +import { buildPrimaryKeyIndex, extractTableNameFromColumn, isTypeCompatible, toSnakeCase } from "./match-strategies"; + +export function buildRelationshipId(sourceTable: string, sourceColumn: string, targetTable: string, targetColumn: string): string { + return ["inferred", sourceTable, sourceColumn, targetTable, targetColumn].join(":"); +} + +export function inferRelationships(tables: DiagramTable[]): InferredRelationship[] { + const results: InferredRelationship[] = []; + const tableNameSet = new Set(tables.map((t) => t.name)); + const primaryKeys = buildPrimaryKeyIndex(tables); + + for (const table of tables) { + for (const column of table.columns) { + if (column.is_primary_key) continue; + + const candidateTableName = extractTableNameFromColumn(column.name); + if (!candidateTableName) continue; + + const candidateTable = toSnakeCase(candidateTableName); + if (!tableNameSet.has(candidateTable)) { + const partialMatch = tables.find((t) => t.name.toLowerCase().includes(candidateTableName.toLowerCase())); + if (partialMatch) { + const targetPK = primaryKeys.get(partialMatch.name); + if (targetPK) { + const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention"; + const confidence = "medium"; + + results.push({ + id: buildRelationshipId(table.name, column.name, partialMatch.name, targetPK.name), + sourceTable: table.name, + sourceColumn: column.name, + targetTable: partialMatch.name, + targetColumn: targetPK.name, + confidence, + strategy, + }); + } + } + continue; + } + + const targetPK = primaryKeys.get(candidateTable); + if (!targetPK) continue; + + const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention"; + const confidence = strategy === "type_signature" ? "high" : "high"; + + results.push({ + id: buildRelationshipId(table.name, column.name, candidateTable, targetPK.name), + sourceTable: table.name, + sourceColumn: column.name, + targetTable: candidateTable, + targetColumn: targetPK.name, + confidence, + strategy, + }); + } + } + + return deduplicate(results); +} + +function deduplicate(relationships: InferredRelationship[]): InferredRelationship[] { + const seen = new Set(); + const unique: InferredRelationship[] = []; + + for (const rel of relationships) { + const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(rel); + } + } + + return unique; +} + +export function filterByStorage(inferred: InferredRelationship[], confirms: string[], ignores: string[]): MatchResult { + const confirmed = inferred.filter((r) => confirms.includes(r.id)); + const pending = inferred.filter((r) => !confirms.includes(r.id) && !ignores.includes(r.id)); + + const conflictList = findConflicts(pending.filter((r) => r.confidence === "high")); + const conflictIds = new Set(conflictList.map((r) => r.id)); + const actionablePending = pending.filter((r) => !conflictIds.has(r.id)); + + return { + relationships: [...confirmed, ...actionablePending], + conflicts: conflictList, + pending: actionablePending, + stats: { + total: inferred.length, + high: inferred.filter((r) => r.confidence === "high").length, + medium: inferred.filter((r) => r.confidence === "medium").length, + }, + }; +} + +function findConflicts(relationships: InferredRelationship[]): InferredRelationship[] { + const conflicts: InferredRelationship[] = []; + const sourceMap = new Map(); + + for (const rel of relationships) { + const key = `${rel.sourceTable}:${rel.sourceColumn}`; + const existing = sourceMap.get(key) || []; + existing.push(rel); + sourceMap.set(key, existing); + } + + for (const [, rels] of sourceMap) { + if (rels.length > 1) { + conflicts.push(...rels); + } + } + + return conflicts; +} + +export function mergeRelationships(existing: DiagramRelationship[], inferred: InferredRelationship[]): (DiagramRelationship | InferredRelationship)[] { + const existingIds = new Set(existing.map((r) => r.id)); + const merged: (DiagramRelationship | InferredRelationship)[] = [...existing]; + + for (const rel of inferred) { + if (!existingIds.has(rel.id)) { + merged.push(rel); + } + } + + return merged; +} diff --git a/apps/desktop/src/lib/diagram/match-storage.ts b/apps/desktop/src/lib/diagram/match-storage.ts new file mode 100644 index 000000000..06604bcbb --- /dev/null +++ b/apps/desktop/src/lib/diagram/match-storage.ts @@ -0,0 +1,63 @@ +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; + +function matchStorageKey(type: "match-confirms" | "match-ignores" | "match-rules", connectionId: string, database: string, schema: string): string { + return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":"); +} + +export function loadMatchConfirms(connectionId: string, database: string, schema: string): string[] { + const key = matchStorageKey("match-confirms", connectionId, database, schema); + try { + return JSON.parse(safeLocalStorageGet(key) || "[]"); + } catch { + return []; + } +} + +export function saveMatchConfirms(ids: string[], connectionId: string, database: string, schema: string): void { + const key = matchStorageKey("match-confirms", connectionId, database, schema); + safeLocalStorageSet(key, JSON.stringify(ids)); +} + +export function loadMatchIgnores(connectionId: string, database: string, schema: string): string[] { + const key = matchStorageKey("match-ignores", connectionId, database, schema); + try { + return JSON.parse(safeLocalStorageGet(key) || "[]"); + } catch { + return []; + } +} + +export function saveMatchIgnores(ids: string[], connectionId: string, database: string, schema: string): void { + const key = matchStorageKey("match-ignores", connectionId, database, schema); + safeLocalStorageSet(key, JSON.stringify(ids)); +} + +export function loadMatchRules(connectionId: string, database: string, schema: string): MatchRule[] { + const key = matchStorageKey("match-rules", connectionId, database, schema); + try { + return JSON.parse(safeLocalStorageGet(key) || "[]"); + } catch { + return []; + } +} + +export function saveMatchRules(rules: MatchRule[], connectionId: string, database: string, schema: string): void { + const key = matchStorageKey("match-rules", connectionId, database, schema); + safeLocalStorageSet(key, JSON.stringify(rules)); +} + +export function isAutoMatchEnabled(): boolean { + return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false"; +} + +export function setAutoMatchEnabled(enabled: boolean): void { + safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled)); +} + +export interface MatchRule { + id: string; + name: string; + pattern: string; + enabled: boolean; + priority: number; +} diff --git a/apps/desktop/src/lib/diagram/match-strategies.ts b/apps/desktop/src/lib/diagram/match-strategies.ts new file mode 100644 index 000000000..baa33fc16 --- /dev/null +++ b/apps/desktop/src/lib/diagram/match-strategies.ts @@ -0,0 +1,71 @@ +import type { DiagramTable } from "./erDiagram"; + +const TYPE_COMPATIBLE_MAP: Record = { + bigint: ["bigint", "int", "smallint", "tinyint", "integer", "long"], + int: ["int", "bigint", "smallint", "tinyint", "integer"], + smallint: ["smallint", "int", "bigint", "tinyint"], + tinyint: ["tinyint", "smallint", "int", "bigint"], + integer: ["integer", "int", "bigint", "smallint"], + uuid: ["uuid", "char", "varchar", "nvarchar", "uniqueidentifier", "guid"], + char: ["char", "varchar", "uuid", "uniqueidentifier"], + varchar: ["varchar", "char", "uuid", "uniqueidentifier"], + nvarchar: ["nvarchar", "varchar", "char", "uuid"], + uniqueidentifier: ["uniqueidentifier", "uuid", "char", "varchar"], +}; + +export function isTypeCompatible(sourceType: string, targetType: string): boolean { + const source = sourceType.toLowerCase(); + const target = targetType.toLowerCase(); + + if (source === target) return true; + + const compatibleWithSource = TYPE_COMPATIBLE_MAP[source]; + if (compatibleWithSource && compatibleWithSource.includes(target)) { + return true; + } + + const compatibleWithTarget = TYPE_COMPATIBLE_MAP[target]; + if (compatibleWithTarget && compatibleWithTarget.includes(source)) { + return true; + } + + return false; +} + +export function toSnakeCase(str: string): string { + return str + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +export interface PrimaryKeyInfo { + name: string; + data_type: string; +} + +export function buildPrimaryKeyIndex(tables: DiagramTable[]): Map { + const index = new Map(); + + for (const table of tables) { + const pk = table.columns.find((col) => col.is_primary_key); + if (pk) { + index.set(table.name, { name: pk.name, data_type: pk.data_type }); + } + } + + return index; +} + +export function extractTableNameFromColumn(columnName: string): string | null { + const camelCaseMatch = columnName.match(/^(.+?)(?:ID|UID)$/); + if (camelCaseMatch) return camelCaseMatch[1]; + + const underscoreMatch = columnName.match(/^(.+?)_(?:id|uuid|pk)$/i); + if (underscoreMatch) return underscoreMatch[1]; + + const lowercaseMatch = columnName.match(/^(.+?)(?:id|uid)$/i); + if (lowercaseMatch) return lowercaseMatch[1]; + + return null; +} diff --git a/apps/desktop/src/lib/diagram/size-layer.ts b/apps/desktop/src/lib/diagram/size-layer.ts new file mode 100644 index 000000000..793a8aab5 --- /dev/null +++ b/apps/desktop/src/lib/diagram/size-layer.ts @@ -0,0 +1,116 @@ +import type { DiagramLayer } from "@/types/diagram"; +import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN } from "./diagram-constants"; + +type Rect = { x: number; y: number; width: number; height: number }; + +function rectsOverlap(a: Rect, b: Rect, gap = 8): boolean { + return !(a.x + a.width + gap <= b.x || b.x + b.width + gap <= a.x || a.y + a.height + gap <= b.y || b.y + b.height + gap <= a.y); +} + +function collectOccupiedRects(layers: DiagramLayer[], tablePositions: Record, tableHeights: Record, excludeLayerId?: string): Rect[] { + const rects: Rect[] = []; + + for (const [name, position] of Object.entries(tablePositions)) { + const parented = layers.some((l) => l.id !== excludeLayerId && l.tableNames.includes(name)); + // Tables inside a layer are covered by the layer rect; still include unassigned tables + if (parented) continue; + rects.push({ + x: position.x, + y: position.y, + width: CARD_WIDTH, + height: tableHeights[name] ?? 120, + }); + } + + for (const layer of layers) { + if (excludeLayerId && layer.id === excludeLayerId) continue; + if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) continue; + rects.push({ + x: layer.position.x, + y: layer.position.y, + width: layer.width, + height: layer.height, + }); + } + + return rects; +} + +export function sizeLayerToFit(layer: DiagramLayer, tablePositions: Record, tableHeights: Record): void { + if (layer.tableNames.length === 0) { + layer.width = EMPTY_LAYER_WIDTH; + layer.height = EMPTY_LAYER_HEIGHT; + return; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const name of layer.tableNames) { + const position = tablePositions[name]; + if (!position) continue; + const height = tableHeights[name] ?? 120; + minX = Math.min(minX, position.x); + minY = Math.min(minY, position.y); + maxX = Math.max(maxX, position.x + CARD_WIDTH); + maxY = Math.max(maxY, position.y + height); + } + + if (!Number.isFinite(minX) || !Number.isFinite(minY)) { + layer.width = EMPTY_LAYER_WIDTH; + layer.height = EMPTY_LAYER_HEIGHT; + return; + } + + layer.position = { + x: minX - LAYER_CONTENT_PADDING, + y: minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING, + }; + layer.width = Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2); + layer.height = Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2); +} + +export function pointInLayerBounds(point: { x: number; y: number }, layer: DiagramLayer): boolean { + if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) { + return false; + } + const { x, y } = layer.position; + return point.x >= x && point.x <= x + layer.width && point.y >= y && point.y <= y + layer.height; +} + +export function findLayerAtPoint(point: { x: number; y: number }, layers: DiagramLayer[], excludeLayerId?: string): DiagramLayer | undefined { + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i]; + if (excludeLayerId && layer.id === excludeLayerId) continue; + if (pointInLayerBounds(point, layer)) return layer; + } + return undefined; +} + +/** + * Place a new empty layer near the top-left of the canvas without overlapping + * existing tables or layers. + */ +export function placeNewLayer(layers: DiagramLayer[], tablePositions: Record, tableHeights: Record, options?: { width?: number; height?: number; startY?: number }): { position: { x: number; y: number }; width: number; height: number } { + const width = options?.width ?? EMPTY_LAYER_WIDTH; + const height = options?.height ?? EMPTY_LAYER_HEIGHT; + const startY = options?.startY ?? MARGIN; + const occupied = collectOccupiedRects(layers, tablePositions, tableHeights); + const stepX = width + 16; + const stepY = height + 16; + const maxX = 2400; + + for (let row = 0; row < 40; row += 1) { + const y = startY + row * stepY; + for (let x = MARGIN; x + width <= maxX; x += stepX) { + const candidate: Rect = { x, y, width, height }; + if (!occupied.some((rect) => rectsOverlap(candidate, rect))) { + return { position: { x, y }, width, height }; + } + } + } + + return { position: { x: MARGIN, y: startY + occupied.length * stepY }, width, height }; +} diff --git a/apps/desktop/src/lib/diagram/vue-flow-adapter.ts b/apps/desktop/src/lib/diagram/vue-flow-adapter.ts new file mode 100644 index 000000000..3857b7b27 --- /dev/null +++ b/apps/desktop/src/lib/diagram/vue-flow-adapter.ts @@ -0,0 +1,167 @@ +import type { DiagramTable, DiagramRelationship } from "./erDiagram"; +import type { InferredRelationship, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram"; +import type { Node, Edge } from "@vue-flow/core"; +import { useLayerStore } from "./layer-store"; +import { CARD_WIDTH, tableCardHeight } from "./diagram-constants"; +import { handlesFromWaypoints, type Point } from "./edge-obstacle-router"; + +const LAYER_Z_INDEX = 0; +const TABLE_Z_INDEX = 10; + +export type RelationshipEdgeData = { + relationship: DiagramRelationship | InferredRelationship; + waypoints?: Point[]; +}; + +/** Table is on canvas when unlayered, or when its layer is visible. */ +export function isTableCanvasVisible(tableName: string, layers: DiagramLayer[]): boolean { + const layer = layers.find((l) => l.tableNames.includes(tableName)); + if (!layer) return true; + return layer.visible; +} + +/** positions are absolute canvas coordinates; converted to relative when parented to a visible layer */ +export function toVueFlowNodes(tables: DiagramTable[], positions?: Record): Node<{ table: DiagramTable }>[] { + const layerStore = useLayerStore(); + const layers = layerStore.layers; + + return tables + .filter((table) => !table.pendingDrop && isTableCanvasVisible(table.name, layers)) + .map((table) => { + const layer = layerStore.getLayerByTable(table.name); + const absolute = positions?.[table.name] || { x: 0, y: 0 }; + const visibleParent = layer?.visible ? layer : undefined; + const layerPos = visibleParent?.position || { x: 0, y: 0 }; + const relative = visibleParent ? { x: absolute.x - layerPos.x, y: absolute.y - layerPos.y } : absolute; + + return { + id: table.name, + type: "table", + position: relative, + parentNode: visibleParent?.id, + expandParent: false, + zIndex: TABLE_Z_INDEX, + data: { table }, + }; + }); +} + +export function toDiagramNodes(vueFlowNodes: Node<{ table: DiagramTable }>[]): DiagramNode[] { + return vueFlowNodes + .filter((node) => (node.type === "table" || !node.type) && node.data?.table) + .map((node) => ({ + id: node.id, + type: node.type || "table", + position: node.position, + data: { table: node.data!.table }, + })); +} + +/** Pick L/R or T/B handles from table center deltas. */ +export function pickHandles(sourcePos: { x: number; y: number } | undefined, targetPos: { x: number; y: number } | undefined, sourceHeight = 120, targetHeight = 120, cardWidth = CARD_WIDTH): { sourceHandle: string; targetHandle: string } { + if (!sourcePos || !targetPos) { + return { sourceHandle: "right", targetHandle: "left-target" }; + } + const scx = sourcePos.x + cardWidth / 2; + const scy = sourcePos.y + sourceHeight / 2; + const tcx = targetPos.x + cardWidth / 2; + const tcy = targetPos.y + targetHeight / 2; + const dx = tcx - scx; + const dy = tcy - scy; + + if (Math.abs(dx) >= Math.abs(dy)) { + if (dx >= 0) return { sourceHandle: "right", targetHandle: "left-target" }; + return { sourceHandle: "left", targetHandle: "right-target" }; + } + if (dy >= 0) return { sourceHandle: "bottom", targetHandle: "top-target" }; + return { sourceHandle: "top", targetHandle: "bottom-target" }; +} + +export function toVueFlowEdges( + relationships: (DiagramRelationship | InferredRelationship)[], + positions?: Record, + waypointsById?: Record, + tableHeights?: Record, + handleHintsById?: Record, +): Edge[] { + const layers = useLayerStore().layers; + + return relationships + .filter((rel) => isTableCanvasVisible(rel.sourceTable, layers) && isTableCanvasVisible(rel.targetTable, layers)) + .map((rel) => { + const sourceHeight = tableHeights?.[rel.sourceTable] ?? tableCardHeight(0); + const targetHeight = tableHeights?.[rel.targetTable] ?? tableCardHeight(0); + const waypoints = waypointsById?.[rel.id]; + const fromWaypoints = waypoints?.length ? handlesFromWaypoints(waypoints) : null; + const hint = handleHintsById?.[rel.id]; + const picked = pickHandles(positions?.[rel.sourceTable], positions?.[rel.targetTable], sourceHeight, targetHeight); + const sourceHandle = fromWaypoints?.sourceHandle || hint?.sourceHandle || picked.sourceHandle; + const targetHandle = fromWaypoints?.targetHandle || hint?.targetHandle || picked.targetHandle; + return { + id: rel.id, + type: "relationship", + source: rel.sourceTable, + target: rel.targetTable, + sourceHandle, + targetHandle, + class: "relationship-edge", + selectable: true, + interactionWidth: 24, + data: { + relationship: rel, + ...(waypoints?.length ? { waypoints } : {}), + }, + }; + }); +} + +export function toDiagramEdges(vueFlowEdges: Edge[]): DiagramEdge[] { + return vueFlowEdges + .filter((edge) => edge.data?.relationship) + .map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + waypoints: edge.data?.waypoints, + data: { relationship: edge.data!.relationship }, + })); +} + +export function toVueFlowLayerNodes(layers: DiagramLayer[]): Node<{ layer: DiagramLayer }>[] { + return layers + .filter((layer) => layer.visible) + .map((layer) => ({ + id: layer.id, + type: "layer", + position: layer.position || { x: 0, y: 0 }, + width: layer.width || 240, + height: layer.height || 52, + zIndex: LAYER_Z_INDEX, + draggable: true, + selectable: true, + dragHandle: ".layer-drag-handle", + data: { layer }, + style: { + width: `${layer.width || 240}px`, + height: `${layer.height || 52}px`, + pointerEvents: "none", + }, + })); +} + +/** Convert Vue Flow node position (possibly relative) to absolute canvas coords */ +export function toAbsolutePosition(nodeId: string, relativeOrAbsolute: { x: number; y: number }, layers: DiagramLayer[]): { x: number; y: number } { + const layer = layers.find((l) => l.id === nodeId); + if (layer) return relativeOrAbsolute; + + const parent = layers.find((l) => l.visible && l.tableNames.includes(nodeId)); + if (parent?.position) { + return { + x: parent.position.x + relativeOrAbsolute.x, + y: parent.position.y + relativeOrAbsolute.y, + }; + } + return relativeOrAbsolute; +} diff --git a/apps/desktop/src/lib/export/diagramFormats.ts b/apps/desktop/src/lib/export/diagramFormats.ts new file mode 100644 index 000000000..f71edd2a8 --- /dev/null +++ b/apps/desktop/src/lib/export/diagramFormats.ts @@ -0,0 +1,194 @@ +import type { CustomDiagramRelationship, DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram"; +import type { DiagramLayer } from "@/types/diagram"; + +export type DiagramExportFormat = "svg" | "png" | "json" | "dbml" | "mermaid"; + +export interface DiagramJsonSnapshot { + meta: { + connectionName: string; + database: string; + schema: string; + mode: "table" | "engineering"; + exportedAt: string; + }; + tables: Array<{ + name: string; + columns: Array<{ + name: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + }>; + foreignKeys: Array<{ + name: string; + column: string; + refTable: string; + refColumn: string; + }>; + }>; + relationships: DiagramRelationship[]; + positions: Record; + layers: DiagramLayer[]; + customRelationships: CustomDiagramRelationship[]; + matchConfirms: string[]; + matchIgnores: string[]; +} + +function fileToken(value: string): string { + return value + .trim() + .replace(/[^\p{L}\p{N}._-]+/gu, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function quoteIdent(name: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name; + return `"${name.replace(/"/g, '\\"')}"`; +} + +function mermaidIdent(name: string): string { + const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_"); + return cleaned || "entity"; +} + +function dbmlType(dataType: string): string { + const t = dataType.trim() || "varchar"; + // Keep parentheses content; quote if spaces / special chars + if (/^[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?$/.test(t)) return t; + return `"${t.replace(/"/g, '\\"')}"`; +} + +function dbmlRefOp(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string { + if (sourceCardinality === "1" && targetCardinality === "1") return "-"; + if (sourceCardinality === "N" && targetCardinality === "N") return "<>"; + if (sourceCardinality === "N" && targetCardinality === "1") return ">"; + return "<"; +} + +function mermaidCardinality(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string { + // Mermaid: ENTITY1 ||--o{ ENTITY2 : "label" + if (sourceCardinality === "1" && targetCardinality === "1") return "||--||"; + if (sourceCardinality === "1" && targetCardinality === "N") return "||--o{"; + if (sourceCardinality === "N" && targetCardinality === "1") return "}o--||"; + return "}o--o{"; +} + +export function diagramExportFileName(connectionName: string, databaseName: string, mode: "table" | "engineering", format: DiagramExportFormat): string { + const context = [connectionName, databaseName].map(fileToken).filter(Boolean); + const modeSuffix = mode === "engineering" ? "engineering-er" : "table-structure"; + const ext = format === "svg" ? "svg" : format === "png" ? "png" : format === "json" ? "json" : format === "dbml" ? "dbml" : "mmd"; + const kind = format === "svg" || format === "png" ? modeSuffix : format === "json" ? "diagram" : format === "dbml" ? "schema" : "er"; + return ["dbx", ...(context.length > 0 ? context : ["diagram"]), kind].join("-") + `.${ext}`; +} + +export function buildDiagramJson(snapshot: DiagramJsonSnapshot): string { + return `${JSON.stringify(snapshot, null, 2)}\n`; +} + +export function buildDiagramDbml(tables: DiagramTable[], relationships: DiagramRelationship[]): string { + const lines: string[] = ["// Generated by DBX", ""]; + + for (const table of tables) { + lines.push(`Table ${quoteIdent(table.name)} {`); + for (const column of table.columns) { + const attrs: string[] = []; + if (column.is_primary_key) attrs.push("pk"); + if (!column.is_nullable) attrs.push("not null"); + const attrSuffix = attrs.length > 0 ? ` [${attrs.join(", ")}]` : ""; + lines.push(` ${quoteIdent(column.name)} ${dbmlType(column.data_type)}${attrSuffix}`); + } + lines.push("}", ""); + } + + for (const rel of relationships) { + const op = dbmlRefOp(rel.sourceCardinality, rel.targetCardinality); + lines.push(`Ref: ${quoteIdent(rel.sourceTable)}.${quoteIdent(rel.sourceColumn)} ${op} ${quoteIdent(rel.targetTable)}.${quoteIdent(rel.targetColumn)}`); + } + + if (relationships.length > 0) lines.push(""); + return lines.join("\n"); +} + +export function buildDiagramMermaid(tables: DiagramTable[], relationships: DiagramRelationship[]): string { + const lines: string[] = ["erDiagram"]; + + for (const table of tables) { + const entity = mermaidIdent(table.name); + lines.push(` ${entity} {`); + for (const column of table.columns) { + const typeToken = (column.data_type || "unknown").replace(/\s+/g, "_").replace(/[^A-Za-z0-9_]/g, "") || "unknown"; + const key = column.is_primary_key ? " PK" : ""; + lines.push(` ${typeToken} ${mermaidIdent(column.name)}${key}`); + } + lines.push(" }"); + } + + for (const rel of relationships) { + const left = mermaidIdent(rel.sourceTable); + const right = mermaidIdent(rel.targetTable); + const card = mermaidCardinality(rel.sourceCardinality, rel.targetCardinality); + const label = `${rel.sourceColumn} -> ${rel.targetColumn}`.replace(/"/g, "'"); + lines.push(` ${left} ${card} ${right} : "${label}"`); + } + + lines.push(""); + return lines.join("\n"); +} + +export function svgToPngBlob(svg: string, scale = 2): Promise { + return new Promise((resolve, reject) => { + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + try { + const width = Math.max(1, Math.ceil(img.naturalWidth * scale)); + const height = Math.max(1, Math.ceil(img.naturalHeight * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(url); + reject(new Error("Canvas unsupported")); + return; + } + ctx.fillStyle = "#fafafa"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + URL.revokeObjectURL(url); + canvas.toBlob((png) => { + if (!png) { + reject(new Error("PNG encode failed")); + return; + } + resolve(png); + }, "image/png"); + } catch (err) { + URL.revokeObjectURL(url); + reject(err); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load SVG for PNG export")); + }; + img.src = url; + }); +} + +export function diagramExportDialogFilter(format: DiagramExportFormat): { name: string; extensions: string[] } { + switch (format) { + case "svg": + return { name: "SVG", extensions: ["svg"] }; + case "png": + return { name: "PNG", extensions: ["png"] }; + case "json": + return { name: "JSON", extensions: ["json"] }; + case "dbml": + return { name: "DBML", extensions: ["dbml"] }; + case "mermaid": + return { name: "Mermaid", extensions: ["mmd", "md"] }; + } +} diff --git a/apps/desktop/src/lib/export/diagramSvgExport.ts b/apps/desktop/src/lib/export/diagramSvgExport.ts index 50f9433f4..3dbfb7fd7 100644 --- a/apps/desktop/src/lib/export/diagramSvgExport.ts +++ b/apps/desktop/src/lib/export/diagramSvgExport.ts @@ -1,34 +1,51 @@ import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/engineeringDiagram"; import type { DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram"; +import { pickHandles } from "@/lib/diagram/vue-flow-adapter"; +import { pointAlongPolyline, pointsToSvgPath, type Point } from "@/lib/diagram/edge-obstacle-router"; +import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "@/lib/diagram/diagram-constants"; -type DiagramSvgMode = "table" | "engineering"; +const SOURCE_CARDINALITY_T = 0.18; +const TARGET_CARDINALITY_T = 0.82; interface DiagramCanvas { width: number; height: number; + /** viewBox origin; defaults to 0 when omitted (engineering mode already normalizes to ~0). */ + originX?: number; + originY?: number; } -export interface TableDiagramRelationshipLayout { - path: string; - routePoints: DiagramPosition[]; - sourceCardinality: DiagramPosition; - targetCardinality: DiagramPosition; +export interface DiagramSvgLayer { + id: string; + name: string; + color: string; + x: number; + y: number; + width: number; + height: number; } export interface TableDiagramSvgOptions { tables: DiagramTable[]; relationships: DiagramRelationship[]; positions: Record; - relationshipLayouts: Record; + relationshipPaths: Record; + /** Polyline points for endpoint cardinality badges (aligned with relationshipPaths). */ + relationshipPolylines?: Record; canvas: DiagramCanvas; cardWidth: number; cardHeaderHeight: number; columnRowHeight: number; - maxVisibleColumns: number; cardBottomPadding?: number; - moreColumnsLabel?: (count: number) => string; + layers?: DiagramSvgLayer[]; } +type CardHeightMetrics = { + cardHeaderHeight: number; + columnRowHeight: number; + cardBottomPadding?: number; +}; + function escapeXml(value: string | number): string { return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } @@ -37,17 +54,11 @@ function svgNumber(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ""); } -interface SvgViewport { - x: number; - y: number; - width: number; - height: number; -} - -function svgHeader(canvas: DiagramCanvas, viewport: SvgViewport = { x: 0, y: 0, width: canvas.width, height: canvas.height }): string { +function svgHeader(canvas: DiagramCanvas): string { + // Always viewBox 0 0 — callers that use non-zero canvas.origin must translate content (see buildTableDiagramSvg). return [ - ``, - ``, + ``, + ``, ].join(""); } @@ -62,101 +73,211 @@ function svgText( anchor?: "start" | "middle" | "end"; family?: string; decoration?: string; - stroke?: string; - strokeWidth?: number; - paintOrder?: string; - attributes?: Record; } = {}, ): string { const attrs = [`x="${svgNumber(x)}"`, `y="${svgNumber(y)}"`, `fill="${options.fill ?? "#18181b"}"`, `font-size="${options.size ?? 12}"`, `font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`, 'dominant-baseline="middle"']; if (options.weight) attrs.push(`font-weight="${options.weight}"`); if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`); if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`); - if (options.stroke) attrs.push(`stroke="${options.stroke}"`); - if (options.strokeWidth) attrs.push(`stroke-width="${svgNumber(options.strokeWidth)}"`); - if (options.paintOrder) attrs.push(`paint-order="${options.paintOrder}"`); - for (const [name, value] of Object.entries(options.attributes ?? {})) { - attrs.push(`${name}="${escapeXml(value)}"`); - } return `${escapeXml(label)}`; } -function tableHeight(table: DiagramTable, options: TableDiagramSvgOptions): number { - const visibleCount = Math.min(table.columns.length, options.maxVisibleColumns); - const overflowHeight = table.columns.length > options.maxVisibleColumns ? options.columnRowHeight : 0; - return options.cardHeaderHeight + visibleCount * options.columnRowHeight + overflowHeight + (options.cardBottomPadding ?? 12); -} - -function tableDiagramDefs(): string { - return ["", '', '', "", ""].join(""); +/** Shared table card height for SVG canvas / paths / cards. */ +function svgCardHeight(columnCount: number, metrics: CardHeightMetrics): number { + return metrics.cardHeaderHeight + columnCount * metrics.columnRowHeight + (metrics.cardBottomPadding ?? CARD_BOTTOM_PADDING); } function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean { return table.foreignKeys.some((fk) => fk.column === columnName); } -function tableDiagramViewport(options: TableDiagramSvgOptions): SvgViewport { - const relationshipPadding = 20; - let minX = 0; - let minY = 0; - let maxX = options.canvas.width; - let maxY = options.canvas.height; +function handleAnchor(pos: DiagramPosition, handle: string, width: number, height: number): Point { + const cx = pos.x + width / 2; + const cy = pos.y + height / 2; + if (handle.startsWith("right")) return { x: pos.x + width, y: cy }; + if (handle.startsWith("left")) return { x: pos.x, y: cy }; + if (handle.startsWith("bottom")) return { x: cx, y: pos.y + height }; + return { x: cx, y: pos.y }; +} - for (const layout of Object.values(options.relationshipLayouts)) { - const points = [...layout.routePoints, layout.sourceCardinality, layout.targetCardinality]; +/** Orthogonal fallback polyline when no ELK/obstacle waypoints are stored. */ +function orthogonalPointsBetweenTables(sourcePos: DiagramPosition, targetPos: DiagramPosition, sourceHeight: number, targetHeight: number, cardWidth: number): Point[] { + const { sourceHandle, targetHandle } = pickHandles(sourcePos, targetPos, sourceHeight, targetHeight, cardWidth); + const s = handleAnchor(sourcePos, sourceHandle, cardWidth, sourceHeight); + const t = handleAnchor(targetPos, targetHandle.replace(/-target$/, ""), cardWidth, targetHeight); + const mid: Point = Math.abs(s.x - t.x) >= Math.abs(s.y - t.y) ? { x: t.x, y: s.y } : { x: s.x, y: t.y }; + return [s, mid, t]; +} + +type RelationshipGeometryInput = { + relationships: DiagramRelationship[]; + positions: Record; + tables: DiagramTable[]; + waypoints?: Record; + cardWidth?: number; + cardHeaderHeight?: number; + columnRowHeight?: number; + cardBottomPadding?: number; +}; + +/** + * Build relationship polylines from live waypoints or table positions. + */ +export function buildTableRelationshipPolylines(input: RelationshipGeometryInput): Record { + const cardWidth = input.cardWidth ?? CARD_WIDTH; + const metrics: CardHeightMetrics = { + cardHeaderHeight: input.cardHeaderHeight ?? CARD_HEADER_HEIGHT, + columnRowHeight: input.columnRowHeight ?? COLUMN_ROW_HEIGHT, + cardBottomPadding: input.cardBottomPadding ?? CARD_BOTTOM_PADDING, + }; + const heightByName = new Map(input.tables.map((t) => [t.name, svgCardHeight(t.columns.length, metrics)])); + const polylines: Record = {}; + + for (const rel of input.relationships) { + const stored = input.waypoints?.[rel.id]; + if (stored && stored.length >= 2) { + polylines[rel.id] = stored.map((p) => ({ ...p })); + continue; + } + const sourcePos = input.positions[rel.sourceTable]; + const targetPos = input.positions[rel.targetTable]; + if (!sourcePos || !targetPos) continue; + const sh = heightByName.get(rel.sourceTable) ?? svgCardHeight(0, metrics); + const th = heightByName.get(rel.targetTable) ?? svgCardHeight(0, metrics); + polylines[rel.id] = orthogonalPointsBetweenTables(sourcePos, targetPos, sh, th, cardWidth); + } + return polylines; +} + +/** + * Build SVG path `d` strings for relationships from live waypoints or table positions. + */ +export function buildTableRelationshipPaths(input: RelationshipGeometryInput): Record { + const polylines = buildTableRelationshipPolylines(input); + const paths: Record = {}; + for (const [id, points] of Object.entries(polylines)) { + paths[id] = pointsToSvgPath(points); + } + return paths; +} + +/** Compute canvas size that fits tables + layers + relationship polylines with padding. */ +export function computeTableDiagramCanvas( + tables: DiagramTable[], + positions: Record, + options: { + cardWidth: number; + cardHeaderHeight: number; + columnRowHeight: number; + cardBottomPadding?: number; + layers?: DiagramSvgLayer[]; + relationshipPolylines?: Record; + padding?: number; + }, +): DiagramCanvas { + const padding = options.padding ?? MARGIN; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const expand = (x1: number, y1: number, x2: number, y2: number) => { + minX = Math.min(minX, x1); + minY = Math.min(minY, y1); + maxX = Math.max(maxX, x2); + maxY = Math.max(maxY, y2); + }; + + for (const layer of options.layers ?? []) { + if (layer.width <= 0 || layer.height <= 0) continue; + expand(layer.x, layer.y, layer.x + layer.width, layer.y + layer.height); + } + + for (const table of tables) { + const pos = positions[table.name] ?? { x: 0, y: 0 }; + const height = svgCardHeight(table.columns.length, options); + expand(pos.x, pos.y, pos.x + options.cardWidth, pos.y + height); + } + + for (const points of Object.values(options.relationshipPolylines ?? {})) { for (const point of points) { - minX = Math.min(minX, point.x - relationshipPadding); - minY = Math.min(minY, point.y - relationshipPadding); - maxX = Math.max(maxX, point.x + relationshipPadding); - maxY = Math.max(maxY, point.y + relationshipPadding); + expand(point.x, point.y, point.x, point.y); } } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { + return { width: 400 + padding, height: 300 + padding, originX: 0, originY: 0 }; + } + + return { + width: Math.ceil(maxX - minX + 2 * padding), + height: Math.ceil(maxY - minY + 2 * padding), + originX: minX - padding, + originY: minY - padding, + }; } export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string { - const parts = [svgHeader(options.canvas, tableDiagramViewport(options)), tableDiagramDefs()]; + const ox = options.canvas.originX ?? 0; + const oy = options.canvas.originY ?? 0; + const parts = [svgHeader(options.canvas)]; + parts.push(``); - for (const relationship of options.relationships) { - const layout = options.relationshipLayouts[relationship.id]; - if (!layout?.path) continue; - parts.push(``); - parts.push( - `` + - `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + - "", - ); - parts.push( - svgText(relationship.sourceCardinality, layout.sourceCardinality.x, layout.sourceCardinality.y, { - size: 12, - weight: "600", - anchor: "middle", - stroke: "#fafafa", - strokeWidth: 4, - paintOrder: "stroke", - attributes: { "data-cardinality-end": "source" }, - }), - ); - parts.push( - svgText(relationship.targetCardinality, layout.targetCardinality.x, layout.targetCardinality.y, { - size: 12, - weight: "600", - anchor: "middle", - stroke: "#fafafa", - strokeWidth: 4, - paintOrder: "stroke", - attributes: { "data-cardinality-end": "target" }, - }), - ); + const layers = (options.layers ?? []).filter((l) => l.width > 0 && l.height > 0); + if (layers.length > 0) { + parts.push(''); + for (const layer of layers) { + const fill = layer.color || "#9ca3af"; + parts.push(``); + parts.push( + svgText(layer.name, layer.x + 12, layer.y + 18, { + size: 12, + weight: "600", + fill: fill, + }), + ); + } parts.push(""); } + parts.push(''); + for (const relationship of options.relationships) { + const path = options.relationshipPaths[relationship.id]; + if (!path) continue; + parts.push(`` + `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + ""); + } + parts.push(""); + + parts.push(''); + for (const relationship of options.relationships) { + const points = options.relationshipPolylines?.[relationship.id]; + if (!points || points.length < 2) continue; + const sourcePos = pointAlongPolyline(points, SOURCE_CARDINALITY_T); + const targetPos = pointAlongPolyline(points, TARGET_CARDINALITY_T); + const sourceCard = relationship.sourceCardinality || "N"; + const targetCard = relationship.targetCardinality || "1"; + parts.push( + svgText(sourceCard, sourcePos.x, sourcePos.y, { + size: 11, + weight: "700", + anchor: "middle", + fill: "#18181b", + }), + ); + parts.push( + svgText(targetCard, targetPos.x, targetPos.y, { + size: 11, + weight: "700", + anchor: "middle", + fill: "#18181b", + }), + ); + } + parts.push(""); + for (const table of options.tables) { const position = options.positions[table.name] ?? { x: 0, y: 0 }; - const height = tableHeight(table, options); - const visibleColumns = table.columns.slice(0, options.maxVisibleColumns); - const hiddenCount = Math.max(0, table.columns.length - options.maxVisibleColumns); + const height = svgCardHeight(table.columns.length, options); parts.push(``); parts.push(``); parts.push(``); @@ -170,7 +291,7 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string { }), ); - visibleColumns.forEach((column, index) => { + table.columns.forEach((column, index) => { const rowTop = options.cardHeaderHeight + index * options.columnRowHeight; const rowCenter = rowTop + options.columnRowHeight / 2; parts.push(``); @@ -188,19 +309,10 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string { }), ); }); - - if (hiddenCount > 0) { - const y = options.cardHeaderHeight + visibleColumns.length * options.columnRowHeight + options.columnRowHeight / 2; - parts.push( - svgText(options.moreColumnsLabel?.(hiddenCount) ?? `+ ${hiddenCount} columns`, 12, y, { - size: 11, - fill: "#71717a", - }), - ); - } parts.push(""); } + parts.push(""); parts.push(""); return parts.join(""); } @@ -311,6 +423,8 @@ export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string return parts.join(""); } +type DiagramSvgMode = "table" | "engineering"; + function fileToken(value: string): string { return value .trim() @@ -319,6 +433,7 @@ function fileToken(value: string): string { .replace(/^-|-$/g, ""); } +/** Stable download name for SVG exports (shared with upstream contract tests). */ export function diagramSvgFileName(connectionName: string, databaseName: string, mode: DiagramSvgMode): string { const context = [connectionName, databaseName].map(fileToken).filter(Boolean); const suffix = mode === "engineering" ? "engineering-er" : "table-structure"; diff --git a/apps/desktop/src/lib/export/saveDiagramExport.ts b/apps/desktop/src/lib/export/saveDiagramExport.ts new file mode 100644 index 000000000..beeeb93f1 --- /dev/null +++ b/apps/desktop/src/lib/export/saveDiagramExport.ts @@ -0,0 +1,46 @@ +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; +import { diagramExportDialogFilter, type DiagramExportFormat } from "./diagramFormats"; + +export async function saveDiagramTextExport(defaultPath: string, content: string, format: DiagramExportFormat): Promise { + if (isTauriRuntime()) { + const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]); + const path = await save({ + defaultPath, + filters: [diagramExportDialogFilter(format)], + }); + if (!path) return false; + await writeTextFile(path, content); + return true; + } + + const mime = format === "svg" ? "image/svg+xml" : format === "json" ? "application/json" : "text/plain"; + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = defaultPath; + a.click(); + URL.revokeObjectURL(url); + return true; +} + +export async function saveDiagramBinaryExport(defaultPath: string, data: Blob, format: DiagramExportFormat): Promise { + if (isTauriRuntime()) { + const [{ save }, { writeFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]); + const path = await save({ + defaultPath, + filters: [diagramExportDialogFilter(format)], + }); + if (!path) return false; + await writeFile(path, new Uint8Array(await data.arrayBuffer())); + return true; + } + + const url = URL.createObjectURL(data); + const a = document.createElement("a"); + a.href = url; + a.download = defaultPath; + a.click(); + URL.revokeObjectURL(url); + return true; +} diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index b19c747a4..4df6cea0d 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -273,6 +273,8 @@ export const DATA_TYPE_OPTIONS: Record = { ], questdb: ["boolean", "ipv4", "byte", "short", "char", "int", "float", "symbol", "varchar", "string", "long", "date", "timestamp", "timestamp_ns", "double", "uuid", "binary", "long256", "geohash", "array", "interval", "decimal"], xugu: ["BOOLEAN", "INTEGER", "SMALLINT", "BIGINT", "FLOAT", "NUMERIC", "CHAR", "VARCHAR", "CLOB", "DATE", "TIME", "TIMESTAMP", "BINARY", "VARBINARY", "BLOB", "XML", "BOOL", "INT", "SHORT", "LONGINT", "LONG", "REAL", "DECIMAL", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR2"], + duckdb: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "FLOAT", "DOUBLE", "DECIMAL", "VARCHAR", "TEXT", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", "UUID", "JSON"], + h2: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "IDENTITY", "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "FLOAT", "CHAR", "CHARACTER", "VARCHAR", "VARCHAR_IGNORECASE", "CLOB", "BINARY", "VARBINARY", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "UUID", "ARRAY", "JSON"], }; const DATA_TYPE_OPTION_ALIASES: Partial> = { @@ -287,13 +289,20 @@ const DATA_TYPE_OPTION_ALIASES: Partial> = { opengauss: "postgres", questdb: "questdb", redshift: "postgres", + vertica: "postgres", highgo: "postgres", uxdb: "postgres", vastbase: "postgres", kingbase: "postgres", + firebird: "postgres", dameng: "oracle", "oceanbase-oracle": "oracle", iris: "oracle", + yashandb: "oracle", + rqlite: "sqlite", + turso: "sqlite", + "cloudflare-d1": "sqlite", + access: "h2", }; export function getDataTypeOptions(dbType: DatabaseType | undefined): string[] { @@ -1104,6 +1113,22 @@ export function defaultNewColumnDataType(dbType: DatabaseType | undefined, dataT const baseType = dataTypeOptions[0] ?? "text"; return combineDataTypeForDatabase(dbType, baseType, getDefaultLengthForType(dbType, baseType)); } + + const options = dataTypeOptions.length > 0 ? dataTypeOptions : getDataTypeOptions(dbType); + const dialectKey = dbType ? (DATA_TYPE_OPTION_ALIASES[dbType] ?? dbType) : ""; + + if (dialectKey === "sqlite" || dialectKey === "duckdb") { + const textType = options.find((type) => /^text$/i.test(type.trim())); + return textType ?? "text"; + } + + if (options.length > 0) { + const preferred = options.find((type) => /^(varchar|character varying|nvarchar)$/i.test(type.trim())) ?? options.find((type) => /^(string|clob|lvarchar|text)$/i.test(type.trim())) ?? options.find((type) => /^varchar/i.test(type.trim())); + if (preferred) { + return combineDataTypeForDatabase(dbType, preferred, getDefaultLengthForType(dbType, preferred)); + } + } + return dbType === "sqlite" ? "text" : "varchar(255)"; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 8a4ced9dc..a03dfb7a1 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -443,6 +443,7 @@ export interface ColumnInfo { is_nullable: boolean; column_default: string | null; is_primary_key: boolean; + is_unique?: boolean; extra: string | null; comment?: string | null; numeric_precision?: number | null; diff --git a/apps/desktop/src/types/diagram.ts b/apps/desktop/src/types/diagram.ts new file mode 100644 index 000000000..2486f08f2 --- /dev/null +++ b/apps/desktop/src/types/diagram.ts @@ -0,0 +1,92 @@ +import type { CustomDiagramRelationship, DiagramPosition, DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram"; + +export interface InferredRelationship { + id: string; + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; + confidence: "high" | "medium"; + strategy: "naming_convention" | "type_signature" | "regex"; +} + +export interface MatchResult { + relationships: InferredRelationship[]; + conflicts: InferredRelationship[]; + pending: InferredRelationship[]; + stats: { total: number; high: number; medium: number }; +} + +export interface LayoutOptions { + direction?: "LR" | "TB" | "RL" | "BT"; +} + +export interface HistorySnapshot { + nodes: DiagramNode[]; + edges: DiagramEdge[]; + positions: Record; + layers: DiagramLayer[]; + tables: DiagramTable[]; + customRelationships: CustomDiagramRelationship[]; + edgeWaypoints: Record; + edgeHandleHints: Record; + matchConfirms: string[]; + matchIgnores: string[]; +} + +export interface DiagramNode { + id: string; + type: string; + position: { x: number; y: number }; + data: { table: DiagramTable }; + selected?: boolean; +} + +export interface DiagramEdge { + id: string; + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; + /** Absolute canvas waypoints from ELK / obstacle router (includes endpoints). */ + waypoints?: { x: number; y: number }[]; + data: { relationship: DiagramRelationship | InferredRelationship }; +} + +export type RelationshipKind = "foreign-key" | "custom" | "inferred"; + +export interface MatchRule { + id: string; + name: string; + pattern: string; + enabled: boolean; + priority: number; +} + +export interface MatchStorageKeys { + confirms: string; + ignores: string; + rules: string; + enabled: string; +} + +export type LayerLayoutMode = "free" | "auto"; + +export interface DiagramLayer { + id: string; + name: string; + color: string; + tableNames: string[]; + collapsed: boolean; + visible: boolean; + /** free = locked (keep relative table positions); auto = unlocked (auto-layout rearranges tables in this layer) */ + layoutMode: LayerLayoutMode; + position?: { x: number; y: number }; + width?: number; + height?: number; +} + +export const LAYER_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; + +/** Canvas selection driving the diagram inspector panel */ +export type InspectorTarget = { kind: "table"; tableName: string } | { kind: "edge"; edgeId: string } | null; diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 68415c8f5..4fa064608 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -3408,6 +3408,7 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul }; Some(ColumnInfo { is_primary_key: column_key.eq_ignore_ascii_case("PRI"), + is_unique: column_key.eq_ignore_ascii_case("UNI"), name, data_type: column_type, is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES", @@ -3468,6 +3469,7 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), column_default: get_opt_str(row, "Default"), is_primary_key: key.eq_ignore_ascii_case("PRI"), + is_unique: key.eq_ignore_ascii_case("UNI"), extra: get_opt_str(row, "Extra"), comment: get_opt_str(row, "Comment") .map(|s| fix_potential_double_encoding(&s)) @@ -4439,6 +4441,438 @@ pub async fn show_create_table_ddl(pool: &MySqlPool, database: &str, table: &str .ok_or_else(|| "Failed to read DDL".to_string()) } +// --------------------------------------------------------------------------- +// Doris / StarRocks multi-catalog support. +// +// These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside +// the native `internal` catalog via `SHOW CATALOGS`. The functions below address +// objects in a specific catalog using 3-part qualified names +// (`..`), which the engines accept directly without +// needing to `SWITCH` the session catalog. +// --------------------------------------------------------------------------- + +/// Build a 2-part qualified identifier `` ``.`` ``. +fn doris_catalog_database_ref(catalog: &str, database: &str) -> String { + format!("{}.{}", quote_identifier(catalog), quote_identifier(database)) +} + +/// Build a 3-part qualified identifier `` ``.``.`
` ``. +fn doris_catalog_table_ref(catalog: &str, database: &str, table: &str) -> String { + format!("{}.{}.{}", quote_identifier(catalog), quote_identifier(database), quote_identifier(table)) +} + +/// `SHOW CATALOGS` → list of catalogs visible to the current user. +/// +/// Column layouts differ between engines: Doris exposes `CatalogName` (with +/// `CatalogId`/`IsCurrent`/`CreateTime`/`LastUpdateTime`), while StarRocks +/// exposes `Catalog` (only `Type`/`Comment`, no `IsCurrent`). The name is read +/// from either column; missing trailing columns degrade gracefully to +/// empty/None. The built-in catalog is named `internal` in Doris and +/// `default_catalog` in StarRocks (both with `Type=internal`); detection is +/// type-based (see `CatalogInfo::is_internal`), not name-based. +pub async fn list_doris_catalogs(pool: &MySqlPool) -> Result, String> { + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let result = conn.query_iter("SHOW CATALOGS").await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let catalogs: Vec = rows + .iter() + .filter_map(|row| { + // Doris column is `CatalogName`; StarRocks column is `Catalog`. + let name = first_nonempty_str_by_name(row, &["CatalogName", "Catalog"]).trim().to_string(); + if name.is_empty() { + return None; + } + let catalog_type = get_str_by_name(row, "Type").trim().to_string(); + let is_current = { + let value = get_str_by_name(row, "IsCurrent").trim().to_ascii_lowercase(); + !value.is_empty() && value != "no" && value != "false" && value != "0" + }; + let comment = get_opt_str(row, "Comment").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + Some(crate::db::CatalogInfo { name, catalog_type, is_current, comment }) + }) + .collect(); + Ok(normalize_doris_catalogs(catalogs)) +} + +/// Sort with the built-in catalog first, then the rest alphabetically by name. +/// The built-in catalog is identified by `CatalogInfo::is_internal` (type-based) +/// rather than by name, so StarRocks `default_catalog` sorts first just like +/// Doris `internal`. No synthetic catalog is injected: `SHOW CATALOGS` always +/// lists the built-in catalog on both engines, and a single-catalog result is +/// handled by the flat-sidebar fallback in the caller. +fn normalize_doris_catalogs(mut catalogs: Vec) -> Vec { + catalogs.sort_by(|a, b| match (a.is_internal(), b.is_internal()) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + catalogs +} + +/// `SHOW DATABASES FROM ` → databases in the given catalog. +pub async fn list_databases_show_from(pool: &MySqlPool, catalog: &str) -> Result, String> { + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let sql = format!("SHOW DATABASES FROM {}", quote_identifier(catalog)); + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + Ok(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false)) +} + +/// `SHOW TABLES FROM .` → tables in an external catalog. +/// +/// External catalogs do not support `SHOW TABLE STATUS`, so comments/status are +/// not fetched (the caller only needs names + types for browsing). +pub async fn list_tables_show_from(pool: &MySqlPool, catalog: &str, database: &str) -> Result, String> { + let sql = format!("SHOW TABLES FROM {}", doris_catalog_database_ref(catalog, database)); + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let mut tables: Vec = rows + .iter() + .filter_map(|row| { + let name = get_str(row, 0).trim().to_string(); + if name.is_empty() { + return None; + } + // SHOW FULL TABLES exposes a type column; plain SHOW TABLES does not. + let table_type = get_str(row, 1); + Some(TableInfo { + name, + table_type: if table_type.trim().is_empty() { "TABLE".to_string() } else { table_type }, + comment: None, + parent_schema: None, + parent_name: None, + }) + }) + .collect(); + tables.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(tables) +} + +/// `SHOW COLUMNS FROM ..
` → columns of an external +/// catalog table. Falls back to `DESCRIBE` if `SHOW COLUMNS` is rejected. +pub async fn get_columns_show_from( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let qualified = doris_catalog_table_ref(catalog, database, table); + let full_sql = format!("SHOW FULL COLUMNS FROM {qualified}"); + let plain_sql = format!("SHOW COLUMNS FROM {qualified}"); + let describe_sql = format!("DESCRIBE {qualified}"); + let mut conn = get_conn_with_health_check(pool).await?; + let rows: Vec = match conn.query_iter(&full_sql).await { + Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, + Err(_) => match conn.query_iter(&plain_sql).await { + Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?, + Err(_) => { + let result = conn.query_iter(&describe_sql).await.map_err(|e| e.to_string())?; + result.collect_and_drop().await.map_err(|e| e.to_string())? + } + }, + }; + Ok(rows + .iter() + .filter_map(|row| { + let name = get_str_by_name(row, "Field").trim().to_string(); + if name.is_empty() { + return None; + } + let key = get_str_by_name(row, "Key"); + let collation = get_opt_str(row, "Collation").filter(|s| !s.is_empty()); + Some(ColumnInfo { + name, + data_type: get_str_by_name(row, "Type"), + is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), + column_default: get_opt_str(row, "Default"), + is_primary_key: key.eq_ignore_ascii_case("PRI"), + is_unique: key.eq_ignore_ascii_case("UNI"), + extra: get_opt_str(row, "Extra"), + comment: get_opt_str(row, "Comment") + .map(|s| fix_potential_double_encoding(&s)) + .filter(|s| !s.is_empty()), + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + enum_values: None, + character_set: collation + .as_deref() + .and_then(|c| c.split_once('_').map(|(charset, _)| charset.to_string())) + .filter(|s| !s.is_empty()), + collation, + }) + }) + .collect()) +} + +/// `SHOW CREATE TABLE ..
` → DDL for an external +/// catalog table. +pub async fn show_create_table_ddl_from( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result { + let sql = format!("SHOW CREATE TABLE {}", doris_catalog_table_ref(catalog, database, table)); + let mut conn = get_conn_with_health_check(pool).await?; + let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?; + let rows: Vec = result.collect_and_drop().await.map_err(|e| e.to_string())?; + let row = rows.first().ok_or("DDL not found")?; + row.get_opt::(1) + .and_then(|result| result.ok()) + .or_else(|| { + row.get_opt::, usize>(1) + .and_then(|result| result.ok()) + .map(|b| String::from_utf8_lossy(&b).to_string()) + }) + .ok_or_else(|| "Failed to read DDL".to_string()) +} + +/// Best-effort index listing for an external catalog table. External catalogs +/// generally do not expose MySQL-style index metadata via `information_schema` +/// (that view is scoped to the internal catalog), so indexes are derived from +/// `SHOW CREATE TABLE` parsing. Returns empty on failure (graceful degradation +/// — indexes are informational for external tables). +pub async fn list_doris_catalog_indexes( + pool: &MySqlPool, + catalog: &str, + database: &str, + table: &str, +) -> Result, String> { + let ddl = show_create_table_ddl_from(pool, catalog, database, table).await?; + Ok(doris_indexes_from_create_table_ddl(&ddl)) +} + +fn doris_indexes_from_create_table_ddl(ddl: &str) -> Vec { + let mut indexes = Vec::new(); + for raw_line in ddl.lines() { + let line = trim_ddl_definition_line(raw_line); + if line.is_empty() { + continue; + } + let upper = line.to_ascii_uppercase(); + if upper.starts_with("PRIMARY KEY") { + if let Some(index) = doris_table_key_index("PRIMARY", line, true, true, "PRIMARY KEY") { + indexes.push(index); + } + } else if upper.starts_with("UNIQUE KEY") { + if let Some(index) = doris_table_key_index("UNIQUE KEY", line, true, false, "UNIQUE KEY") { + indexes.push(index); + } + } else if upper.starts_with("INDEX ") { + if let Some(index) = doris_secondary_index(line) { + indexes.push(index); + } + } + } + indexes +} + +fn trim_ddl_definition_line(line: &str) -> &str { + let mut trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(',') { + trimmed = rest.trim_start(); + } + while let Some(rest) = trimmed.strip_suffix(',') { + trimmed = rest.trim_end(); + } + trimmed +} + +fn doris_table_key_index( + name: &str, + line: &str, + is_unique: bool, + is_primary: bool, + index_type: &str, +) -> Option { + let columns = parse_mysql_index_columns(first_parenthesized_content(line)?); + if columns.is_empty() { + return None; + } + Some(IndexInfo { + name: name.to_string(), + columns, + is_unique, + is_primary, + filter: None, + index_type: Some(index_type.to_string()), + included_columns: None, + comment: None, + }) +} + +fn doris_secondary_index(line: &str) -> Option { + let (_, rest) = split_keyword_prefix(line, "INDEX")?; + let (name, after_name) = read_mysql_identifier(rest.trim_start())?; + let columns = parse_mysql_index_columns(first_parenthesized_content(after_name)?); + if columns.is_empty() { + return None; + } + Some(IndexInfo { + name, + columns, + is_unique: false, + is_primary: false, + filter: None, + index_type: mysql_keyword_argument(after_name, "USING").or_else(|| Some("INDEX".to_string())), + included_columns: None, + comment: mysql_quoted_string_argument(after_name, "COMMENT"), + }) +} + +fn split_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<(&'a str, &'a str)> { + if line.len() < keyword.len() || !line[..keyword.len()].eq_ignore_ascii_case(keyword) { + return None; + } + let rest = &line[keyword.len()..]; + if !rest.is_empty() && is_mysql_identifier_byte(rest.as_bytes()[0]) { + return None; + } + Some((&line[..keyword.len()], rest)) +} + +fn read_mysql_identifier(input: &str) -> Option<(String, &str)> { + let input = input.trim_start(); + if input.is_empty() { + return None; + } + let bytes = input.as_bytes(); + if bytes[0] == b'`' { + let mut i = 1; + let mut value = String::new(); + while i < bytes.len() { + if bytes[i] == b'`' { + if i + 1 < bytes.len() && bytes[i + 1] == b'`' { + value.push('`'); + i += 2; + continue; + } + return Some((value, &input[i + 1..])); + } + let ch = input[i..].chars().next()?; + value.push(ch); + i += ch.len_utf8(); + } + return None; + } + + let end = input.find(|ch: char| ch.is_whitespace() || matches!(ch, '(' | ')' | ',')).unwrap_or(input.len()); + if end == 0 { + return None; + } + Some((input[..end].to_string(), &input[end..])) +} + +fn first_parenthesized_content(input: &str) -> Option<&str> { + let bytes = input.as_bytes(); + let mut depth = 0usize; + let mut start = None; + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_mysql_quoted(input, i, bytes[i]); + continue; + } + b'(' => { + if depth == 0 { + start = Some(i + 1); + } + depth += 1; + } + b')' if depth > 0 => { + depth -= 1; + if depth == 0 { + return start.map(|start| &input[start..i]); + } + } + _ => {} + } + i += 1; + } + None +} + +fn split_top_level_csv(input: &str) -> Vec<&str> { + let bytes = input.as_bytes(); + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_mysql_quoted(input, i, bytes[i]); + continue; + } + b'(' => depth += 1, + b')' if depth > 0 => depth -= 1, + b',' if depth == 0 => { + parts.push(input[start..i].trim()); + start = i + 1; + } + _ => {} + } + i += 1; + } + parts.push(input[start..].trim()); + parts +} + +fn parse_mysql_index_columns(input: &str) -> Vec { + split_top_level_csv(input) + .into_iter() + .filter_map(|part| read_mysql_identifier(part).map(|(column, _)| column)) + .filter(|column| !column.is_empty()) + .collect() +} + +fn mysql_keyword_argument(input: &str, keyword: &str) -> Option { + let bytes = input.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_mysql_quoted(input, i, bytes[i]); + continue; + } + _ if mysql_keyword_at(input, i, keyword) => { + return read_mysql_identifier(&input[i + keyword.len()..]).map(|(value, _)| value); + } + _ => i += 1, + } + } + None +} + +fn mysql_quoted_string_argument(input: &str, keyword: &str) -> Option { + let bytes = input.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + i = skip_mysql_quoted(input, i, bytes[i]); + continue; + } + _ if mysql_keyword_at(input, i, keyword) => { + let rest = input[i + keyword.len()..].trim_start(); + if rest.as_bytes().first().copied() != Some(b'\'') { + return None; + } + let end = skip_mysql_quoted(rest, 0, b'\''); + if end <= 1 || end > rest.len() { + return None; + } + return Some(rest[1..end - 1].replace("\\'", "'").replace("''", "'")); + } + _ => i += 1, + } + } + None +} + pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result, String> { let column_sql = format!( "SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, \ diff --git a/crates/dbx-core/src/db/mysql_compatible.rs b/crates/dbx-core/src/db/mysql_compatible.rs index 628a2fb2d..27524a492 100644 --- a/crates/dbx-core/src/db/mysql_compatible.rs +++ b/crates/dbx-core/src/db/mysql_compatible.rs @@ -191,6 +191,7 @@ pub async fn get_columns_show_from( is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), column_default: get_opt_str(row, "Default"), is_primary_key: key.eq_ignore_ascii_case("PRI"), + is_unique: key.eq_ignore_ascii_case("UNI"), extra: get_opt_str(row, "Extra"), comment: get_opt_str(row, "Comment") .map(|s| fix_potential_double_encoding(&s)) diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 6afc8f814..a2eef41c3 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -5415,6 +5415,7 @@ fn deduplicate_column_infos(columns: Vec) -> Vec for column in columns { if let Some(existing) = result.iter_mut().find(|existing| existing.name == column.name) { existing.is_primary_key |= column.is_primary_key; + existing.is_unique |= column.is_unique; existing.is_nullable &= column.is_nullable; merge_optional_string(&mut existing.column_default, column.column_default); merge_optional_string(&mut existing.extra, column.extra); @@ -5438,6 +5439,37 @@ fn deduplicate_column_infos(columns: Vec) -> Vec result } +pub async fn get_all_columns_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, +) -> Result, String> { + let tables = list_tables_core(state, connection_id, database, schema, None, None, None, None, None).await?; + + let mut result: Vec = Vec::with_capacity(tables.len()); + for table in tables { + match get_columns_core(state, connection_id, database, schema, &table.name).await { + Ok(columns) => { + result.push(db::TableColumnsResult { table_name: table.name, columns, error: None }); + } + Err(e) => { + log::warn!( + "[schema][get_all_columns] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table.name, + e + ); + result.push(db::TableColumnsResult { table_name: table.name, columns: Vec::new(), error: Some(e) }); + } + } + } + + Ok(result) +} + fn merge_optional_string(target: &mut Option, candidate: Option) { let Some(candidate) = candidate else { return; diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index e33fe9a96..de29034e9 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -3899,6 +3899,7 @@ mod tests { is_nullable: false, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: comment.map(str::to_string), numeric_precision: None, @@ -5091,6 +5092,7 @@ mod tests { is_nullable: true, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, @@ -7385,6 +7387,7 @@ mod tests { column_default: Some("'default'".into()), comment: Some("new".into()), is_primary_key: false, + is_unique: false, extra: None, numeric_precision: None, numeric_scale: None, @@ -7400,6 +7403,7 @@ mod tests { column_default: None, comment: Some("old".into()), is_primary_key: false, + is_unique: false, extra: None, numeric_precision: None, numeric_scale: None, @@ -7642,6 +7646,7 @@ mod tests { data_type: "int(11)".into(), is_nullable: false, is_primary_key: true, + is_unique: false, extra: Some("auto_increment".into()), ..Default::default() }), diff --git a/crates/dbx-core/src/script_generator.rs b/crates/dbx-core/src/script_generator.rs index 353ca5654..e520c82e2 100644 --- a/crates/dbx-core/src/script_generator.rs +++ b/crates/dbx-core/src/script_generator.rs @@ -1363,6 +1363,7 @@ mod tests { is_nullable: true, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, @@ -2234,6 +2235,7 @@ mod tests { is_nullable: true, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, @@ -2249,6 +2251,7 @@ mod tests { is_nullable: true, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index afa2fabe4..bf208769b 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -127,6 +127,8 @@ pub struct ColumnInfo { pub is_nullable: bool, pub column_default: Option, pub is_primary_key: bool, + #[serde(default)] + pub is_unique: bool, pub extra: Option, pub comment: Option, pub numeric_precision: Option, @@ -140,6 +142,14 @@ pub struct ColumnInfo { pub collation: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableColumnsResult { + pub table_name: String, + pub columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CompletionAssistantObjectKind { diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index c742b1b41..75fafde52 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -231,6 +231,7 @@ fn column_info_serialization_roundtrip() { is_nullable: false, column_default: None, is_primary_key: true, + is_unique: true, extra: None, comment: None, numeric_precision: Some(10), @@ -241,9 +242,65 @@ fn column_info_serialization_roundtrip() { collation: None, }; let json = serde_json::to_value(&col).unwrap(); + assert_eq!(json.get("is_unique"), Some(&serde_json::json!(true))); let deserialized: ColumnInfo = serde_json::from_value(json).unwrap(); assert_eq!(col.name, deserialized.name); assert_eq!(col.numeric_precision, deserialized.numeric_precision); + assert!(deserialized.is_unique); + + let legacy = serde_json::json!({ + "name": "email", + "data_type": "varchar", + "is_nullable": true, + "column_default": null, + "is_primary_key": false, + "extra": null, + "comment": null, + "numeric_precision": null, + "numeric_scale": null, + "character_maximum_length": 255 + }); + let from_legacy: ColumnInfo = serde_json::from_value(legacy).unwrap(); + assert!(!from_legacy.is_unique); +} + +/// TableColumnsResult (get_all_columns) uses snake_case `table_name`, not camelCase. +#[test] +fn table_columns_result_serialization_contract() { + use dbx_core::db::TableColumnsResult; + + let result = TableColumnsResult { + table_name: "users".to_string(), + columns: vec![ColumnInfo { + name: "id".to_string(), + data_type: "int".to_string(), + is_nullable: false, + column_default: None, + is_primary_key: true, + is_unique: false, + extra: None, + comment: None, + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + enum_values: None, + character_set: None, + collation: None, + }], + error: Some("partial".to_string()), + }; + let json = serde_json::to_value(&result).unwrap(); + let obj = json.as_object().expect("object"); + assert!(obj.contains_key("table_name")); + assert!(!obj.contains_key("tableName")); + assert!(obj.contains_key("columns")); + assert!(obj.contains_key("error")); + assert_eq!(json["columns"][0]["is_unique"], false); + + let roundtrip: TableColumnsResult = serde_json::from_value(json).unwrap(); + assert_eq!(roundtrip.table_name, "users"); + assert_eq!(roundtrip.error.as_deref(), Some("partial")); + assert_eq!(roundtrip.columns.len(), 1); } // ============================================================================ diff --git a/crates/dbx-core/tests/bidirectional_diff_e2e.rs b/crates/dbx-core/tests/bidirectional_diff_e2e.rs index 8ce6b4dd0..4c15ec272 100644 --- a/crates/dbx-core/tests/bidirectional_diff_e2e.rs +++ b/crates/dbx-core/tests/bidirectional_diff_e2e.rs @@ -19,6 +19,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { is_nullable: false, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, diff --git a/crates/dbx-core/tests/cross_dialect_integration.rs b/crates/dbx-core/tests/cross_dialect_integration.rs index e03574f08..a45f6f625 100644 --- a/crates/dbx-core/tests/cross_dialect_integration.rs +++ b/crates/dbx-core/tests/cross_dialect_integration.rs @@ -22,6 +22,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { is_nullable: false, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, diff --git a/crates/dbx-core/tests/performance_benchmarks.rs b/crates/dbx-core/tests/performance_benchmarks.rs index 85d920b54..c806b001a 100644 --- a/crates/dbx-core/tests/performance_benchmarks.rs +++ b/crates/dbx-core/tests/performance_benchmarks.rs @@ -37,6 +37,7 @@ fn generate_details(tables: &[TableInfo], columns_per_table: usize) -> Vec
ColumnInfo { is_nullable: false, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 6c08dca9a..b982f6a60 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -1471,6 +1471,7 @@ fn infer_document_columns(documents: &[Value]) -> Vec { is_nullable: true, column_default: None, is_primary_key: false, + is_unique: false, extra: None, comment: None, numeric_precision: None, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index a55a4ab83..b703bc9a6 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -360,6 +360,7 @@ async fn main() { .route("/schema/completion-assistant", post(routes::schema::completion_assistant_search)) .route("/schema/object-source", get(routes::schema::get_object_source)) .route("/schema/columns", get(routes::schema::list_columns)) + .route("/schema/all-columns", get(routes::schema::get_all_columns)) .route("/schema/data-types", get(routes::schema::list_data_types)) .route("/schema/indexes", get(routes::schema::list_indexes)) .route("/schema/foreign-keys", get(routes::schema::list_foreign_keys)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 0d3b752b5..4298c29e9 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -356,6 +356,18 @@ pub async fn list_columns( Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } +pub async fn get_all_columns( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let database = q.database.as_deref().unwrap_or(""); + let schema = q.schema.as_deref().unwrap_or(""); + let result = dbx_core::schema::get_all_columns_core(&state.app, &q.connection_id, database, schema) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) +} + pub async fn list_data_types( State(state): State>, Query(q): Query, diff --git a/dbx-er-diagram-architecture.html b/dbx-er-diagram-architecture.html new file mode 100644 index 000000000..8b56ce549 --- /dev/null +++ b/dbx-er-diagram-architecture.html @@ -0,0 +1,800 @@ + + + + + + + DBX ER 图增强架构方案 v4 + + + + + + + +
+
开源贡献提案
+

DBX ER 图增强架构方案

+

面向 t8y2/dbx 项目的 ER 图模块重构与 ID 关联智能匹配方案,遵循 DBX 现有交互规范与存储模式

+
+
项目dbx v0.5.56
+
技术栈Tauri 2 + Vue 3 + Rust
+
协议Apache-2.0
+
日期2026-07-15
+
+
v4 — 遵循 DBX 操作规范与存储模式
+
+ +
+ +

方案审计与修订说明

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
#审计项v3 方案(原)v4 方案(修订)修订原因
1工具栏交互未对齐 DBX 现有按钮风格、布局结构和图标规范严格遵循现有 shadcn-vue Button 规范、lucide 图标、工具栏布局PR 需要与现有 UI 风格完全一致,否则会被 maintainer 要求修改
2配置存储新增 Rust 后端 match_rules.rs 持久化匹配规则沿用 localStorage + dbx:diagram:... key 前缀 + safeLocalStorageGet/SetDBX 自定义关系已用此模式存储,不引入新的存储机制
3后端改动新增 match_rules.rs(Tauri Command)、修改 schema.rs仅修改 schema.rs:新增 get_all_columnsColumnInfo.is_unique删除存储相关后端改动,存储全部在前端 localStorage 完成
+ + +

DBX 现有操作规范

+

在描述新方案之前,先明确需要遵循的现有规范。以下内容基于对 SchemaDiagramDialog.vue(v0.5.58)的源码分析。

+ +

Dialog 容器结构

+
<Dialog :open="open" @update:open="(v) => model = v">
+  <DialogContent class="w-[94vw] h-[86vh] flex flex-col p-0">
+    <DialogHeader class="px-4 py-3 border-b">
+      <DialogTitle>Network图标 + "ER 图"标题</DialogTitle>
+    </DialogHeader>
+    <!-- 工具栏 -->
+    <div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
+      ...按钮和选择器...
+    </div>
+    <!-- 可折叠面板(关系建模 / 匹配管理) -->
+    <div v-if="showPanel" class="shrink-0 border-b">...</div>
+    <!-- 画布 -->
+    <div class="min-h-0 flex-1 bg-muted/20">...</div>
+  </DialogContent>
+</Dialog>
+ +

按钮规范

+
+ + + + + + + + +
场景variantsize额外 class图标尺寸
带文字的操作按钮(建模关系、复制 SQL)outlinesmh-8 px-2 text-xsh-3.5 w-3.5 + mr-1
纯图标按钮(缩放、刷新、导出)ghosticonh-8 w-8h-4 w-4
模式切换按钮组(表模式/工程模式)ghostsmh-8 rounded-none px-2 text-xsh-3.5 w-3.5 + mr-1
面板内主要操作按钮(添加关系)defaultsmh-8 px-2 text-xsh-3.5 w-3.5 + mr-1
+
+ +

现有工具栏布局(改造前)

+
+ [Select:连接] [Select:数据库] [Select:Schema] | [🔍 搜索框] | [表模式][工程模式] | [🔗 建模关系] [📋 复制SQL] [关联表/全部表] | [Badge:表数] [Badge:关系数] [Badge:自定义关系数] | [⬇导出SVG] [🔄刷新] [➖缩小] [➕放大] [⧉重置布局] +
+ +

现有存储模式

+

DBX 的自定义关系存储使用 原生 localStorage,key 格式为 dbx:diagram:relationships:v1:<connectionId>:<database>:<schema>。全项目另有一层安全封装 safeLocalStorageGet/Set/Remove(位于 lib/backend/safeStorage.ts),使用 globalThis.localStorage + try-catch。当前 ER 图模块直接使用原生 localStorage,本方案统一迁移到 safeLocalStorage 封装。

+ +
+ 存储约束:不引入 Tauri plugin-store,不新增 Rust 后端存储命令。匹配规则的存储沿用 localStorage + dbx:diagram:... key 前缀,按"连接 + 数据库 + schema"粒度隔离,与自定义关系保持一致的存储范式。 +
+ +

现状分析与核心问题

+ +

DBX ER 图当前实现

+

+ DBX 的 ER 图功能完全自研,基于原生 HTML/CSS + SVG 渲染,没有引入任何第三方图可视化库。整个功能封装在一个约 1150 行的 SchemaDiagramDialog.vue 组件中。支持两种视图模式(Table View 和 Engineering View),正交折线连线路由(自动绕开中间表卡片),简单网格布局,缩放范围 0.6x - 1.5x,搜索过滤,聚焦模式,自定义关系建模(localStorage 持久化)和 JOIN SQL 自动生成。 +

+ +

与 DataGrip / Navicat 的差距

+
+ + + + + + + + + + +
交互维度DataGripNavicatDBX 现状
自动关系推断物理外键 + 正则匹配虚拟外键仅物理外键仅物理外键 + 手动自定义 缺少智能匹配
布局算法多种可选布局 + 方向控制Auto-Layout 一键排列简单网格 无分层布局
缩放范围无硬限制无硬限制0.6x - 1.5x 范围过窄
框选框选复制搜索筛选完全缺失
撤销/重做Ctrl+Z/Y无限次 Undo/Redo完全缺失
连线交互显示/隐藏虚拟外键悬停高亮、编辑折点SVG 箭头连线不可交互 连线无交互
+
+ +

核心问题

+
+

关系发现能力弱

仅依赖物理外键。大量项目不建外键,或使用不支持外键的数据库(MongoDB、ClickHouse),ER 图上大量表呈现为孤岛。

+

布局与交互原始

网格布局无法体现表间逻辑关系,50+ 张表时连线交叉严重。缺少框选、撤销、连线交互等基本操作。

+

单体组件架构瓶颈

全部逻辑集中在单个 1150 行 Vue 组件中,渲染、布局、路由、交互、状态管理耦合,难以扩展和测试。

+
+ +

整体架构设计

+ +

技术选型

+
+ + + + + + + + +
能力层DifyCozeDBX v4选型理由
画布框架ReactFlowFlowGram(Canvas)Vue FlowVue 3 项目;ReactFlow 忠实移植[6],gzip 49.8KB
布局引擎ELK.js(懒加载)自研ELK.js(打包)布局 + 正交边路由[7];打包确保离线可用
状态管理Zustand + ImmerMobXPinia(现有)DBX 已用 Pinia,撤销/重做内嵌到 store
配置存储localStorage + CRDT未知localStorage(现有)沿用 dbx:diagram:... key + safeLocalStorage
+
+ +

模块拆分

+
+
+graph TB
+    subgraph UI["UI 层 (Vue Components)"]
+        A["SchemaDiagramDialog.vue
Dialog 容器 + 工具栏"] + B["TableNode.vue
表卡片(Vue Flow 自定义节点)"] + C["RelationshipEdge.vue
关系线(Vue Flow 自定义边)"] + D["MatchPanel.vue
匹配规则管理面板"] + end + + subgraph Adapter["适配层"] + E["VueFlowAdapter
图数据 ↔ VueFlow 格式转换"] + end + + subgraph Core["核心引擎 (lib/diagram/)"] + F["GraphStore
Pinia + 撤销/重做历史栈"] + G["LayoutManager
ELK.js 布局 + 正交边路由"] + H["MatchEngine
ID 关联智能匹配"] + I["MatchStorage
匹配规则 localStorage 读写"] + end + + subgraph Backend["后端 (Rust) — 仅微调"] + J["schema.rs
+get_all_columns
+ColumnInfo.is_unique"] + end + + A --> E + B --> E + C --> E + F --> E + A --> G + A --> H + H --> I + F --> J +
+
图 1: v4 模块拆分架构
+
+ +

工具栏改造方案

+

新工具栏严格沿用 DBX 现有布局:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增按钮遵循现有 Button 规范,新增图标使用 lucide-vue。Vue Flow 的 Controls 和 MiniMap 作为浮动组件叠加在画布上,不占用工具栏空间。

+ +

改造后工具栏布局

+
+ [Select:连接] [Select:数据库] [Select:Schema] | [🔍 搜索框] | [表模式][工程模式] | [🔗 建模关系] [🔍 自动匹配] [📊 自动布局 ▾] [📋 复制SQL] [关联表/全部表] | [Badge:表数] [Badge:关系数] [Badge:匹配关系数] [Badge:自定义关系数] | [⬇导出SVG] [🔄刷新] ➖缩小 ➕放大 [⧉重置布局] +
+ +

新增/变更按钮明细

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
按钮位置规范图标说明
自动匹配新增"建模关系"按钮右侧variant="outline" size="sm" class="h-8 px-2 text-xs"ScanSearch(lucide,h-3.5 w-3.5)切换打开/关闭 MatchPanel 面板,复用现有关系面板的条件渲染模式
自动布局新增"自动匹配"按钮右侧variant="outline" size="sm" class="h-8 px-2 text-xs"LayoutGrid(lucide,h-3.5 w-3.5)点击触发 ELK 自动布局;下拉可选方向(LR / TB / RL / BT)
匹配关系 Badge新增Badge 区域,自定义关系 Badge 前variant="secondary" class="h-6 text-xs"显示当前自动匹配推断的关系数,点击切换显示/隐藏
缩放按钮变更工具栏右侧图标区移除,由 Vue Flow Controls 替代Vue Flow 的 <Controls /> 浮动在画布右下角,包含 +/-/fit/lock 按钮
重置布局变更工具栏右侧移除,由"自动布局"按钮替代"自动布局"按钮已包含重排功能,无需单独的重置按钮
+
+ +
+ 面板复用模式:MatchPanel 面板的交互模式完全复用现有关系建模面板的设计——通过 v-if="showMatchPanel" 条件渲染在工具栏和画布之间,包含 Select 选择器 + Button 操作 + Badge 列表。用户在 DBX 中看到的是一个与现有"建模关系"面板风格完全一致的"自动匹配"面板。 +
+ +

存储方案

+

所有匹配相关数据存储在前端 localStorage,使用 DBX 现有的 safeLocalStorageGet/Set/Remove 封装。key 格式与自定义关系保持一致的 dbx:diagram:... 前缀 + "连接 + 数据库 + schema"粒度。

+ +

存储 key 设计

+
+ + + + + + + + + +
数据类型key 格式现有/新增
自定义关系dbx:diagram:relationships:v1:<connId>:<db>:<schema>现有(保持不变)
匹配确认记录dbx:diagram:match-confirms:v1:<connId>:<db>:<schema>新增
匹配忽略记录dbx:diagram:match-ignores:v1:<connId>:<db>:<schema>新增
用户自定义正则规则dbx:diagram:match-rules:v1:<connId>:<db>:<schema>新增
匹配全局开关dbx:diagram:match-enabled新增
+
+ +

存储实现代码

+
// match-storage.ts
+import { safeLocalStorageGet, safeLocalStorageSet, safeLocalStorageRemove }
+  from "@/lib/backend/safeStorage";
+
+function matchStorageKey(
+  type: "match-confirms" | "match-ignores" | "match-rules",
+  connectionId: string,
+  database: string,
+  schema: string,
+): string {
+  return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":");
+}
+
+// 加载已确认的匹配关系
+export function loadMatchConfirms(
+  connectionId: string, database: string, schema: string
+): string[] {
+  const key = matchStorageKey("match-confirms", connectionId, database, schema);
+  try {
+    return JSON.parse(safeLocalStorageGet(key) || "[]");
+  } catch { return []; }
+}
+
+// 保存已确认的匹配关系
+export function saveMatchConfirms(
+  ids: string[], connectionId: string, database: string, schema: string
+): void {
+  const key = matchStorageKey("match-confirms", connectionId, database, schema);
+  safeLocalStorageSet(key, JSON.stringify(ids));
+}
+
+// 加载已忽略的匹配关系
+export function loadMatchIgnores(
+  connectionId: string, database: string, schema: string
+): string[] {
+  const key = matchStorageKey("match-ignores", connectionId, database, schema);
+  try {
+    return JSON.parse(safeLocalStorageGet(key) || "[]");
+  } catch { return []; }
+}
+
+// 保存已忽略的匹配关系
+export function saveMatchIgnores(
+  ids: string[], connectionId: string, database: string, schema: string
+): void {
+  const key = matchStorageKey("match-ignores", connectionId, database, schema);
+  safeLocalStorageSet(key, JSON.stringify(ids));
+}
+
+// 匹配全局开关(跨连接共享)
+export function isAutoMatchEnabled(): boolean {
+  return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false";
+}
+
+export function setAutoMatchEnabled(enabled: boolean): void {
+  safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled));
+}
+ +
+ 不新增 Rust 后端存储:v3 方案中的 match_rules.rs(Tauri Command: save_match_rules / load_match_rules)已删除。匹配规则的存储量很小(通常几十条 JSON),localStorage 完全胜任,且与 DBX 现有的自定义关系存储方式保持一致。 +
+ +

智能 ID 关联匹配引擎

+

本方案中最有价值的增量能力。DataGrip 通过正则表达式虚拟外键实现了类似功能[1],但需要用户手动配置。本方案内置开箱即用的自动匹配策略。

+ +

匹配策略分层

+
+ + + + + + + + +
优先级策略规则置信度
P0物理外键读取 INFORMATION_SCHEMA 外键约束100%
P1命名约定{table}_id / {table}_uuid → 目标表主键,支持 snake_case / camelCase高(自动确认)
P2类型签名P1 + 源列与目标列类型兼容(如都是 bigint高(自动确认)
P3正则规则用户自定义正则,如 (.*)_id$1.id[1]中(需确认)
+
+ +

匹配算法核心逻辑

+
function inferRelationships(tables: TableMeta[]): InferredRelationship[] {
+  const results: InferredRelationship[] = [];
+  const tableNameSet = new Set(tables.map(t => t.name));
+  const primaryKeys = buildPrimaryKeyIndex(tables);
+
+  for (const table of tables) {
+    for (const column of table.columns) {
+      if (column.is_primary_key) continue;
+
+      // P1: 命名约定匹配
+      const match = column.name.match(/^(.+?)_(?:id|uuid|pk)$/i);
+      if (!match) continue;
+
+      const candidateTable = toSnakeCase(match[1]);
+      if (!tableNameSet.has(candidateTable)) continue;
+
+      const targetPK = primaryKeys.get(candidateTable);
+      if (!targetPK) continue;
+
+      // P2: 类型签名校验
+      if (!isTypeCompatible(column.data_type, targetPK.data_type)) continue;
+
+      results.push({
+        sourceTable: table.name,
+        sourceColumn: column.name,
+        targetTable: candidateTable,
+        targetColumn: targetPK.name,
+        confidence: 'high',
+        strategy: 'naming_convention',
+      });
+    }
+  }
+  return deduplicate(results);
+}
+ +

匹配结果与存储交互

+

匹配引擎运行时需要与 match-storage.ts 交互,过滤已确认和已忽略的记录:

+
// match-engine.ts 中的过滤逻辑
+function filterByStorage(
+  inferred: InferredRelationship[],
+  confirms: string[],   // 从 localStorage 加载
+  ignores: string[],    // 从 localStorage 加载
+): MatchResult {
+  const confirmed = inferred.filter(r => confirms.includes(r.id));
+  const pending = inferred.filter(r =>
+    !confirms.includes(r.id) && !ignores.includes(r.id)
+      && r.confidence === 'high'
+  );
+  const conflicts = pending.filter(r => hasMultipleTargets(r, pending));
+  return {
+    relationships: [...confirmed, ...pending.filter(r => !conflicts.includes(r))],
+    conflicts,
+    pending: conflicts,
+    stats: { total: inferred.length, high: confirmed.length + pending.length, ... },
+  };
+}
+ +
+ 视觉区分:物理外键实线高亮;自动匹配(高置信)虚线半透明;待确认关系点线灰色。工具栏 Badge 区的"匹配关系"Badge 点击可切换显示/隐藏。 +
+ +

Vue Flow + ELK.js 交互设计

+ +

Vue Flow 提供的开箱即用能力

+
+ + + + + + + + + + + +
能力Vue Flow 原生DBX v1 中
节点拖拽内置 draggable手动 mousedown/move/up
缩放与平移内置 viewport,无范围限制自研 diagramZoom.ts,0.6x-1.5x
框选多选SelectionMode.Partial缺失
MiniMap<MiniMap /> 浮动组件缺失
Controls<Controls /> 浮动组件(替代工具栏 +/- 按钮)手动按钮
背景网格<Background />CSS 背景
虚拟化onlyRenderVisibleElements缺失
+
+ +

ELK.js 布局配置

+
// elk-layout.ts
+import ELK from 'elkjs/lib/elk.bundled.js';
+const elk = new ELK();
+
+export async function computeLayout(
+  graph: DiagramGraph, options: LayoutOptions
+): Promise<LayoutResult> {
+  const elkGraph = buildElkGraph(graph, options);
+  const result = await elk.layout(elkGraph);
+  return extractLayoutResult(result);
+}
+ +
+ + + + + + + + + + + +
配置项说明
elk.algorithmlayeredSugiyama 分层布局
elk.directionRIGHT / DOWN工具栏下拉切换
edgeRoutingORTHOGONAL正交折线,自动避开节点
nodePlacementBRANDES_KOEPF平衡对齐(同 Dify)
crossingMinimizationLAYER_SWEEP交叉最小化
layering.strategyNETWORK_SIMPLEX最小化边跨度
separateConnectedComponentstrue自动分离孤立子图
+
+ +

Pinia 原生撤销/重做

+

不引入第三方撤销库。在 GraphStore(Pinia)中手动维护 historyStack + redoStack,使用 lodash-escloneDeep 做快照(DBX 已通过 shadcn-vue 间接依赖 lodash-es,无新增依赖)。

+
// graph-store.ts(核心片段)
+export const useGraphStore = defineStore('diagram-graph', () => {
+  const nodes = ref<DiagramNode[]>([]);
+  const edges = ref<DiagramEdge[]>([]);
+  const historyStack = ref<HistorySnapshot[]>([]);
+  const redoStack = ref<HistorySnapshot[]>([]);
+  const maxHistorySize = 50;
+
+  function pushHistory() {
+    historyStack.value.push({
+      nodes: cloneDeep(nodes.value),
+      edges: cloneDeep(edges.value),
+    });
+    if (historyStack.value.length > maxHistorySize) historyStack.value.shift();
+    redoStack.value = [];
+  }
+
+  function undo() {
+    if (!historyStack.value.length) return;
+    redoStack.value.push({ nodes: cloneDeep(nodes.value), edges: cloneDeep(edges.value) });
+    const prev = historyStack.value.pop()!;
+    nodes.value = prev.nodes;
+    edges.value = prev.edges;
+  }
+
+  function redo() { /* 对称实现 */ }
+
+  // 仅布局调整和关系操作记录历史,选择/缩放不记录
+  function applyLayout(newNodes: DiagramNode[], newEdges: DiagramEdge[]) {
+    pushHistory();
+    nodes.value = newNodes;
+    edges.value = newEdges;
+  }
+});
+ +

后端改动(最小化)

+

v4 方案的后端改动仅限于 schema.rs,不新增任何 Tauri Command 或存储模块:

+
+ + + + + + +
改动点文件内容说明
批量列查询schema.rs新增 get_all_columns 命令一次返回 Schema 下所有表的列,避免匹配引擎逐表 IPC
列唯一键标识schema.rsColumnInfo 新增 is_unique 字段辅助匹配引擎判断目标列是否为主键或唯一键
+
+ +

文件结构规划

+
apps/desktop/src/
+├── components/diagram/
+│   ├── SchemaDiagramDialog.vue     (重构:拆分为容器 + 工具栏)
+│   ├── **TableNode.vue**           (新增:Vue Flow 自定义节点)
+│   ├── **RelationshipEdge.vue**    (新增:Vue Flow 自定义边)
+│   ├── **MatchPanel.vue**          (新增:匹配管理面板)
+│   └── **DiagramToolbar.vue**      (新增:工具栏提取)
+│
+├── lib/diagram/
+│   ├── erDiagram.ts               (保留:核心数据模型)
+│   ├── engineeringDiagram.ts      (保留:工程 ER 图)
+│   ├── **vue-flow-adapter.ts**   (新增:Vue Flow 适配层)
+│   ├── **graph-store.ts**         (新增:Pinia + 撤销/重做历史栈)
+│   ├── **layout-manager.ts**      (新增:布局调度)
+│   ├── **elk-layout.ts**          (新增:ELK.js 布局配置)
+│   ├── **layout-grid.ts**         (新增:网格布局 fallback)
+│   ├── **match-engine.ts**        (新增:智能匹配引擎)
+│   ├── **match-strategies.ts**    (新增:匹配策略)
+│   ├── **match-storage.ts**       (新增:localStorage 读写,复用 safeLocalStorage)
+│   └── fieldLineage.ts            (保留)
+│
+├── types/
+│   └── **diagram.ts**             (新增:类型定义)
+│
+└── tests/
+    ├── unit/
+    │   ├── **match-engine.test.ts**
+    │   ├── **match-storage.test.ts**
+    │   ├── **layout-manager.test.ts**
+    │   ├── **vue-flow-adapter.test.ts**
+    │   └── **graph-store.test.ts**
+    └── e2e/
+        └── **er-diagram.spec.ts**
+
+src-tauri/src/commands/
+└── schema.rs                      (微调:+get_all_columns, +is_unique)
+
+// v3 中的 match_rules.rs 已删除
+// v1 中的 edge-router.ts / interaction-manager.ts 已删除(Vue Flow / ELK 替代)
+ +

测试方案

+ +

单元测试(Vitest)

+ +

MatchEngine

+
TC-M1: 命名约定匹配 — user_id → users.id
输入 users(id PK)、orders(user_id)。期望匹配到 users.id,confidence='high'。
assert(result.relationships[0].targetTable === 'users')
+
TC-M2: 类型不匹配时拒绝
输入 users(id bigint)、orders(user_id varchar)。期望无匹配。
assert(result.relationships.length === 0)
+
TC-M3: 冲突检测 — type_id 指向多表
types 和 user_types 同时存在。期望标记冲突。
assert(result.conflicts.length > 0)
+
TC-M4: 物理外键优先不重复
物理外键 + 命名约定同时匹配。期望仅一条。
assert(result.relationships.length === 1)
+
TC-M5: camelCase 列名
Users(Id PK)、Orders(UserId)。期望匹配成功。
assert(result.relationships.length === 1)
+ +

MatchStorage

+
TC-MS1: confirms 存储与加载
保存 3 条 confirm ID 后加载。期望返回相同 3 条。
assert(loaded.length === 3 && loaded.every(id => saved.includes(id)))
+
TC-MS2: ignores 与 confirms 互不干扰
分别保存 confirms 和 ignores。期望各自加载正确。
assert(confirms !== ignores)
+
TC-MS3: 全局开关默认开启
未设置时调用 isAutoMatchEnabled()。期望 true。
assert(isAutoMatchEnabled() === true)
+ +

LayoutManager

+
TC-L1: ELK 输出无重叠
10 节点 + 9 边。期望无碰撞。
assert(!hasCollision(result.nodes))
+
TC-L2: 正交边路径含 L 命令
带端口的节点和边。期望 SVG path 包含 'L'。
assert(result.edges.every(e => e.path.includes('L')))
+
TC-L3: pinned 节点位置不变
2 个 pinned 节点。期望坐标不变。
assert(pinned.every(n => n.position === original))
+ +

GraphStore(撤销/重做)

+
TC-S1: 撤销恢复位置
拖拽 A 到 (100,100),undo。期望 A 回 (0,0)。
assert(nodes[0].position.x === 0)
+
TC-S2: redo 恢复新位置
拖拽,undo,redo。期望 A 在 (100,100)。
assert(nodes[0].position.x === 100)
+
TC-S3: 新操作清空 redo
拖拽 A,undo,拖拽 B。期望 canRedo = false。
assert(!canRedo)
+
TC-S4: 栈深度限制 50
连续 55 次 pushHistory。期望 historyStack.length === 50。
assert(historyStack.length === 50)
+ +

e2e 测试(Playwright)

+
TC-E1: 完整 ER 图加载
打开连接 → 选择数据库 → 点击"ER 图"。期望节点数 = 表数。
assert(locator('.vue-flow__node').count() === tableCount)
+
TC-E2: 无外键库的智能匹配
连接 SQLite → 打开 ER 图 → 点击"自动匹配"。期望虚线连线可见。
assert(locator('[data-kind="inferred"]').count() > 0)
+
TC-E3: ELK 自动布局
点击"自动布局"。期望节点按层级排列,连线无交叉。
assert(noOverlapping() && noIntersectingEdges())
+
TC-E4: 框选 + 批量拖拽
拖拽框选 3 节点 → 移动。期望 3 个同时移动。
assert(selectedCount === 3)
+
TC-E5: Ctrl+Z/Y 撤销重做
拖拽 → Ctrl+Z → Ctrl+Y。期望先回原位再回新位。
assert(posAfterRedo === draggedPos)
+
TC-E6: 匹配面板交互
点击"自动匹配"按钮 → 面板出现 → 点击确认一条 → 虚线变实线。
assert(confirmedEdge.getStyle().strokeDasharray === 'none')
+ +

分阶段实施计划

+ +
+
+
1
+
+

Phase 1: Vue Flow 迁移 + 智能匹配 + 撤销重做

+

目标:Vue Flow 替换自研渲染,智能匹配核心可用,Pinia 撤销/重做。

+
    +
  • Vue Flow 集成:安装 @vue-flow/core + @vue-flow/minimap + @vue-flow/controls,实现适配层
  • +
  • ELK 集成:npm 安装 elkjs(直接打包),实现分层布局 + 正交边路由
  • +
  • GraphStore:Pinia + Immer,包含手动历史栈的撤销/重做
  • +
  • 匹配引擎:实现 P1/P2 策略,match-storage.ts 使用 safeLocalStorage
  • +
  • MatchPanel:复用现有关系面板的 UI 模式(Select + Button + Badge)
  • +
  • 工具栏:新增"自动匹配"和"自动布局"按钮,遵循现有 Button 规范
  • +
  • 后端schema.rs 新增 get_all_columnsis_unique
  • +
  • 单元测试:MatchEngine、MatchStorage、LayoutManager、GraphStore
  • +
+
+
+
+
2
+
+

Phase 2: 交互增强 + e2e 测试

+

目标:框选、连线交互、MiniMap、e2e 覆盖。

+
    +
  • 框选:启用 SelectionMode.Partial
  • +
  • 连线交互RelationshipEdge.vue 悬停高亮
  • +
  • MiniMap:50+ 表时自动显示
  • +
  • 显示控制:工具栏开关控制列/注释/匹配关系
  • +
  • e2e 测试:Playwright 覆盖 6 个核心流程
  • +
+
+
+
+
3
+
+

Phase 3: 高级功能 + 性能优化

+

目标:大 Schema 支持、高级分析、P3 正则规则。

+
    +
  • 虚拟化onlyRenderVisibleElements,支持 200+ 表
  • +
  • 路径过滤:选中两节点,仅显示关联路径
  • +
  • 正则规则:P3 用户自定义正则匹配,存储到 localStorage
  • +
  • 侧边栏拖入:Vue Flow DnD 增量添加表
  • +
+
+
+
+ +

关键设计决策

+ +

为什么工具栏新增按钮而非重新设计

+

+ DBX 的工具栏已有固定的布局节奏:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增的"自动匹配"和"自动布局"按钮插入到操作按钮区域("建模关系"按钮右侧),遵循 variant="outline" size="sm" class="h-8 px-2 text-xs" + lucide 图标 h-3.5 w-3.5 + mr-1 的规范。缩放和重置按钮移除后由 Vue Flow 的浮动 Controls 组件替代,不占用工具栏空间。这种增量式改动与现有 UI 风格完全一致,PR 审查时不会因为"风格不统一"被要求返工。 +

+ +

为什么用 localStorage 而非新增后端存储

+

+ DBX 的自定义关系已使用 localStorage + dbx:diagram:relationships:v1:... key 模式存储。匹配规则的存储需求与自定义关系完全相同(按连接+数据库+schema 隔离、数据量小、JSON 序列化),没有必要引入新的存储机制。使用已有的 safeLocalStorageGet/Set/Remove 封装(而非直接 localStorage),可以统一错误处理,比现有自定义关系代码更健壮。 +

+ +

为什么 ELK.js 直接打包

+

+ DBA 常在无网络的内网环境使用数据库管理工具。懒加载在离线场景下会导致布局功能不可用。直接打包后 ELK.js 随安装包分发,增量约 300KB(gzip ~50KB),对 20MB 的 DBX 影响约 1.7%。 +

+ +

为什么不选 Coze 的 FlowGram

+

+ FlowGram 基于 Canvas 自研渲染引擎,定位是 AI 工作流编排(内置变量引擎、表单引擎)。Canvas 渲染文本排版远不如 HTML,不适合包含多行列信息的表卡片。对 ER 图来说严重过度设计。 +

+ +

测试用例索引

+
+ + + + + + + + + +
编号模块类型数量
TC-M1 ~ M5MatchEngine单元5
TC-MS1 ~ MS3MatchStorage单元3
TC-L1 ~ L3LayoutManager单元3
TC-S1 ~ S4GraphStore单元4
TC-E1 ~ E6ER 图全流程e2e6
+
+ + + + + + + + + + \ No newline at end of file diff --git a/flake.nix b/flake.nix index 538cedaa4..c215798b7 100644 --- a/flake.nix +++ b/flake.nix @@ -197,7 +197,7 @@ fetcherVersion = 4; # Update with the hash reported by a failed fixed-output build: # nix build .#dbx-pnpm-deps 2>&1 | grep 'got:' - hash = "sha256-JtyKCHXkgQ/xfPrfi2DhQJ3wzmux40RfNivyfSlhgkY="; + hash = "sha256-NKYI5zHV7rnFhK2Elm2On6ffRJty4BbJcYaW9gRqPa8="; }; # ── Step 2: vendor Cargo dependencies ───────────────────────────── # diff --git a/package.json b/package.json index 4f6fdda48..fef1c7da9 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,10 @@ "@uiw/codemirror-theme-okaidia": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-xcode": "^4.25.10", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^14.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -82,6 +86,7 @@ "diff": "^9.0.0", "dom-to-image-more": "^3.7.2", "echarts": "^6.1.0", + "elkjs": "^0.11.1", "leaflet": "^1.9.4", "marked": "^18.0.4", "pinia": "^3.0.0", diff --git a/packages/app-tests/diagramCardinality.test.ts b/packages/app-tests/diagramCardinality.test.ts new file mode 100644 index 000000000..8b1d6fbf0 --- /dev/null +++ b/packages/app-tests/diagramCardinality.test.ts @@ -0,0 +1,44 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + cardinalityChoiceFromPair, + cardinalityPairFromChoice, + edgeCardinalityPair, + type CardinalityChoice, +} from "../../apps/desktop/src/lib/diagram/cardinality.ts"; + +const CHOICES: CardinalityChoice[] = ["one-to-one", "one-to-many", "many-to-one", "many-to-many"]; + +test("cardinalityPairFromChoice maps all four choices", () => { + assert.deepEqual(cardinalityPairFromChoice("one-to-one"), { sourceCardinality: "1", targetCardinality: "1" }); + assert.deepEqual(cardinalityPairFromChoice("one-to-many"), { sourceCardinality: "1", targetCardinality: "N" }); + assert.deepEqual(cardinalityPairFromChoice("many-to-one"), { sourceCardinality: "N", targetCardinality: "1" }); + assert.deepEqual(cardinalityPairFromChoice("many-to-many"), { sourceCardinality: "N", targetCardinality: "N" }); +}); + +test("cardinalityChoiceFromPair maps all four pairs and falls back to many-to-one", () => { + assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1", targetCardinality: "1" }), "one-to-one"); + assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1", targetCardinality: "N" }), "one-to-many"); + assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "N", targetCardinality: "1" }), "many-to-one"); + assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "N", targetCardinality: "N" }), "many-to-many"); + assert.equal(cardinalityChoiceFromPair(undefined), "many-to-one"); + assert.equal(cardinalityChoiceFromPair(null), "many-to-one"); + assert.equal(cardinalityChoiceFromPair({}), "many-to-one"); + assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1" }), "many-to-one"); +}); + +test("edgeCardinalityPair returns explicit pair or defaults to N:1", () => { + assert.deepEqual(edgeCardinalityPair({ sourceCardinality: "1", targetCardinality: "N" }), { + sourceCardinality: "1", + targetCardinality: "N", + }); + assert.deepEqual(edgeCardinalityPair({}), { sourceCardinality: "N", targetCardinality: "1" }); + assert.deepEqual(edgeCardinalityPair(undefined), { sourceCardinality: "N", targetCardinality: "1" }); + assert.deepEqual(edgeCardinalityPair(null), { sourceCardinality: "N", targetCardinality: "1" }); +}); + +test("choice → pair → choice round-trips for all choices", () => { + for (const choice of CHOICES) { + assert.equal(cardinalityChoiceFromPair(cardinalityPairFromChoice(choice)), choice); + } +}); diff --git a/packages/app-tests/diagramDialectAdapter.test.ts b/packages/app-tests/diagramDialectAdapter.test.ts new file mode 100644 index 000000000..31faea0e5 --- /dev/null +++ b/packages/app-tests/diagramDialectAdapter.test.ts @@ -0,0 +1,42 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { resolveDiagramDialectAdapter } from "../../apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts"; + +test("resolveDiagramDialectAdapter default id types by dialect", () => { + assert.equal(resolveDiagramDialectAdapter("postgres").createDefaultIdColumn().data_type, "bigint"); + assert.equal(resolveDiagramDialectAdapter("mysql").createDefaultIdColumn().data_type, "bigint"); + // Prefer dialect default when listed; otherwise first integer-like option (Oracle often exposes "number"). + assert.match(resolveDiagramDialectAdapter("oracle").createDefaultIdColumn().data_type, /^number$/i); + assert.match(resolveDiagramDialectAdapter("sqlite").createDefaultIdColumn().data_type, /^integer$/i); + assert.match(resolveDiagramDialectAdapter("clickhouse").createDefaultIdColumn().data_type, /^uint64$/i); + assert.match(resolveDiagramDialectAdapter("duckdb").createDefaultIdColumn().data_type, /^integer$/i); + assert.match(resolveDiagramDialectAdapter("h2").createDefaultIdColumn().data_type, /^bigint$/i); +}); + +test("resolveDiagramDialectAdapter unknown dialect falls back", () => { + const adapter = resolveDiagramDialectAdapter(undefined); + const id = adapter.createDefaultIdColumn(); + assert.equal(id.name, "id"); + assert.equal(id.is_primary_key, true); + assert.ok(typeof id.data_type === "string" && id.data_type.length > 0); + assert.equal(adapter.databaseType, undefined); +}); + +test("resolveDiagramDialectAdapter createEmptyColumn defaults", () => { + const col = resolveDiagramDialectAdapter("postgres").createEmptyColumn("foo"); + assert.equal(col.name, "foo"); + assert.equal(col.is_primary_key, false); + assert.equal(col.is_nullable, true); + assert.ok(col.data_type); +}); + +test("adapter remains a thin column factory without capability fields", () => { + const adapter = resolveDiagramDialectAdapter("postgres") as Record; + assert.equal(typeof adapter.createDefaultIdColumn, "function"); + assert.equal(typeof adapter.createEmptyColumn, "function"); + assert.equal("supportsCreateTable" in adapter, false); + assert.equal("supportsCreateIndex" in adapter, false); + assert.equal("supportsComment" in adapter, false); + assert.equal("supportsDropColumn" in adapter, false); + assert.equal("dataTypeOptions" in adapter, false); +}); diff --git a/packages/app-tests/diagramDraftStorage.test.ts b/packages/app-tests/diagramDraftStorage.test.ts new file mode 100644 index 000000000..ec16e2dcc --- /dev/null +++ b/packages/app-tests/diagramDraftStorage.test.ts @@ -0,0 +1,122 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test, vi } from "vitest"; +import { + hasUsablePersistedPositions, + loadDraftTables, + loadPersistedLayers, + loadPersistedPositions, + saveDraftTables, + savePersistedLayers, + savePersistedPositions, +} from "../../apps/desktop/src/lib/diagram/draft-storage.ts"; +import { createDraftTable } from "../../apps/desktop/src/lib/diagram/draft-table.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }); +}); + +test("savePersistedPositions / loadPersistedPositions round-trip", () => { + const positions = { + users: { x: 10, y: 20 }, + roles: { x: 100.5, y: -3 }, + }; + savePersistedPositions(positions, "conn-1", "db", "public"); + const loaded = loadPersistedPositions("conn-1", "db", "public"); + assert.deepEqual(loaded, positions); +}); + +test("loadPersistedPositions drops invalid entries", () => { + store.set( + ["dbx", "diagram", "positions", "v1", "c", "d", "s"].join(":"), + JSON.stringify({ + ok: { x: 1, y: 2 }, + bad: { x: "no", y: 2 }, + missingY: { x: 1 }, + nan: { x: Number.NaN, y: 0 }, + }), + ); + const loaded = loadPersistedPositions("c", "d", "s"); + assert.deepEqual(loaded, { ok: { x: 1, y: 2 } }); +}); + +test("hasUsablePersistedPositions requires at least one known table", () => { + assert.equal(hasUsablePersistedPositions({ users: { x: 0, y: 0 } }, ["roles"]), false); + assert.equal(hasUsablePersistedPositions({ users: { x: 0, y: 0 } }, ["users", "roles"]), true); + assert.equal(hasUsablePersistedPositions({}, ["users"]), false); +}); + +test("saveDraftTables / loadDraftTables round-trip and only persists drafts", () => { + const draft = createDraftTable("orders"); + const live: DiagramTable = { + name: "users", + columns: draft.columns, + foreignKeys: [], + origin: "live", + }; + saveDraftTables([draft, live], "conn-1", "db", "public"); + const loaded = loadDraftTables("conn-1", "db", "public"); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].name, "orders"); + assert.equal(loaded[0].origin, "draft"); + assert.equal(loaded[0].syncStatus, "pending"); +}); + +test("loadDraftTables drops invalid entries and normalizes origin/syncStatus", () => { + store.set( + ["dbx", "diagram", "draft-tables", "v1", "c", "d", "s"].join(":"), + JSON.stringify([ + { name: "ok", columns: [], foreignKeys: [], syncStatus: "error" }, + { name: 123, columns: [] }, + { name: "no-cols" }, + null, + ]), + ); + const loaded = loadDraftTables("c", "d", "s"); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].name, "ok"); + assert.equal(loaded[0].origin, "draft"); + assert.equal(loaded[0].syncStatus, "error"); +}); + +test("savePersistedLayers / loadPersistedLayers round-trip", () => { + const layers: DiagramLayer[] = [ + { + id: "l1", + name: "Core", + color: "#3b82f6", + tableNames: ["users"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 10, y: 20 }, + width: 240, + height: 100, + }, + ]; + savePersistedLayers(layers, "conn-1", "db", "public"); + assert.deepEqual(loadPersistedLayers("conn-1", "db", "public"), layers); +}); + +test("empty connectionId or database skips write and load returns empty", () => { + saveDraftTables([createDraftTable("t")], "", "db", "public"); + saveDraftTables([createDraftTable("t")], "c", "", "public"); + savePersistedLayers([], "", "db", "public"); + savePersistedPositions({ t: { x: 1, y: 2 } }, "c", "", "public"); + assert.equal(store.size, 0); + assert.deepEqual(loadDraftTables("", "db", "public"), []); + assert.deepEqual(loadPersistedLayers("c", "", "public"), []); + assert.deepEqual(loadPersistedPositions("", "db", "public"), {}); +}); diff --git a/packages/app-tests/diagramDraftTable.test.ts b/packages/app-tests/diagramDraftTable.test.ts new file mode 100644 index 000000000..aaa44ef85 --- /dev/null +++ b/packages/app-tests/diagramDraftTable.test.ts @@ -0,0 +1,86 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + createDraftIndex, + createDraftTable, + createEmptyColumn, + draftTableToCreateSqlOptions, + nextUniqueColumnName, + validateDraftTable, +} from "../../apps/desktop/src/lib/diagram/draft-table.ts"; +import { isDraftTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { EditableStructureIndex } from "../../apps/desktop/src/lib/table/tableStructureEditorSql.ts"; + +test("createDraftTable marks origin draft and optional id pk", () => { + const withId = createDraftTable("users"); + assert.equal(withId.origin, "draft"); + assert.equal(isDraftTable(withId), true); + assert.equal(withId.columns.length, 1); + assert.equal(withId.columns[0].name, "id"); + assert.equal(withId.columns[0].is_primary_key, true); + + const empty = createDraftTable("orders", { withDefaultId: false }); + assert.equal(empty.columns.length, 0); + assert.deepEqual(validateDraftTable(empty), ['Table "orders" needs at least one column']); +}); + +test("validateDraftTable catches duplicates", () => { + const table = createDraftTable("t", { withDefaultId: false }); + table.columns = [createEmptyColumn("a"), createEmptyColumn("a")]; + const errors = validateDraftTable(table); + assert.ok(errors.some((e) => e.includes("duplicate"))); +}); + +test("nextUniqueColumnName increments", () => { + assert.equal(nextUniqueColumnName([{ name: "column_1", data_type: "int", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]), "column_2"); +}); + +test("createDraftIndex generates unique names", () => { + const first = createDraftIndex("users", ["email"]); + const second = createDraftIndex("users", ["email"], [first]); + assert.ok(first.name); + assert.ok(second.name); + assert.notEqual(first.name, second.name); + assert.deepEqual(first.columns, ["email"]); +}); + +test("validateDraftTable catches empty name and index errors", () => { + const table = createDraftTable(" ", { withDefaultId: false }); + table.columns = [createEmptyColumn("id")]; + const emptyNameIndex: EditableStructureIndex = { + id: "i1", + name: " ", + columns: [], + isUnique: false, + isPrimary: false, + filter: "", + indexType: "", + includedColumns: [], + comment: "", + markedForDrop: false, + }; + const missingColIndex: EditableStructureIndex = { + ...emptyNameIndex, + id: "i2", + name: "idx_missing", + columns: ["nope"], + }; + table.indexes = [emptyNameIndex, missingColIndex]; + const errors = validateDraftTable(table); + assert.ok(errors.some((e) => e.includes("Table name is required"))); + assert.ok(errors.some((e) => e.includes("empty name"))); + assert.ok(errors.some((e) => e.includes("needs at least one column"))); + assert.ok(errors.some((e) => e.includes("missing column"))); +}); + +test("draftTableToCreateSqlOptions shape", () => { + const table = createDraftTable("users"); + table.indexes = [createDraftIndex("users", ["id"])]; + const options = draftTableToCreateSqlOptions(table, "postgres", "public"); + assert.equal(options.tableName, "users"); + assert.equal(options.schema, "public"); + assert.equal(options.databaseType, "postgres"); + assert.equal(options.columns.length, 1); + assert.equal(options.indexes.length, 1); + assert.deepEqual(options.foreignKeys, []); +}); diff --git a/packages/app-tests/diagramEdgeRouter.test.ts b/packages/app-tests/diagramEdgeRouter.test.ts new file mode 100644 index 000000000..cac077c05 --- /dev/null +++ b/packages/app-tests/diagramEdgeRouter.test.ts @@ -0,0 +1,224 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { Position } from "@vue-flow/core"; +import { + alignWaypointsToEndpoints, + collapseColinearPoints, + dedupePoints, + handlesFromWaypoints, + pathHitsObstacles, + pathSkimsEndpoints, + pointsToSvgPath, + polylineLength, + routeOrthogonalAroundObstacles, + type ObstacleRect, +} from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts"; +import { EDGE_ROUTE_OFFSET } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts"; + +test("pointsToSvgPath and dedupePoints", () => { + assert.equal(pointsToSvgPath([]), ""); + assert.equal(pointsToSvgPath([{ x: 1, y: 2 }, { x: 3, y: 4 }]), "M1,2 L3,4"); + assert.deepEqual( + dedupePoints([ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10.2, y: 0 }, + ]), + [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + ); +}); + +test("collapseColinearPoints removes middle points on a straight run", () => { + assert.deepEqual( + collapseColinearPoints([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 10 }, + ]), + [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 10 }, + ], + ); +}); + +test("pathHitsObstacles detects crossing segments", () => { + const obstacle: ObstacleRect = { + id: "block", + x: 40, + y: 40, + width: 100, + height: 100, + kind: "table", + }; + assert.equal( + pathHitsObstacles( + [ + { x: 0, y: 90 }, + { x: 200, y: 90 }, + ], + [obstacle], + ), + true, + ); + assert.equal( + pathHitsObstacles( + [ + { x: 0, y: 10 }, + { x: 200, y: 10 }, + ], + [obstacle], + ), + false, + ); +}); + +test("pathSkimsEndpoints detects middle segment on table border", () => { + const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" }; + assert.equal( + pathSkimsEndpoints( + [ + { x: 360, y: 100 }, + { x: 396, y: 100 }, + { x: 360, y: 100 }, + { x: 360, y: 400 }, + { x: 396, y: 400 }, + { x: 500, y: 400 }, + ], + [tall], + ), + true, + ); + assert.equal( + pathSkimsEndpoints( + [ + { x: 360, y: 100 }, + { x: 396, y: 100 }, + { x: 396, y: 400 }, + { x: 464, y: 400 }, + { x: 500, y: 400 }, + ], + [tall], + ), + false, + ); +}); + +test("routeOrthogonalAroundObstacles returns a clear orthogonal path", () => { + const obstacle: ObstacleRect = { + id: "mid", + x: 80, + y: 40, + width: 40, + height: 40, + kind: "table", + }; + const path = routeOrthogonalAroundObstacles({ + source: { x: 0, y: 0 }, + target: { x: 200, y: 0 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + obstacles: [obstacle], + endpointIds: ["a", "b"], + }); + assert.ok(path); + assert.ok(path!.length >= 2); + assert.equal(pathHitsObstacles(path!, [obstacle]), false); +}); + +test("routeOrthogonalAroundObstacles prefers short stubbed corridor over far detour", () => { + const path = routeOrthogonalAroundObstacles({ + source: { x: 0, y: 0 }, + target: { x: 100, y: 50 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + obstacles: [], + endpointIds: ["a", "b"], + }); + assert.ok(path); + const farDetourLen = polylineLength([ + { x: 0, y: 0 }, + { x: EDGE_ROUTE_OFFSET, y: 0 }, + { x: 100 + EDGE_ROUTE_OFFSET, y: 0 }, + { x: 100 + EDGE_ROUTE_OFFSET, y: 50 }, + { x: 100 - EDGE_ROUTE_OFFSET, y: 50 }, + { x: 100, y: 50 }, + ]); + assert.ok(polylineLength(path!) < farDetourLen); +}); + +test("routeOrthogonalAroundObstacles does not skim tall endpoint table border", () => { + const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" }; + const other: ObstacleRect = { id: "other", x: 500, y: 400, width: 360, height: 120, kind: "table" }; + const rightEdge = 360; + const path = routeOrthogonalAroundObstacles({ + source: { x: rightEdge, y: 120 }, + target: { x: 500, y: 460 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + obstacles: [tall, other], + endpointIds: ["tall", "other"], + offset: EDGE_ROUTE_OFFSET, + }); + assert.ok(path); + assert.equal(pathSkimsEndpoints(path!, [tall, other]), false); + + for (let i = 1; i < path!.length - 2; i++) { + const a = path![i]; + const b = path![i + 1]; + const verticalOnBorder = Math.abs(a.x - rightEdge) <= 0.5 && Math.abs(b.x - rightEdge) <= 0.5; + const run = Math.abs(a.y - b.y); + assert.ok(!(verticalOnBorder && run > EDGE_ROUTE_OFFSET), `middle segment skims right edge: ${JSON.stringify([a, b])}`); + } +}); + +test("alignWaypointsToEndpoints rejects paths that skim endpoints", () => { + const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" }; + const other: ObstacleRect = { id: "other", x: 500, y: 400, width: 360, height: 120, kind: "table" }; + const aligned = alignWaypointsToEndpoints( + [ + { x: 360, y: 120 }, + { x: 360, y: 460 }, + { x: 500, y: 460 }, + ], + 360, + 120, + 500, + 460, + { obstacles: [tall, other], endpointIds: ["tall", "other"] }, + ); + assert.equal(aligned, null); +}); + +test("handlesFromWaypoints and alignWaypointsToEndpoints", () => { + const handles = handlesFromWaypoints([ + { x: 0, y: 50 }, + { x: 40, y: 50 }, + { x: 40, y: 100 }, + { x: 120, y: 100 }, + ]); + assert.deepEqual(handles, { sourceHandle: "right", targetHandle: "left-target" }); + assert.equal(handlesFromWaypoints([{ x: 0, y: 0 }]), null); + + const aligned = alignWaypointsToEndpoints( + [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 80 }, + { x: 100, y: 80 }, + ], + 10, + 20, + 200, + 90, + ); + assert.ok(aligned); + assert.deepEqual(aligned![0], { x: 10, y: 20 }); + assert.deepEqual(aligned![aligned!.length - 1], { x: 200, y: 90 }); +}); diff --git a/packages/app-tests/diagramElkLayout.test.ts b/packages/app-tests/diagramElkLayout.test.ts new file mode 100644 index 000000000..57a60838c --- /dev/null +++ b/packages/app-tests/diagramElkLayout.test.ts @@ -0,0 +1,61 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { computeLayoutWithLayers } from "../../apps/desktop/src/lib/diagram/elk-layout.ts"; +import type { DiagramEdge, DiagramLayer, DiagramNode } from "../../apps/desktop/src/types/diagram.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; + +function table(name: string): DiagramTable { + return { + name, + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + ], + foreignKeys: [], + }; +} + +test("computeLayoutWithLayers returns finite coords and layerLayouts for nested auto layer", async () => { + const nodes: DiagramNode[] = [ + { id: "users", type: "table", position: { x: 0, y: 0 }, data: { table: table("users") } }, + { id: "orders", type: "table", position: { x: 0, y: 0 }, data: { table: table("orders") } }, + ]; + const edges: DiagramEdge[] = [ + { id: "e1", source: "orders", target: "users", sourceHandle: "right", targetHandle: "left-target" }, + ]; + const layers: DiagramLayer[] = [ + { + id: "layer-1", + name: "Core", + color: "#3b82f6", + tableNames: ["users", "orders"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 240, + height: 52, + }, + ]; + + const result = await computeLayoutWithLayers(nodes, edges, layers); + assert.equal(result.nodes.length, 2); + assert.equal(result.layerLayouts.length, 1); + assert.equal(result.layerLayouts[0].layerId, "layer-1"); + assert.ok(Number.isFinite(result.layerLayouts[0].x)); + assert.ok(Number.isFinite(result.layerLayouts[0].width)); + assert.ok(result.layerLayouts[0].width > 0); + assert.ok(result.layerLayouts[0].height > 0); + + for (const node of result.nodes) { + assert.ok(Number.isFinite(node.position.x)); + assert.ok(Number.isFinite(node.position.y)); + } + + const layer = result.layerLayouts[0]; + for (const node of result.nodes) { + assert.ok(node.position.x >= layer.x - 1, `${node.id} x outside layer`); + assert.ok(node.position.y >= layer.y - 1, `${node.id} y outside layer`); + assert.ok(node.position.x <= layer.x + layer.width + 1, `${node.id} exceeds layer width`); + assert.ok(node.position.y <= layer.y + layer.height + 1, `${node.id} exceeds layer height`); + } +}); diff --git a/packages/app-tests/diagramErHelpers.test.ts b/packages/app-tests/diagramErHelpers.test.ts new file mode 100644 index 000000000..a7af37dcc --- /dev/null +++ b/packages/app-tests/diagramErHelpers.test.ts @@ -0,0 +1,103 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + filterAssignableDiagramTables, + hasDroppedColumns, + hasPendingColumns, + isDiagramTableAssignable, + isDraftTable, + isDroppedColumn, + isLiveTable, + isPendingColumn, + needsDiagramSync, + type DiagramTable, +} from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; + +function col(name: string): ColumnInfo { + return { + name, + data_type: "varchar(255)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }; +} + +function table(partial: Partial & { name: string }): DiagramTable { + return { + columns: [], + foreignKeys: [], + ...partial, + }; +} + +test("isDraftTable / isLiveTable treat missing origin as live", () => { + const draft = table({ name: "d", origin: "draft" }); + const live = table({ name: "l", origin: "live" }); + const legacy = table({ name: "x" }); + + assert.equal(isDraftTable(draft), true); + assert.equal(isLiveTable(draft), false); + assert.equal(isDraftTable(live), false); + assert.equal(isLiveTable(live), true); + assert.equal(isDraftTable(legacy), false); + assert.equal(isLiveTable(legacy), true); +}); + +test("hasPendingColumns / isPendingColumn", () => { + const withPending = table({ + name: "users", + origin: "live", + columns: [col("id"), col("nickname")], + pendingColumnNames: ["nickname"], + }); + assert.equal(hasPendingColumns(withPending), true); + assert.equal(isPendingColumn(withPending, "nickname"), true); + assert.equal(isPendingColumn(withPending, "id"), false); + + const empty = table({ name: "users", origin: "live", pendingColumnNames: [] }); + assert.equal(hasPendingColumns(empty), false); + assert.equal(hasPendingColumns(table({ name: "users" })), false); +}); + +test("needsDiagramSync for draft and live pending", () => { + assert.equal(needsDiagramSync(table({ name: "d", origin: "draft" })), true); + assert.equal( + needsDiagramSync(table({ name: "l", origin: "live", pendingColumnNames: ["x"], columns: [col("x")] })), + true, + ); + assert.equal(needsDiagramSync(table({ name: "l", origin: "live" })), false); + assert.equal(needsDiagramSync(table({ name: "legacy" })), false); +}); + +test("hasDroppedColumns / isDroppedColumn / needsDiagramSync for drops", () => { + const withDrop = table({ + name: "users", + origin: "live", + columns: [col("id"), col("nickname")], + droppedColumnNames: ["nickname"], + }); + assert.equal(hasDroppedColumns(withDrop), true); + assert.equal(isDroppedColumn(withDrop, "nickname"), true); + assert.equal(isDroppedColumn(withDrop, "id"), false); + assert.equal(needsDiagramSync(withDrop), true); + + const pendingDrop = table({ name: "orders", origin: "live", pendingDrop: true }); + assert.equal(needsDiagramSync(pendingDrop), true); +}); + +test("filterAssignableDiagramTables excludes pendingDrop tables", () => { + const live = table({ name: "users", origin: "live" }); + const pendingDrop = table({ name: "orders", origin: "live", pendingDrop: true }); + const draft = table({ name: "draft_t", origin: "draft" }); + + assert.equal(isDiagramTableAssignable(live), true); + assert.equal(isDiagramTableAssignable(pendingDrop), false); + assert.equal(isDiagramTableAssignable(draft), true); + assert.deepEqual( + filterAssignableDiagramTables([live, pendingDrop, draft]).map((t) => t.name), + ["users", "draft_t"], + ); +}); diff --git a/packages/app-tests/diagramFormats.test.ts b/packages/app-tests/diagramFormats.test.ts new file mode 100644 index 000000000..bd23e7daa --- /dev/null +++ b/packages/app-tests/diagramFormats.test.ts @@ -0,0 +1,165 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { buildDiagramDbml, buildDiagramJson, buildDiagramMermaid, diagramExportDialogFilter, diagramExportFileName, type DiagramJsonSnapshot } from "../../apps/desktop/src/lib/export/diagramFormats.ts"; +import { buildDiagramRelationships, type DiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; + +const tables: DiagramTable[] = [ + { + name: "users", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "created at", data_type: "timestamp with time zone", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [], + }, + { + name: "order-items", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [], + }, +]; + +function rel(partial: Pick & Partial>): DiagramRelationship { + return { + id: partial.id ?? "rel-1", + sourceTable: partial.sourceTable ?? "users", + sourceColumn: partial.sourceColumn ?? "id", + targetTable: partial.targetTable ?? "order-items", + targetColumn: partial.targetColumn ?? "user_id", + sourceCardinality: partial.sourceCardinality, + targetCardinality: partial.targetCardinality, + }; +} + +test("diagramExportFileName builds safe names for each format and mode", () => { + assert.equal(diagramExportFileName("", "", "table", "svg"), "dbx-diagram-table-structure.svg"); + assert.equal(diagramExportFileName("prod/main", "billing db", "engineering", "png"), "dbx-prod-main-billing-db-engineering-er.png"); + assert.equal(diagramExportFileName("a", "b", "table", "json"), "dbx-a-b-diagram.json"); + assert.equal(diagramExportFileName("a", "b", "table", "dbml"), "dbx-a-b-schema.dbml"); + assert.equal(diagramExportFileName("a", "b", "engineering", "mermaid"), "dbx-a-b-er.mmd"); +}); + +test("buildDiagramJson pretty-prints with trailing newline", () => { + const snapshot: DiagramJsonSnapshot = { + meta: { + connectionName: "local", + database: "app", + schema: "public", + mode: "table", + exportedAt: "2026-01-01T00:00:00.000Z", + }, + tables: [], + relationships: [], + positions: {}, + layers: [], + customRelationships: [], + matchConfirms: [], + matchIgnores: [], + }; + const text = buildDiagramJson(snapshot); + assert.ok(text.endsWith("\n")); + assert.deepEqual(JSON.parse(text), snapshot); +}); + +test("buildDiagramJson round-trips non-empty layers", () => { + const snapshot: DiagramJsonSnapshot = { + meta: { + connectionName: "local", + database: "app", + schema: "public", + mode: "table", + exportedAt: "2026-01-01T00:00:00.000Z", + }, + tables: [], + relationships: [], + positions: {}, + layers: [ + { + id: "layer-1", + name: "Core", + color: "#3b82f6", + tableNames: ["users"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 40, y: 40 }, + width: 400, + height: 240, + }, + ], + customRelationships: [], + matchConfirms: [], + matchIgnores: [], + }; + const parsed = JSON.parse(buildDiagramJson(snapshot)) as DiagramJsonSnapshot; + assert.equal(parsed.layers.length, 1); + assert.deepEqual(parsed.layers[0], snapshot.layers[0]); +}); + +test("buildDiagramDbml emits tables, quoted types, and Ref operators", () => { + const dbml = buildDiagramDbml(tables, [rel({ sourceCardinality: "1", targetCardinality: "1" }), rel({ id: "r2", sourceCardinality: "N", targetCardinality: "1" }), rel({ id: "r3", sourceCardinality: "1", targetCardinality: "N" }), rel({ id: "r4", sourceCardinality: "N", targetCardinality: "N" })]); + + assert.match(dbml, /Table users \{/); + assert.match(dbml, /id bigint \[pk, not null\]/); + assert.match(dbml, /"created at" "timestamp with time zone"/); + assert.match(dbml, /Table "order-items"/); + assert.match(dbml, /Ref: users\.id - "order-items"\.user_id/); + assert.match(dbml, /Ref: users\.id > "order-items"\.user_id/); + assert.match(dbml, /Ref: users\.id < "order-items"\.user_id/); + assert.match(dbml, /Ref: users\.id <> "order-items"\.user_id/); +}); + +test("buildDiagramMermaid emits erDiagram entities, PK markers, and cardinalities", () => { + const relationships = buildDiagramRelationships([ + tables[0], + { + ...tables[1], + name: "orders", + foreignKeys: [{ name: "fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ]); + const mermaid = buildDiagramMermaid([tables[0], { ...tables[1], name: "orders", foreignKeys: [{ name: "fk", column: "user_id", ref_table: "users", ref_column: "id" }] }], relationships); + + assert.ok(mermaid.startsWith("erDiagram\n")); + assert.match(mermaid, /bigint id PK/); + assert.match(mermaid, /\}o--\|\|/); +}); + +test("buildDiagramMermaid maps all cardinality pairs", () => { + const oneOne = buildDiagramMermaid(tables, [rel({ sourceCardinality: "1", targetCardinality: "1" })]); + const oneN = buildDiagramMermaid(tables, [rel({ sourceCardinality: "1", targetCardinality: "N" })]); + const nOne = buildDiagramMermaid(tables, [rel({ sourceCardinality: "N", targetCardinality: "1" })]); + const nN = buildDiagramMermaid(tables, [rel({ sourceCardinality: "N", targetCardinality: "N" })]); + + assert.match(oneOne, /\|\|--\|\|/); + assert.match(oneN, /\|\|--o\{/); + assert.match(nOne, /\}o--\|\|/); + assert.match(nN, /\}o--o\{/); +}); + +test("buildDiagramRelationships unique FK exports as 1:1 in Mermaid/DBML", () => { + const profileTables: DiagramTable[] = [ + tables[0], + { + name: "profiles", + columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [{ name: "profiles_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ]; + const relationships = buildDiagramRelationships(profileTables); + assert.equal(relationships[0].sourceCardinality, "1"); + assert.equal(relationships[0].targetCardinality, "1"); + assert.match(buildDiagramMermaid(profileTables, relationships), /\|\|--\|\|/); + assert.match(buildDiagramDbml(profileTables, relationships), /Ref: profiles\.user_id - users\.id/); +}); + +test("diagramExportDialogFilter returns expected extensions", () => { + assert.deepEqual(diagramExportDialogFilter("svg").extensions, ["svg"]); + assert.deepEqual(diagramExportDialogFilter("png").extensions, ["png"]); + assert.deepEqual(diagramExportDialogFilter("json").extensions, ["json"]); + assert.deepEqual(diagramExportDialogFilter("dbml").extensions, ["dbml"]); + assert.deepEqual(diagramExportDialogFilter("mermaid").extensions, ["mmd", "md"]); +}); diff --git a/packages/app-tests/diagramGraphStore.test.ts b/packages/app-tests/diagramGraphStore.test.ts new file mode 100644 index 000000000..e63e61dcd --- /dev/null +++ b/packages/app-tests/diagramGraphStore.test.ts @@ -0,0 +1,130 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useGraphStore } from "../../apps/desktop/src/lib/diagram/graph-store.ts"; +import type { HistorySnapshot } from "../../apps/desktop/src/types/diagram.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; + +function emptyTable(name: string): DiagramTable { + return { name, columns: [], foreignKeys: [] }; +} + +function snapshot(partial: Partial = {}): HistorySnapshot { + return { + nodes: [ + { + id: "users", + type: "table", + position: { x: 10, y: 20 }, + data: { table: emptyTable("users") }, + }, + ], + edges: [], + positions: { users: { x: 10, y: 20 } }, + layers: [ + { + id: "l1", + name: "Core", + color: "#3b82f6", + tableNames: ["users"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 200, + height: 100, + }, + ], + tables: [emptyTable("users")], + customRelationships: [ + { + id: "custom-1", + name: "c", + sourceTable: "users", + sourceColumn: "id", + targetTable: "orders", + targetColumn: "user_id", + sourceCardinality: "1", + targetCardinality: "N", + }, + ], + edgeWaypoints: { "e-1": [{ x: 0, y: 0 }, { x: 10, y: 10 }] }, + edgeHandleHints: { "e-1": { sourceHandle: "right", targetHandle: "left-target" } }, + matchConfirms: ["rel-a"], + matchIgnores: ["rel-b"], + ...partial, + }; +} + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +test("pushHistory enables undo and clears redo", () => { + const store = useGraphStore(); + assert.equal(store.canUndo, false); + store.pushHistory(snapshot()); + assert.equal(store.canUndo, true); + + const current = snapshot({ positions: { users: { x: 99, y: 99 } } }); + const undone = store.undo(current); + assert.ok(undone); + assert.deepEqual(undone!.positions.users, { x: 10, y: 20 }); + assert.equal(store.canRedo, true); + + store.pushHistory(snapshot({ positions: { users: { x: 1, y: 1 } } })); + assert.equal(store.canRedo, false); +}); + +test("undo/redo round-trips snapshot extras", () => { + const store = useGraphStore(); + const older = snapshot({ + positions: { users: { x: 1, y: 1 } }, + matchConfirms: ["old"], + }); + const newer = snapshot({ + positions: { users: { x: 2, y: 2 } }, + matchConfirms: ["new"], + layers: [], + }); + + store.pushHistory(older); + const afterUndo = store.undo(newer); + assert.ok(afterUndo); + assert.deepEqual(afterUndo!.positions.users, { x: 1, y: 1 }); + assert.deepEqual(afterUndo!.layers[0]?.tableNames, ["users"]); + assert.deepEqual(afterUndo!.edgeWaypoints["e-1"]?.length, 2); + assert.deepEqual(afterUndo!.customRelationships[0]?.id, "custom-1"); + assert.deepEqual(afterUndo!.matchConfirms, ["old"]); + assert.deepEqual(afterUndo!.matchIgnores, ["rel-b"]); + assert.ok(afterUndo!.edgeHandleHints["e-1"]); + + const afterRedo = store.redo(afterUndo!); + assert.ok(afterRedo); + assert.deepEqual(afterRedo!.positions.users, { x: 2, y: 2 }); + assert.deepEqual(afterRedo!.matchConfirms, ["new"]); + assert.deepEqual(afterRedo!.layers, []); +}); + +test("undo/redo round-trips tables field", () => { + const store = useGraphStore(); + const olderTables = [emptyTable("users"), emptyTable("draft_a")]; + const newerTables = [emptyTable("users"), emptyTable("draft_b")]; + const older = snapshot({ tables: olderTables, positions: { users: { x: 1, y: 1 } } }); + const newer = snapshot({ tables: newerTables, positions: { users: { x: 2, y: 2 } } }); + + store.pushHistory(older); + const afterUndo = store.undo(newer); + assert.ok(afterUndo); + assert.deepEqual( + afterUndo!.tables.map((t) => t.name), + ["users", "draft_a"], + ); + + const afterRedo = store.redo(afterUndo!); + assert.ok(afterRedo); + assert.deepEqual( + afterRedo!.tables.map((t) => t.name), + ["users", "draft_b"], + ); +}); diff --git a/packages/app-tests/diagramLayer.test.ts b/packages/app-tests/diagramLayer.test.ts new file mode 100644 index 000000000..ed923a1ed --- /dev/null +++ b/packages/app-tests/diagramLayer.test.ts @@ -0,0 +1,160 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useLayerStore } from "../../apps/desktop/src/lib/diagram/layer-store.ts"; +import { findLayerAtPoint, placeNewLayer, sizeLayerToFit } from "../../apps/desktop/src/lib/diagram/size-layer.ts"; +import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts"; +import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts"; +import { LAYER_COLORS } from "../../apps/desktop/src/types/diagram.ts"; + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +test("addLayer defaults visible, auto layout, Layer N name, and unique colors", () => { + const store = useLayerStore(); + const a = store.addLayer(); + const b = store.addLayer(); + + assert.equal(a.visible, true); + assert.equal(a.layoutMode, "auto"); + assert.equal(a.name, "Layer 1"); + assert.equal(b.name, "Layer 2"); + assert.notEqual(a.color, b.color); + assert.ok(LAYER_COLORS.includes(a.color)); + assert.equal(store.activeLayerId, b.id); +}); + +test("addLayer collapses all existing layers", () => { + const store = useLayerStore(); + const a = store.addLayer("A"); + assert.equal(a.collapsed, false); + const b = store.addLayer("B"); + assert.equal(store.layers.find((l) => l.id === a.id)?.collapsed, true); + assert.equal(b.collapsed, false); + store.addLayer("C"); + assert.equal(store.layers.find((l) => l.id === a.id)?.collapsed, true); + assert.equal(store.layers.find((l) => l.id === b.id)?.collapsed, true); +}); + +test("moveTableToLayer enforces single-layer membership", () => { + const store = useLayerStore(); + const layerA = store.addLayer("A"); + const layerB = store.addLayer("B"); + store.addTableToLayer(layerA.id, "users"); + store.moveTableToLayer("users", layerB.id); + + assert.deepEqual(store.getLayerByTable("users")?.id, layerB.id); + assert.ok(!layerA.tableNames.includes("users")); + assert.ok(layerB.tableNames.includes("users")); +}); + +test("removeTableFromLayer, setLayoutMode, geometry, and visibility toggle", () => { + const store = useLayerStore(); + const layer = store.addLayer("Core", { x: 10, y: 20 }, { width: 300, height: 100 }); + store.addTableToLayer(layer.id, "orders"); + store.setLayoutMode(layer.id, "free"); + store.updateLayerGeometry(layer.id, { position: { x: 50, y: 60 }, width: 400, height: 200 }); + store.toggleLayerVisibility(layer.id); + store.removeTableFromLayer(layer.id, "orders"); + + const current = store.layers.find((l) => l.id === layer.id)!; + assert.equal(current.layoutMode, "free"); + assert.deepEqual(current.position, { x: 50, y: 60 }); + assert.equal(current.width, 400); + assert.equal(current.height, 200); + assert.equal(current.visible, false); + assert.deepEqual(current.tableNames, []); +}); + +test("sizeLayerToFit uses empty size or wraps table bbox with padding", () => { + const empty: DiagramLayer = { + id: "l0", + name: "Empty", + color: "#3b82f6", + tableNames: [], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 1, + height: 1, + }; + sizeLayerToFit(empty, {}, {}); + assert.equal(empty.width, EMPTY_LAYER_WIDTH); + assert.equal(empty.height, EMPTY_LAYER_HEIGHT); + + const filled: DiagramLayer = { + ...empty, + id: "l1", + tableNames: ["users", "orders"], + }; + sizeLayerToFit( + filled, + { users: { x: 100, y: 100 }, orders: { x: 200, y: 180 } }, + { users: 120, orders: 140 }, + ); + assert.equal(filled.position?.x, 100 - LAYER_CONTENT_PADDING); + assert.equal(filled.position?.y, 100 - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING); + assert.ok((filled.width ?? 0) >= CARD_WIDTH + LAYER_CONTENT_PADDING * 2); + assert.ok((filled.height ?? 0) >= EMPTY_LAYER_HEIGHT); +}); + +test("findLayerAtPoint returns topmost hit and placeNewLayer avoids overlap", () => { + const layers: DiagramLayer[] = [ + { + id: "bottom", + name: "Bottom", + color: "#3b82f6", + tableNames: [], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 200, + height: 100, + }, + { + id: "top", + name: "Top", + color: "#10b981", + tableNames: [], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 50, y: 20 }, + width: 200, + height: 100, + }, + ]; + + assert.equal(findLayerAtPoint({ x: 60, y: 30 }, layers)?.id, "top"); + assert.equal(findLayerAtPoint({ x: 10, y: 10 }, layers)?.id, "bottom"); + assert.equal(findLayerAtPoint({ x: 60, y: 30 }, layers, "top")?.id, "bottom"); + + const placement = placeNewLayer(layers, {}, {}); + assert.equal(placement.width, EMPTY_LAYER_WIDTH); + assert.equal(placement.height, EMPTY_LAYER_HEIGHT); + assert.ok(placement.position.x >= MARGIN); + assert.equal( + findLayerAtPoint( + { x: placement.position.x + 1, y: placement.position.y + 1 }, + [ + ...layers, + { + id: "new", + name: "New", + color: "#000", + tableNames: [], + collapsed: false, + visible: true, + layoutMode: "auto", + position: placement.position, + width: placement.width, + height: placement.height, + }, + ], + )?.id, + "new", + ); +}); diff --git a/packages/app-tests/diagramLivePatches.test.ts b/packages/app-tests/diagramLivePatches.test.ts new file mode 100644 index 000000000..e63954be2 --- /dev/null +++ b/packages/app-tests/diagramLivePatches.test.ts @@ -0,0 +1,175 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test, vi } from "vitest"; +import { + applyLiveTablePatches, + loadLiveTablePatches, + saveLiveTablePatches, +} from "../../apps/desktop/src/lib/diagram/draft-storage.ts"; +import { liveTableToAlterSqlOptions, validateLivePendingColumns } from "../../apps/desktop/src/lib/diagram/draft-table.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }); +}); + +function col(name: string, dataType = "varchar(255)"): ColumnInfo { + return { + name, + data_type: dataType, + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }; +} + +function liveTable(name: string, columns: ColumnInfo[], pendingColumnNames?: string[]): DiagramTable { + return { + name, + columns, + foreignKeys: [], + origin: "live", + pendingColumnNames, + }; +} + +test("save/load live patches round-trip", () => { + const tables = [ + liveTable("users", [col("id", "bigint"), col("nickname")], ["nickname"]), + ]; + saveLiveTablePatches(tables, "c1", "db", "public"); + const loaded = loadLiveTablePatches("c1", "db", "public"); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].tableName, "users"); + assert.equal(loaded[0].pendingColumns.length, 1); + assert.equal(loaded[0].pendingColumns[0].name, "nickname"); +}); + +test("applyLiveTablePatches merges missing pending columns", () => { + const tables = [liveTable("users", [col("id", "bigint")])]; + const merged = applyLiveTablePatches(tables, [ + { tableName: "users", pendingColumns: [col("nickname")] }, + ]); + assert.equal(merged[0].columns.length, 2); + assert.deepEqual(merged[0].pendingColumnNames, ["nickname"]); +}); + +test("applyLiveTablePatches skips columns already in DB", () => { + const tables = [liveTable("users", [col("id", "bigint"), col("nickname")])]; + const merged = applyLiveTablePatches(tables, [ + { tableName: "users", pendingColumns: [col("nickname")] }, + ]); + assert.equal(merged[0].columns.length, 2); + assert.equal(merged[0].pendingColumnNames, undefined); +}); + +test("liveTableToAlterSqlOptions marks only pending without original", () => { + const table = liveTable("users", [col("id", "bigint"), col("nickname")], ["nickname"]); + const options = liveTableToAlterSqlOptions(table, "postgres", "public"); + assert.ok(options.columns[0].original); + assert.equal(options.columns[1].original, undefined); +}); + +test("validateLivePendingColumns catches empty type", () => { + const table = liveTable("users", [col("id", "bigint"), { ...col("x"), data_type: "" }], ["x"]); + const errors = validateLivePendingColumns(table); + assert.ok(errors.some((e) => e.includes("needs a type"))); +}); + +test("validateLivePendingColumns catches missing, duplicate, and conflict", () => { + const missing = liveTable("users", [col("id")], ["ghost"]); + assert.ok(validateLivePendingColumns(missing).some((e) => e.includes("is missing"))); + + // Pending "name" collides with existing non-pending "Name" (case-insensitive). + const conflict = liveTable("users", [col("id"), col("Name"), col("name")], ["name"]); + assert.ok(validateLivePendingColumns(conflict).some((e) => e.includes("conflicts"))); + + const duplicate = liveTable("users", [col("id"), col("nick")], ["nick", "nick"]); + assert.ok(validateLivePendingColumns(duplicate).some((e) => e.includes("duplicate"))); +}); + +test("saveLiveTablePatches ignores draft tables", () => { + const draft: DiagramTable = { + name: "draft_t", + columns: [col("id")], + foreignKeys: [], + origin: "draft", + pendingColumnNames: ["id"], + }; + const live = liveTable("users", [col("id"), col("nickname")], ["nickname"]); + saveLiveTablePatches([draft, live], "c1", "db", "public"); + const loaded = loadLiveTablePatches("c1", "db", "public"); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].tableName, "users"); +}); + +test("applyLiveTablePatches skips case-insensitive name collisions", () => { + const tables = [liveTable("users", [col("ID", "bigint")])]; + const merged = applyLiveTablePatches(tables, [ + { tableName: "users", pendingColumns: [col("id", "bigint"), col("nickname")] }, + ]); + assert.equal(merged[0].columns.length, 2); + assert.deepEqual(merged[0].pendingColumnNames, ["nickname"]); +}); + +test("save/load persists dropped columns and pendingDrop", () => { + const tables: DiagramTable[] = [ + { + ...liveTable("users", [col("id"), col("nickname")]), + droppedColumnNames: ["nickname"], + }, + { + ...liveTable("orders", [col("id")]), + pendingDrop: true, + }, + ]; + saveLiveTablePatches(tables, "c1", "db", "public"); + const loaded = loadLiveTablePatches("c1", "db", "public"); + assert.equal(loaded.length, 2); + const users = loaded.find((p) => p.tableName === "users"); + const orders = loaded.find((p) => p.tableName === "orders"); + assert.deepEqual(users?.droppedColumnNames, ["nickname"]); + assert.equal(orders?.pendingDrop, true); +}); + +test("applyLiveTablePatches restores dropped columns and pendingDrop", () => { + const tables = [liveTable("users", [col("id"), col("nickname")]), liveTable("orders", [col("id")])]; + const merged = applyLiveTablePatches(tables, [ + { tableName: "users", pendingColumns: [], droppedColumnNames: ["nickname"] }, + { tableName: "orders", pendingColumns: [], pendingDrop: true }, + ]); + assert.deepEqual(merged[0].droppedColumnNames, ["nickname"]); + assert.equal(merged[1].pendingDrop, true); +}); + +test("liveTableToAlterSqlOptions marks dropped columns for drop", () => { + const table: DiagramTable = { + ...liveTable("users", [col("id", "bigint"), col("nickname")]), + droppedColumnNames: ["nickname"], + }; + const options = liveTableToAlterSqlOptions(table, "postgres", "public"); + assert.equal(options.columns[0].markedForDrop, false); + assert.ok(options.columns[0].original); + assert.equal(options.columns[1].markedForDrop, true); + assert.ok(options.columns[1].original); +}); + +test("validateLivePendingColumns catches missing dropped column", () => { + const table: DiagramTable = { + ...liveTable("users", [col("id")]), + droppedColumnNames: ["ghost"], + }; + assert.ok(validateLivePendingColumns(table).some((e) => e.includes("dropped column") && e.includes("is missing"))); +}); diff --git a/packages/app-tests/diagramLtrLayout.test.ts b/packages/app-tests/diagramLtrLayout.test.ts new file mode 100644 index 000000000..742be4fbf --- /dev/null +++ b/packages/app-tests/diagramLtrLayout.test.ts @@ -0,0 +1,140 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + computeLtrAutoLayout, + orderTablesByConnectivity, + reflowUnassignedTables, +} from "../../apps/desktop/src/lib/diagram/ltr-auto-layout.ts"; +import { CARD_WIDTH } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts"; + +function table(name: string, columns = 2): DiagramTable { + return { + name, + columns: Array.from({ length: columns }, (_, i) => ({ + name: i === 0 ? "id" : `c${i}`, + data_type: "bigint", + is_nullable: false, + column_default: null, + is_primary_key: i === 0, + extra: null, + })), + foreignKeys: [], + }; +} + +test("orderTablesByConnectivity keeps connected tables adjacent", () => { + const tables = [table("zeta"), table("users"), table("orders"), table("alpha")]; + const ordered = orderTablesByConnectivity(tables, [ + { sourceTable: "orders", targetTable: "users" }, + ]); + const names = ordered.map((t) => t.name); + const usersIdx = names.indexOf("users"); + const ordersIdx = names.indexOf("orders"); + assert.equal(Math.abs(usersIdx - ordersIdx), 1); + assert.ok(names.includes("alpha")); + assert.ok(names.includes("zeta")); +}); + +test("computeLtrAutoLayout places tables without overlap and respects CARD_WIDTH", () => { + const tables = [table("a"), table("b"), table("c")]; + const { positions } = computeLtrAutoLayout({ + tables, + positions: {}, + layers: [], + paneWidth: 1200, + relationships: [{ sourceTable: "b", targetTable: "a" }], + }); + + const names = Object.keys(positions); + assert.equal(names.length, 3); + for (const name of names) { + assert.ok(Number.isFinite(positions[name].x)); + assert.ok(Number.isFinite(positions[name].y)); + } + + // Axis-aligned cards using CARD_WIDTH should not overlap + const boxes = names.map((name) => ({ + name, + x: positions[name].x, + y: positions[name].y, + w: CARD_WIDTH, + h: 100, + })); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]; + const b = boxes[j]; + const overlap = !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y); + assert.equal(overlap, false, `${a.name} overlaps ${b.name}`); + } + } +}); + +test("reflowUnassignedTables preserves positions of layered tables", () => { + const layers: DiagramLayer[] = [ + { + id: "l1", + name: "Core", + color: "#3b82f6", + tableNames: ["users"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 40, y: 40 }, + width: 400, + height: 200, + }, + ]; + const prev = { + users: { x: 99, y: 88 }, + orders: { x: 500, y: 500 }, + payments: { x: 800, y: 600 }, + }; + const next = reflowUnassignedTables({ + tables: [table("users"), table("orders"), table("payments")], + positions: prev, + layers, + paneWidth: 1200, + }); + + assert.deepEqual(next.users, prev.users); + assert.notDeepEqual(next.orders, prev.orders); + assert.ok(Number.isFinite(next.payments.x)); +}); + +test("computeLtrAutoLayout keeps layer-assigned tables inside updated layer geometry", () => { + const layers: DiagramLayer[] = [ + { + id: "l1", + name: "Core", + color: "#3b82f6", + tableNames: ["users", "orders"], + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 100, + height: 80, + }, + ]; + const { positions, layers: nextLayers } = computeLtrAutoLayout({ + tables: [table("users"), table("orders"), table("orphan")], + positions: { + users: { x: 0, y: 0 }, + orders: { x: 0, y: 0 }, + orphan: { x: 0, y: 0 }, + }, + layers, + paneWidth: 1400, + relationships: [{ sourceTable: "orders", targetTable: "users" }], + }); + + assert.equal(nextLayers.length, 1); + const layer = nextLayers[0]; + assert.ok((layer.width ?? 0) > 100); + assert.ok(positions.users.x >= (layer.position?.x ?? 0)); + assert.ok(positions.orders.x >= (layer.position?.x ?? 0)); + assert.ok(positions.orphan.y > (layer.position?.y ?? 0) + (layer.height ?? 0) - 1); +}); diff --git a/packages/app-tests/diagramRefreshConfirm.test.ts b/packages/app-tests/diagramRefreshConfirm.test.ts new file mode 100644 index 000000000..108f92e8d --- /dev/null +++ b/packages/app-tests/diagramRefreshConfirm.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { strict as assert } from "node:assert"; +import { describe, test } from "vitest"; + +const dialogSource = readFileSync(new URL("../../apps/desktop/src/components/diagram/SchemaDiagramDialog.vue", import.meta.url), "utf8"); +const toolbarSource = readFileSync(new URL("../../apps/desktop/src/components/diagram/DiagramToolbar.vue", import.meta.url), "utf8"); + +describe("diagram refresh confirmation wiring", () => { + test("toolbar emits refresh from the refresh button", () => { + assert.match(toolbarSource, /@click="emit\('refresh'\)"/); + assert.match(toolbarSource, /diagram\.refresh/); + }); + + test("dialog opens DangerConfirmDialog before reload", () => { + assert.match(dialogSource, /function requestRefreshDiagram\(\)/); + assert.match(dialogSource, /showRefreshConfirm\.value = true/); + assert.match(dialogSource, /@refresh="requestRefreshDiagram"/); + assert.match( + dialogSource, + /DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/, + ); + assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\)/); + }); +}); diff --git a/packages/app-tests/diagramStructureReuse.test.ts b/packages/app-tests/diagramStructureReuse.test.ts new file mode 100644 index 000000000..f957cae3b --- /dev/null +++ b/packages/app-tests/diagramStructureReuse.test.ts @@ -0,0 +1,80 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + createDraftTable, + createEmptyColumn, + draftTableToCreateSqlOptions, + liveTableToAlterSqlOptions, +} from "../../apps/desktop/src/lib/diagram/draft-table.ts"; +import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import { canAddTableStructureColumn, getTableStructureCapabilities } from "../../apps/desktop/src/lib/table/tableStructureCapabilities.ts"; +import { supportsTableStructureEditing } from "../../apps/desktop/src/lib/database/databaseFeatureSupport.ts"; +import { defaultNewColumnDataType, getDataTypeOptions } from "../../apps/desktop/src/lib/table/tableStructureEditorState.ts"; +import type { DatabaseType } from "../../apps/desktop/src/types/database.ts"; + +const READY_DIALECTS: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle"]; + +test("diagram structure gates reuse TableStructure capabilities", () => { + for (const dbType of READY_DIALECTS) { + const caps = getTableStructureCapabilities(dbType); + assert.equal(caps.createTable, true, `${dbType} createTable`); + assert.equal(canAddTableStructureColumn(dbType, true), caps.createTable); + assert.equal(canAddTableStructureColumn(dbType, false), caps.addColumn); + assert.equal(supportsTableStructureEditing(dbType), true, `${dbType} structure editing`); + } + + const unsupported = getTableStructureCapabilities("mongodb"); + assert.equal(unsupported.createTable, false); + assert.equal(canAddTableStructureColumn("mongodb", true), false); + assert.equal(canAddTableStructureColumn("mongodb", false), false); + assert.equal(supportsTableStructureEditing("mongodb"), false); +}); + +test("draft CREATE options match table-structure SQL API shape", () => { + for (const dbType of READY_DIALECTS) { + const table = createDraftTable("users", { databaseType: dbType }); + const options = draftTableToCreateSqlOptions(table, dbType, dbType === "postgres" ? "public" : undefined); + assert.equal(options.databaseType, dbType); + assert.equal(options.tableName, "users"); + assert.ok(Array.isArray(options.columns)); + assert.ok(Array.isArray(options.indexes)); + assert.deepEqual(options.foreignKeys, []); + assert.deepEqual(options.triggers, []); + assert.equal(options.columns[0]?.isPrimaryKey, true); + assert.ok(options.columns[0]?.dataType); + } +}); + +test("live ALTER options mark pending add and drop for shared change SQL API", () => { + const table: DiagramTable = { + name: "orders", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "note", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [], + origin: "database", + pendingColumnNames: ["note"], + droppedColumnNames: ["id"], + }; + const options = liveTableToAlterSqlOptions(table, "postgres", "public"); + assert.equal(options.databaseType, "postgres"); + assert.equal(options.tableName, "orders"); + assert.deepEqual(options.indexes, []); + assert.deepEqual(options.foreignKeys, []); + const pending = options.columns.find((column) => column.name === "note"); + const dropped = options.columns.find((column) => column.name === "id"); + assert.ok(pending); + assert.equal(pending?.original, undefined); + assert.ok(dropped?.original); + assert.equal(dropped?.markedForDrop, true); +}); + +test("empty column defaults come from shared tableStructureEditorState", () => { + assert.equal(createEmptyColumn("x", "postgres").data_type, defaultNewColumnDataType("postgres", getDataTypeOptions("postgres"))); + assert.equal(createEmptyColumn("x", "duckdb").data_type, defaultNewColumnDataType("duckdb", getDataTypeOptions("duckdb"))); + assert.equal(createEmptyColumn("x", "h2").data_type, defaultNewColumnDataType("h2", getDataTypeOptions("h2"))); + assert.ok(getDataTypeOptions("duckdb").length > 0); + assert.ok(getDataTypeOptions("h2").length > 0); + assert.ok(getDataTypeOptions("rqlite").includes("text")); +}); diff --git a/packages/app-tests/diagramSvgExport.test.ts b/packages/app-tests/diagramSvgExport.test.ts index 5668440ec..617f690b4 100644 --- a/packages/app-tests/diagramSvgExport.test.ts +++ b/packages/app-tests/diagramSvgExport.test.ts @@ -1,8 +1,10 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { buildEngineeringDiagram } from "../../apps/desktop/src/lib/diagram/engineeringDiagram.ts"; -import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts"; +import { buildEngineeringDiagramSvg, buildTableDiagramSvg, buildTableRelationshipPaths, computeTableDiagramCanvas, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts"; import { buildDiagramRelationships, normalizeCustomDiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import { pointsToSvgPath } from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts"; +import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts"; const tables: DiagramTable[] = [ { @@ -25,6 +27,10 @@ const tables: DiagramTable[] = [ test("exports the table diagram as standalone SVG", () => { const relationships = buildDiagramRelationships(tables); + const polyline = [ + { x: 360, y: 96 }, + { x: 310, y: 96 }, + ]; const svg = buildTableDiagramSvg({ tables, relationships, @@ -32,36 +38,73 @@ test("exports the table diagram as standalone SVG", () => { users: { x: 40, y: 40 }, orders: { x: 360, y: 40 }, }, - relationshipLayouts: { - [relationships[0].id]: { - path: "M 360 96 L 310 96", - routePoints: [ - { x: 360, y: 96 }, - { x: 310, y: 96 }, - ], - sourceCardinality: { x: 346, y: 86 }, - targetCardinality: { x: 324, y: 86 }, - }, + relationshipPaths: { + [relationships[0].id]: "M 360 96 L 310 96", + }, + relationshipPolylines: { + [relationships[0].id]: polyline, }, canvas: { width: 720, height: 320 }, cardWidth: 270, cardHeaderHeight: 44, columnRowHeight: 24, - maxVisibleColumns: 9, - moreColumnsLabel: (count) => `+ ${count} columns`, }); assert.match(svg, /^]*>N<\/text>/); - assert.match(svg, /data-cardinality-end="target"[^>]*>1<\/text>/); - assert.match(svg, /orders\.user_id \(N:1\) -> users\.id/); assert.match(svg, />usersordersname & notePKFKN1 { + const relationships = buildDiagramRelationships(tables); + const svg = buildTableDiagramSvg({ + tables, + relationships, + positions: { + users: { x: 40, y: 40 }, + orders: { x: 360, y: 40 }, + }, + relationshipPaths: {}, + canvas: { width: 720, height: 320 }, + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }); + + assert.doesNotMatch(svg, /marker-end=/); + assert.doesNotMatch(svg, /id="dbx-diagram-arrow"/); +}); + +test("draws visible layers and skips zero-size layers", () => { + const svg = buildTableDiagramSvg({ + tables: [tables[0]], + relationships: [], + positions: { users: { x: 40, y: 40 } }, + relationshipPaths: {}, + canvas: { width: 800, height: 600 }, + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + layers: [ + { id: "l1", name: "Core", color: "#3b82f6", x: 10, y: 10, width: 400, height: 200 }, + { id: "l0", name: "Empty", color: "#ef4444", x: 0, y: 0, width: 0, height: 0 }, + ], + }); + + assert.match(svg, /class="diagram-layers"/); + assert.match(svg, />CoreEmpty { const customRelationship = { ...normalizeCustomDiagramRelationship({ @@ -75,6 +118,10 @@ test("exports many-to-many cardinalities at both relationship endpoints", () => }), kind: "custom" as const, }; + const polyline = [ + { x: 310, y: 96 }, + { x: 360, y: 96 }, + ]; const svg = buildTableDiagramSvg({ tables, relationships: [customRelationship], @@ -82,27 +129,22 @@ test("exports many-to-many cardinalities at both relationship endpoints", () => users: { x: 40, y: 40 }, orders: { x: 360, y: 40 }, }, - relationshipLayouts: { - [customRelationship.id]: { - path: "M 310 96 L 360 96", - routePoints: [ - { x: 310, y: 96 }, - { x: 360, y: 96 }, - ], - sourceCardinality: { x: 324, y: 86 }, - targetCardinality: { x: 346, y: 86 }, - }, + relationshipPaths: { + [customRelationship.id]: "M 310 96 L 360 96", + }, + relationshipPolylines: { + [customRelationship.id]: polyline, }, canvas: { width: 720, height: 320 }, cardWidth: 270, cardHeaderHeight: 44, columnRowHeight: 24, - maxVisibleColumns: 9, }); - assert.match(svg, /data-cardinality-end="source"[^>]*>N<\/text>/); - assert.match(svg, /data-cardinality-end="target"[^>]*>N<\/text>/); - assert.match(svg, /users\.id \(N:N\) -> orders\.id/); + assert.match(svg, /diagram-cardinality/); + const cardinalityTexts = [...svg.matchAll(/diagram-cardinality[\s\S]*?<\/g>/g)].join(""); + assert.match(svg, />NN= 2, `expected two N badges, got: ${cardinalityTexts.slice(0, 200)}`); }); test("infers one-to-one foreign keys from primary and unique source columns", () => { @@ -145,40 +187,6 @@ test("infers one-to-one foreign keys from primary and unique source columns", () assert.equal(buildDiagramRelationships(partialUniqueIndexTables)[0].sourceCardinality, "N"); }); -test("expands the exported viewBox for a relationship routed left of the canvas", () => { - const relationships = buildDiagramRelationships(tables); - const svg = buildTableDiagramSvg({ - tables, - relationships, - positions: { - users: { x: 16, y: 40 }, - orders: { x: 336, y: 40 }, - }, - relationshipLayouts: { - [relationships[0].id]: { - path: "M 14 96 L -20 96 L -20 120 L 14 120", - routePoints: [ - { x: 14, y: 96 }, - { x: -20, y: 96 }, - { x: -20, y: 120 }, - { x: 14, y: 120 }, - ], - sourceCardinality: { x: 0, y: 86 }, - targetCardinality: { x: 0, y: 110 }, - }, - }, - canvas: { width: 720, height: 320 }, - cardWidth: 270, - cardHeaderHeight: 44, - columnRowHeight: 24, - maxVisibleColumns: 9, - }); - - assert.match(svg, /viewBox="-40 0 760 320"/); - assert.match(svg, / { const relationships = buildDiagramRelationships(tables); const diagram = buildEngineeringDiagram(tables, relationships, { @@ -201,3 +209,172 @@ test("builds safe SVG file names from the active diagram context", () => { assert.equal(diagramSvgFileName("prod/main", "billing db", "engineering"), "dbx-prod-main-billing-db-engineering-er.svg"); assert.equal(diagramSvgFileName("", "", "table"), "dbx-diagram-table-structure.svg"); }); + +test("buildTableRelationshipPaths uses waypoints when length >= 2", () => { + const relationships = buildDiagramRelationships(tables); + const waypoints = [ + { x: 0, y: 0 }, + { x: 100, y: 50 }, + ]; + const paths = buildTableRelationshipPaths({ + relationships, + positions: { + users: { x: 40, y: 40 }, + orders: { x: 400, y: 40 }, + }, + tables, + waypoints: { [relationships[0].id]: waypoints }, + }); + + assert.equal(paths[relationships[0].id], pointsToSvgPath(waypoints)); +}); + +test("buildTableRelationshipPaths falls back to orthogonal path when waypoints are insufficient", () => { + const relationships = buildDiagramRelationships(tables); + const paths = buildTableRelationshipPaths({ + relationships, + positions: { + users: { x: 40, y: 40 }, + orders: { x: 400, y: 40 }, + }, + tables, + waypoints: { [relationships[0].id]: [{ x: 0, y: 0 }] }, + }); + + assert.ok(paths[relationships[0].id]); + assert.match(paths[relationships[0].id], /^M/); + assert.notEqual(paths[relationships[0].id], pointsToSvgPath([{ x: 0, y: 0 }])); +}); + +test("buildTableRelationshipPaths skips relationships with missing positions", () => { + const relationships = buildDiagramRelationships(tables); + const paths = buildTableRelationshipPaths({ + relationships, + positions: { users: { x: 40, y: 40 } }, + tables, + }); + + assert.equal(paths[relationships[0].id], undefined); +}); + +test("computeTableDiagramCanvas uses default floor and MARGIN padding", () => { + const canvas = computeTableDiagramCanvas( + [], + {}, + { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }, + ); + + assert.deepEqual(canvas, { width: 400 + MARGIN, height: 300 + MARGIN, originX: 0, originY: 0 }); +}); + +test("computeTableDiagramCanvas expands for tables and ignores zero-size layers", () => { + const withTable = computeTableDiagramCanvas( + [tables[0]], + { users: { x: 1000, y: 800 } }, + { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + layers: [{ id: "z", name: "z", color: "#000", x: 0, y: 0, width: 0, height: 0 }], + }, + ); + const tableHeight = CARD_HEADER_HEIGHT + tables[0].columns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING; + assert.equal(withTable.originX, 1000 - MARGIN); + assert.equal(withTable.originY, 800 - MARGIN); + assert.equal(withTable.width, CARD_WIDTH + 2 * MARGIN); + assert.equal(withTable.height, tableHeight + 2 * MARGIN); + + const withLayer = computeTableDiagramCanvas( + [], + {}, + { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + layers: [{ id: "big", name: "big", color: "#000", x: 0, y: 0, width: 2000, height: 1500 }], + }, + ); + assert.equal(withLayer.originX, -MARGIN); + assert.equal(withLayer.originY, -MARGIN); + assert.equal(withLayer.width, 2000 + 2 * MARGIN); + assert.equal(withLayer.height, 1500 + 2 * MARGIN); +}); + +test("computeTableDiagramCanvas expands for relationship polylines beyond table bounds", () => { + const canvas = computeTableDiagramCanvas( + [tables[0]], + { users: { x: 40, y: 40 } }, + { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + relationshipPolylines: { + edge1: [ + { x: 40, y: 40 }, + { x: 40, y: 500 }, + ], + }, + }, + ); + assert.ok(canvas.originY <= 40 - MARGIN); + assert.ok(canvas.originY + canvas.height >= 500 + MARGIN); + assert.ok(canvas.height >= 500 - 40 + 2 * MARGIN); +}); + +test("buildTableDiagramSvg normalizes far-from-origin content to viewBox 0 0", () => { + const table = tables[0]; + const positions = { users: { x: 1000, y: 800 } }; + const canvas = computeTableDiagramCanvas([table], positions, { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }); + assert.ok((canvas.originX ?? 0) > 0); + assert.ok((canvas.originY ?? 0) > 0); + + const svg = buildTableDiagramSvg({ + tables: [table], + relationships: [], + positions, + relationshipPaths: {}, + canvas, + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }); + assert.match(svg, new RegExp(`viewBox="0 0 ${canvas.width} ${canvas.height}"`)); + assert.match(svg, /rect x="0" y="0"/); + assert.match(svg, new RegExp(`transform="translate\\(${-(canvas.originX ?? 0)} ${-(canvas.originY ?? 0)}\\)"`)); + assert.match(svg, /users/); +}); + +test("buildTableDiagramSvg normalizes negative-origin content to viewBox 0 0", () => { + const table = tables[0]; + const positions = { users: { x: -200, y: -100 } }; + const canvas = computeTableDiagramCanvas([table], positions, { + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }); + assert.ok((canvas.originX ?? 0) < 0); + assert.ok((canvas.originY ?? 0) < 0); + + const svg = buildTableDiagramSvg({ + tables: [table], + relationships: [], + positions, + relationshipPaths: {}, + canvas, + cardWidth: CARD_WIDTH, + cardHeaderHeight: CARD_HEADER_HEIGHT, + columnRowHeight: COLUMN_ROW_HEIGHT, + }); + assert.match(svg, new RegExp(`viewBox="0 0 ${canvas.width} ${canvas.height}"`)); + assert.match(svg, /rect x="0" y="0"/); + assert.match(svg, new RegExp(`transform="translate\\(${-(canvas.originX ?? 0)} ${-(canvas.originY ?? 0)}\\)"`)); + assert.match(svg, /users/); +}); diff --git a/packages/app-tests/diagramSvgToPng.test.ts b/packages/app-tests/diagramSvgToPng.test.ts new file mode 100644 index 000000000..99507096f --- /dev/null +++ b/packages/app-tests/diagramSvgToPng.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { strict as assert } from "node:assert"; +import { afterEach, test, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("svgToPngBlob scales canvas, fills opaque background, and returns png blob", async () => { + const fillRect = vi.fn(); + const drawImage = vi.fn(); + const toBlob = vi.fn((cb: (b: Blob | null) => void) => { + cb(new Blob(["png"], { type: "image/png" })); + }); + + class FakeImage { + naturalWidth = 100; + naturalHeight = 50; + width = 100; + height = 50; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + } + + vi.stubGlobal("Image", FakeImage); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + fillStyle: "", + fillRect, + drawImage, + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(toBlob as typeof HTMLCanvasElement.prototype.toBlob); + + const { svgToPngBlob } = await import("../../apps/desktop/src/lib/export/diagramFormats.ts"); + const blob = await svgToPngBlob('', 2); + + assert.equal(blob.type, "image/png"); + assert.equal(fillRect.mock.calls[0]?.slice(0, 4).join(","), "0,0,200,100"); + const ctx = HTMLCanvasElement.prototype.getContext.mock.results[0]?.value as { fillStyle: string }; + assert.equal(ctx.fillStyle, "#fafafa"); + assert.equal(drawImage.mock.calls.length, 1); +}); + +test("svgToPngBlob rejects when image fails to load", async () => { + class FailingImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_value: string) { + queueMicrotask(() => this.onerror?.()); + } + } + vi.stubGlobal("Image", FailingImage); + const { svgToPngBlob } = await import("../../apps/desktop/src/lib/export/diagramFormats.ts"); + await assert.rejects(() => svgToPngBlob("", 2), /Failed to load SVG/); +}); diff --git a/packages/app-tests/diagramVueFlowAdapter.test.ts b/packages/app-tests/diagramVueFlowAdapter.test.ts new file mode 100644 index 000000000..e24fed563 --- /dev/null +++ b/packages/app-tests/diagramVueFlowAdapter.test.ts @@ -0,0 +1,110 @@ +import { strict as assert } from "node:assert"; +import { beforeEach, test } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useLayerStore } from "../../apps/desktop/src/lib/diagram/layer-store.ts"; +import { + isTableCanvasVisible, + toVueFlowEdges, + toVueFlowNodes, +} from "../../apps/desktop/src/lib/diagram/vue-flow-adapter.ts"; +import type { DiagramRelationship, DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts"; + +function table(name: string): DiagramTable { + return { name, columns: [], foreignKeys: [] }; +} + +function layer(partial: Partial & { id: string; name: string; tableNames: string[] }): DiagramLayer { + return { + color: "#3b82f6", + collapsed: false, + visible: true, + layoutMode: "auto", + position: { x: 0, y: 0 }, + width: 240, + height: 100, + ...partial, + }; +} + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +test("isTableCanvasVisible: unlayered always visible; hidden layer hides members", () => { + const layers = [ + layer({ id: "l1", name: "Core", tableNames: ["users"], visible: false }), + layer({ id: "l2", name: "Open", tableNames: ["orders"], visible: true }), + ]; + assert.equal(isTableCanvasVisible("orphan", layers), true); + assert.equal(isTableCanvasVisible("users", layers), false); + assert.equal(isTableCanvasVisible("orders", layers), true); +}); + +test("toVueFlowNodes filters tables on hidden layers", () => { + const store = useLayerStore(); + const hidden = store.addLayer("Hidden"); + const shown = store.addLayer("Shown"); + store.addTableToLayer(hidden.id, "users"); + store.addTableToLayer(shown.id, "orders"); + store.toggleLayerVisibility(hidden.id); + + const nodes = toVueFlowNodes([table("users"), table("orders"), table("orphan")], { + users: { x: 10, y: 10 }, + orders: { x: 20, y: 20 }, + orphan: { x: 30, y: 30 }, + }); + assert.deepEqual(nodes.map((n) => n.id).sort(), ["orders", "orphan"]); +}); + +test("toVueFlowNodes hides tables marked pendingDrop", () => { + const nodes = toVueFlowNodes( + [ + { ...table("users"), pendingDrop: true }, + table("orders"), + ], + { + users: { x: 10, y: 10 }, + orders: { x: 20, y: 20 }, + }, + ); + assert.deepEqual(nodes.map((n) => n.id), ["orders"]); +}); + +test("toVueFlowEdges filters when either endpoint layer is hidden", () => { + const store = useLayerStore(); + const hidden = store.addLayer("Hidden"); + const shown = store.addLayer("Shown"); + store.addTableToLayer(hidden.id, "users"); + store.addTableToLayer(shown.id, "orders"); + store.addTableToLayer(shown.id, "items"); + store.toggleLayerVisibility(hidden.id); + + const relationships: DiagramRelationship[] = [ + { + id: "e1", + name: "fk1", + kind: "foreign-key", + sourceTable: "orders", + sourceColumn: "user_id", + targetTable: "users", + targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "1", + }, + { + id: "e2", + name: "fk2", + kind: "foreign-key", + sourceTable: "items", + sourceColumn: "order_id", + targetTable: "orders", + targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "1", + }, + ]; + + const edges = toVueFlowEdges(relationships); + assert.deepEqual(edges.map((e) => e.id), ["e2"]); +}); diff --git a/packages/app-tests/erDiagram.test.ts b/packages/app-tests/erDiagram.test.ts index 7f694168d..6a56f14a3 100644 --- a/packages/app-tests/erDiagram.test.ts +++ b/packages/app-tests/erDiagram.test.ts @@ -206,3 +206,187 @@ FROM orders t1 LEFT JOIN customers t2 ON t1.customer_id = t2.id AND t1.customer_region = t2.region`, ); }); + +test("R1: single-column unique FK is 1:1", () => { + const relationships = buildDiagramRelationships([ + { + name: "users", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "user_profiles", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [{ name: "user_profiles_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + indexes: [{ name: "user_profiles_user_id_uq", columns: ["user_id"], is_unique: true, is_primary: false }], + }, + ]); + assert.equal(relationships[0].sourceCardinality, "1"); + assert.equal(relationships[0].targetCardinality, "1"); +}); + +test("R2: composite unique covering composite FK is 1:1", () => { + const relationships = buildDiagramRelationships([ + { + name: "tenants", + columns: [ + { name: "org_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + ], + foreignKeys: [], + }, + { + name: "memberships", + columns: [ + { name: "org_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [ + { name: "memberships_tenant_fk", column: "org_id", ref_table: "tenants", ref_column: "org_id" }, + { name: "memberships_tenant_fk", column: "user_id", ref_table: "tenants", ref_column: "id" }, + ], + indexes: [{ name: "memberships_uq", columns: ["user_id", "org_id"], is_unique: true, is_primary: false }], + }, + ]); + assert.ok(relationships.every((r) => r.sourceCardinality === "1" && r.targetCardinality === "1")); +}); + +test("R3: ordinary FK remains N:1", () => { + const relationships = buildDiagramRelationships([ + { + name: "users", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "orders", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ]); + assert.equal(relationships[0].sourceCardinality, "N"); + assert.equal(relationships[0].targetCardinality, "1"); +}); + +test("E1: column.is_unique alone yields 1:1 without indexes", () => { + const relationships = buildDiagramRelationships([ + { + name: "users", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "profiles", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, is_unique: true, extra: null }, + ], + foreignKeys: [{ name: "profiles_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ]); + assert.equal(relationships[0].sourceCardinality, "1"); +}); + +test("E2: unique index superset of FK columns stays N:1", () => { + const relationships = buildDiagramRelationships([ + { + name: "users", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "orders", + columns: [ + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "tenant_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + indexes: [{ name: "orders_uq", columns: ["user_id", "tenant_id"], is_unique: true, is_primary: false }], + }, + ]); + assert.equal(relationships[0].sourceCardinality, "N"); +}); + +test("E4/E5: partial unique and markedForDrop unique indexes do not force 1:1", () => { + const partial = buildDiagramRelationships([ + { name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] }, + { + name: "orders", + columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + indexes: [{ name: "orders_uq", columns: ["user_id"], is_unique: true, is_primary: false, filter: "deleted_at IS NULL" }], + }, + ]); + assert.equal(partial[0].sourceCardinality, "N"); + + const dropped = buildDiagramRelationships([ + { name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] }, + { + name: "orders", + columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + indexes: [ + { + id: "idx-1", + name: "orders_uq", + columns: ["user_id"], + isUnique: true, + isPrimary: false, + filter: "", + indexType: "", + includedColumns: [], + comment: "", + markedForDrop: true, + }, + ], + }, + ]); + assert.equal(dropped[0].sourceCardinality, "N"); +}); + +test("E6: PK column set equal to FK columns is 1:1", () => { + const relationships = buildDiagramRelationships([ + { name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] }, + { + name: "profiles", + columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [{ name: "profiles_pk_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ]); + assert.equal(relationships[0].sourceCardinality, "1"); +}); + +test("E7: custom relationship cardinalities are preserved", () => { + const custom = normalizeCustomDiagramRelationship({ + name: "custom_nn", + sourceTable: "users", + sourceColumn: "id", + targetTable: "orders", + targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "N", + }); + const relationships = buildDiagramRelationships( + [ + { + name: "users", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "orders", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + ], + [custom], + ); + assert.equal(relationships[0].sourceCardinality, "N"); + assert.equal(relationships[0].targetCardinality, "N"); +}); diff --git a/packages/app-tests/getAllColumnsContract.test.ts b/packages/app-tests/getAllColumnsContract.test.ts new file mode 100644 index 000000000..e0d51fa1e --- /dev/null +++ b/packages/app-tests/getAllColumnsContract.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "vitest"; + +function source(relativePath: string): string { + return readFileSync(path.resolve(relativePath), "utf8"); +} + +test("getAllColumns is exported from both backends with table_name", () => { + const http = source("apps/desktop/src/lib/backend/http.ts"); + const tauri = source("apps/desktop/src/lib/backend/tauri.ts"); + const api = source("apps/desktop/src/lib/backend/api.ts"); + + assert.match(http, /export async function getAllColumns/); + assert.match(tauri, /export async function getAllColumns/); + assert.match(api, /getAllColumns/); + + assert.match(http, /export interface TableColumnsResult \{\n table_name: string;/); + assert.match(tauri, /export interface TableColumnsResult \{\n table_name: string;/); + assert.doesNotMatch(http, /export interface TableColumnsResult \{\n tableName:/); + assert.doesNotMatch(tauri, /export interface TableColumnsResult \{\n tableName:/); + + assert.match(tauri, /invoke\("get_all_columns"/); + assert.match(http, /\/api\/schema\/all-columns/); +}); + +test("get_all_columns is registered in Tauri and mounted on the web API", () => { + const schemaCommands = source("src-tauri/src/commands/schema.rs"); + const lib = source("src-tauri/src/lib.rs"); + const webMain = source("crates/dbx-web/src/main.rs"); + + assert.match(schemaCommands, /pub async fn get_all_columns/); + assert.match(lib, /commands::schema::get_all_columns/); + assert.match(webMain, /\/schema\/all-columns/); +}); diff --git a/packages/app-tests/pointAlongPolyline.test.ts b/packages/app-tests/pointAlongPolyline.test.ts new file mode 100644 index 000000000..d94db377e --- /dev/null +++ b/packages/app-tests/pointAlongPolyline.test.ts @@ -0,0 +1,37 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { midpointAlongPolyline, pointAlongPolyline } from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts"; + +test("pointAlongPolyline returns endpoints at 0 and 1", () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]; + assert.deepEqual(pointAlongPolyline(points, 0), { x: 0, y: 0 }); + assert.deepEqual(pointAlongPolyline(points, 1), { x: 100, y: 0 }); +}); + +test("pointAlongPolyline interpolates by arc length", () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]; + // Total length 200; t=0.25 → 50 along first segment + const p = pointAlongPolyline(points, 0.25); + assert.equal(p.x, 50); + assert.equal(p.y, 0); + // t=0.75 → 150 → mid of second segment + const q = pointAlongPolyline(points, 0.75); + assert.equal(q.x, 100); + assert.equal(q.y, 50); +}); + +test("midpointAlongPolyline matches t=0.5", () => { + const points = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ]; + assert.deepEqual(midpointAlongPolyline(points), pointAlongPolyline(points, 0.5)); +}); diff --git a/packages/app-tests/saveDiagramExport.test.ts b/packages/app-tests/saveDiagramExport.test.ts new file mode 100644 index 000000000..17fc16bd2 --- /dev/null +++ b/packages/app-tests/saveDiagramExport.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { strict as assert } from "node:assert"; +import { beforeEach, test, vi } from "vitest"; + +const runtimeMock = vi.hoisted(() => ({ isTauri: false })); +const dialogMock = vi.hoisted(() => ({ save: vi.fn() })); +const fsMock = vi.hoisted(() => ({ + writeTextFile: vi.fn(async () => {}), + writeFile: vi.fn(async () => {}), +})); + +vi.mock("@/lib/backend/tauriRuntime", () => ({ + isTauriRuntime: () => runtimeMock.isTauri, +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: (...args: unknown[]) => dialogMock.save(...args), +})); +vi.mock("@tauri-apps/plugin-fs", () => ({ + writeTextFile: (...args: unknown[]) => fsMock.writeTextFile(...args), + writeFile: (...args: unknown[]) => fsMock.writeFile(...args), +})); + +const { saveDiagramBinaryExport, saveDiagramTextExport } = await import("../../apps/desktop/src/lib/export/saveDiagramExport.ts"); + +beforeEach(() => { + runtimeMock.isTauri = false; + dialogMock.save.mockReset(); + fsMock.writeTextFile.mockReset(); + fsMock.writeFile.mockReset(); +}); + +test("Tauri text export returns false when save is cancelled", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue(null); + const saved = await saveDiagramTextExport("diagram.svg", "", "svg"); + assert.equal(saved, false); + assert.equal(fsMock.writeTextFile.mock.calls.length, 0); +}); + +test("Tauri text export writes file when path chosen", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/out.svg"); + const saved = await saveDiagramTextExport("diagram.svg", "", "svg"); + assert.equal(saved, true); + assert.deepEqual(fsMock.writeTextFile.mock.calls[0]?.slice(0, 2), ["/tmp/out.svg", ""]); +}); + +test("Tauri binary export writes bytes when path chosen", async () => { + runtimeMock.isTauri = true; + dialogMock.save.mockResolvedValue("/tmp/out.png"); + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }); + const saved = await saveDiagramBinaryExport("diagram.png", blob, "png"); + assert.equal(saved, true); + assert.equal(fsMock.writeFile.mock.calls[0]?.[0], "/tmp/out.png"); + assert.ok(fsMock.writeFile.mock.calls[0]?.[1] instanceof Uint8Array); +}); + +test("web text export triggers download and returns true", async () => { + runtimeMock.isTauri = false; + const clicks: string[] = []; + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:diagram"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = originalCreateElement(tag); + if (tag === "a") { + el.click = () => { + clicks.push(el.download); + }; + } + return el; + }); + + const saved = await saveDiagramTextExport("dbx-diagram.svg", "", "svg"); + assert.equal(saved, true); + assert.deepEqual(clicks, ["dbx-diagram.svg"]); + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8e59655b..0c8338ec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,18 @@ importers: '@uiw/codemirror-theme-xcode': specifier: ^4.25.10 version: 4.25.10(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) + '@vue-flow/background': + specifier: ^1.3.2 + version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) + '@vue-flow/controls': + specifier: ^1.1.3 + version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.35(typescript@6.0.3)) + '@vue-flow/minimap': + specifier: ^1.5.4 + version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) '@vueuse/core': specifier: ^14.2.1 version: 14.2.1(vue@3.5.35(typescript@6.0.3)) @@ -134,6 +146,9 @@ importers: echarts: specifier: ^6.1.0 version: 6.1.0 + elkjs: + specifier: ^0.11.1 + version: 0.11.1 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -1572,6 +1587,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -1659,6 +1677,29 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-flow/background@1.3.2': + resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/controls@1.1.3': + resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + + '@vue-flow/minimap@1.5.4': + resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + '@vue/compiler-core@3.5.35': resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} @@ -1724,14 +1765,23 @@ packages: '@vuedx/template-ast-types@0.7.1': resolution: {integrity: sha512-Mqugk/F0lFN2u9bhimH6G1kSu2hhLi2WoqgCVxrMvgxm2kDc30DtdvVGRq+UgEmKVP61OudcMtZqkUoGQeFBUQ==} + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + '@vueuse/core@14.2.1': resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} peerDependencies: vue: ^3.5.0 + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + '@vueuse/metadata@14.2.1': resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + '@vueuse/shared@14.2.1': resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} peerDependencies: @@ -2053,6 +2103,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -2177,6 +2265,9 @@ packages: electron-to-chromium@1.5.382: resolution: {integrity: sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4976,6 +5067,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.20': {} + '@types/web-bluetooth@0.0.21': {} '@types/whatwg-mimetype@3.0.2': {} @@ -5120,6 +5213,34 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3)) + vue: 3.5.35(typescript@6.0.3) + + '@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3)) + vue: 3.5.35(typescript@6.0.3) + + '@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.35(typescript@6.0.3)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.35(typescript@6.0.3) + transitivePeerDependencies: + - '@vue/composition-api' + + '@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3)) + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.35(typescript@6.0.3) + '@vue/compiler-core@3.5.35': dependencies: '@babel/parser': 7.29.7 @@ -5248,6 +5369,16 @@ snapshots: dependencies: '@vue/compiler-core': 3.5.39 + '@vueuse/core@10.11.1(vue@3.5.35(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.35(typescript@6.0.3)) + vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/core@14.2.1(vue@3.5.35(typescript@6.0.3))': dependencies: '@types/web-bluetooth': 0.0.21 @@ -5255,8 +5386,17 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.35(typescript@6.0.3)) vue: 3.5.35(typescript@6.0.3) + '@vueuse/metadata@10.11.1': {} + '@vueuse/metadata@14.2.1': {} + '@vueuse/shared@10.11.1(vue@3.5.35(typescript@6.0.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/shared@14.2.1(vue@3.5.35(typescript@6.0.3))': dependencies: vue: 3.5.35(typescript@6.0.3) @@ -5551,6 +5691,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + dayjs@1.11.21: {} debounce-fn@4.0.0: @@ -5643,6 +5819,8 @@ snapshots: electron-to-chromium@1.5.382: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index f45c260f5..8e22c2ce1 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -333,6 +333,16 @@ pub async fn get_columns( .await } +#[tauri::command] +pub async fn get_all_columns( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, +) -> Result, String> { + dbx_core::schema::get_all_columns_core(&state, &connection_id, &database, &schema).await +} + #[tauri::command] pub async fn get_sqlserver_column_metadata( state: State<'_, Arc>, diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index 3270ad8ce..07fe298e0 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -206,7 +206,8 @@ mod execution_tests { #[tokio::test] async fn stops_on_first_failure_by_default() { - let summary = run_fake_script(vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], false, None).await; + let summary: SqlFileSummary = + run_fake_script(vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], false, None).await; assert_eq!(summary.success_count, 1); assert_eq!(summary.failure_count, 1); diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 413555ef3..3cc9d37bd 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -95,6 +95,10 @@ pub async fn start_transfer( let total_tables = sorted_tables.len(); log::info!("[transfer] starting transfer_id={} tables={}", transfer_id, total_tables); + let mut failed_tables: Vec = Vec::new(); + let mut last_rows_transferred = 0_u64; + let mut last_total_rows = None; + if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres) && matches!(target_db_type, dbx_core::models::connection::DatabaseType::Postgres) { @@ -103,7 +107,11 @@ pub async fn start_transfer( &request, &source_pool_key, &target_pool_key, - |progress| emit_progress(&app, progress), + |progress| { + last_rows_transferred = progress.rows_transferred; + last_total_rows = progress.total_rows; + emit_progress(&app, progress); + }, ) .await { @@ -116,8 +124,8 @@ pub async fn start_transfer( table: "schema dependencies".to_string(), table_index: 0, total_tables, - rows_transferred: 0, - total_rows: None, + rows_transferred: last_rows_transferred, + total_rows: last_total_rows, status: TransferStatus::Cancelled, error: None, terminal: true, @@ -134,8 +142,8 @@ pub async fn start_transfer( table: "schema dependencies".to_string(), table_index: 0, total_tables, - rows_transferred: 0, - total_rows: None, + rows_transferred: last_rows_transferred, + total_rows: last_total_rows, status: TransferStatus::Error, error: Some(e), terminal: true, @@ -146,8 +154,6 @@ pub async fn start_transfer( } } } - - let mut failed_tables: Vec = Vec::new(); for (i, table) in sorted_tables.iter().enumerate() { if dbx_core::transfer::is_cancelled(&transfer_id).await { emit_progress( @@ -157,8 +163,8 @@ pub async fn start_transfer( table: table.clone(), table_index: i, total_tables, - rows_transferred: 0, - total_rows: None, + rows_transferred: last_rows_transferred, + total_rows: last_total_rows, status: TransferStatus::Cancelled, error: None, terminal: true, @@ -170,9 +176,6 @@ pub async fn start_transfer( log::info!("[transfer] table {}/{}: {}", i + 1, total_tables, table); - let mut last_rows_transferred = 0_u64; - let mut last_total_rows = None; - match dbx_core::transfer::transfer_table( &state, &request, @@ -215,8 +218,8 @@ pub async fn start_transfer( table: table.clone(), table_index: i, total_tables, - rows_transferred: 0, - total_rows: None, + rows_transferred: last_rows_transferred, + total_rows: last_total_rows, status: TransferStatus::Cancelled, error: None, terminal: true, @@ -316,8 +319,8 @@ pub async fn start_transfer( table: String::new(), table_index: total_tables, total_tables, - rows_transferred: 0, - total_rows: None, + rows_transferred: last_rows_transferred, + total_rows: last_total_rows, status: if failed_tables.is_empty() { TransferStatus::Done } else { TransferStatus::Error }, error: if failed_tables.is_empty() { if skip_suffix.is_empty() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6e15d3a4..d4e5cdb65 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1529,6 +1529,7 @@ pub fn run() { commands::schema::list_schema_infos, commands::schema::list_data_types, commands::schema::get_columns, + commands::schema::get_all_columns, commands::schema::get_sqlserver_column_metadata, commands::schema::list_indexes, commands::schema::list_foreign_keys,