Merge pull request #52 from SuLea-IT/codex/er-diagram

[codex] add database relationship diagrams
This commit is contained in:
skyler 2026-05-02 16:22:03 +08:00 committed by GitHub
commit 2ad2e138b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2159 additions and 5 deletions

2
src-tauri/Cargo.lock generated
View File

@ -1548,7 +1548,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.3.4"
version = "0.3.5"
dependencies = [
"anyhow",
"chrono",

View File

@ -30,6 +30,39 @@ fn get_opt_str(row: &MySqlRow, name: &str) -> Option<String> {
})
}
fn numeric_metadata_u64_to_i32(value: Option<u64>) -> Option<i32> {
value.and_then(|v| i32::try_from(v).ok())
}
fn numeric_metadata_i64_to_i32(value: Option<i64>) -> Option<i32> {
value.and_then(|v| i32::try_from(v).ok())
}
fn numeric_metadata_str_to_i32(value: Option<String>) -> Option<i32> {
value.and_then(|v| v.parse::<i64>().ok())
.and_then(|v| i32::try_from(v).ok())
}
fn get_opt_i32(row: &MySqlRow, name: &str) -> Option<i32> {
if row.try_get_raw(name).map(|v| v.is_null()).unwrap_or(true) {
return None;
}
row.try_get::<Option<i32>, _>(name)
.ok()
.flatten()
.or_else(|| numeric_metadata_i64_to_i32(row.try_get::<Option<i64>, _>(name).ok().flatten()))
.or_else(|| numeric_metadata_u64_to_i32(row.try_get::<Option<u64>, _>(name).ok().flatten()))
.or_else(|| numeric_metadata_str_to_i32(row.try_get::<Option<String>, _>(name).ok().flatten()))
.or_else(|| {
row.try_get::<Option<Vec<u8>>, _>(name)
.ok()
.flatten()
.and_then(|b| String::from_utf8(b).ok())
.and_then(|v| numeric_metadata_str_to_i32(Some(v)))
})
}
fn mysql_temporal_to_json_value(row: &MySqlRow, idx: usize) -> Option<serde_json::Value> {
if let Ok(v) = row.try_get::<NaiveDateTime, _>(idx) {
return Some(serde_json::Value::String(v.to_string()));
@ -193,8 +226,8 @@ pub async fn get_columns(
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
extra: get_opt_str(row, "EXTRA"),
comment: get_opt_str(row, "COLUMN_COMMENT").filter(|s| !s.is_empty()),
numeric_precision: row.get::<Option<i32>, _>("NUMERIC_PRECISION"),
numeric_scale: row.get::<Option<i32>, _>("NUMERIC_SCALE"),
numeric_precision: get_opt_i32(row, "NUMERIC_PRECISION"),
numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"),
})
.collect())
}
@ -326,3 +359,19 @@ pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Res
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numeric_metadata_accepts_unsigned_information_schema_values() {
assert_eq!(numeric_metadata_u64_to_i32(Some(65)), Some(65));
}
#[test]
fn numeric_metadata_ignores_values_outside_frontend_range() {
assert_eq!(numeric_metadata_u64_to_i32(Some(i32::MAX as u64 + 1)), None);
assert_eq!(numeric_metadata_u64_to_i32(None), None);
}
}

View File

@ -36,6 +36,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue";
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue";
import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue";
import type { ConnectionConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -117,12 +118,17 @@ const showDangerDialog = ref(false);
const showTransferDialog = ref(false);
const showSchemaDiffDialog = ref(false);
const showSqlFileDialog = ref(false);
const showDiagramDialog = ref(false);
const transferPrefillConnectionId = ref("");
const transferPrefillDatabase = ref("");
const schemaDiffPrefillConnectionId = ref("");
const schemaDiffPrefillDatabase = ref("");
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
const diagramFocusTableName = ref("");
const databaseOptions = ref<Record<string, string[]>>({});
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
const checkingUpdates = ref(false);
@ -177,6 +183,17 @@ watch(() => connectionStore.sqlFileSource, (v) => {
}
});
watch(() => connectionStore.diagramSource, (v) => {
if (v) {
diagramPrefillConnectionId.value = v.connectionId;
diagramPrefillDatabase.value = v.database;
diagramPrefillSchema.value = v.schema ?? "";
diagramFocusTableName.value = v.tableName ?? "";
showDiagramDialog.value = true;
connectionStore.diagramSource = null;
}
});
function onConnectionConnectStarted(name: string) {
toast(t("connection.connecting", { name }), 30000);
}
@ -1210,6 +1227,13 @@ async function setupFileDrop() {
:prefill-connection-id="sqlFilePrefillConnectionId"
:prefill-database="sqlFilePrefillDatabase"
/>
<SchemaDiagramDialog
v-model:open="showDiagramDialog"
:prefill-connection-id="diagramPrefillConnectionId"
:prefill-database="diagramPrefillDatabase"
:prefill-schema="diagramPrefillSchema"
:focus-table-name="diagramFocusTableName"
/>
<Dialog v-model:open="showUpdateDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>

View File

@ -0,0 +1,956 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/tauri";
import type { DatabaseType } from "@/types/database";
import {
buildDiagramRelationships,
filterDiagramTables,
layoutDiagramTables,
type DiagramPosition,
type DiagramRelationship,
type DiagramTable,
} from "@/lib/erDiagram";
import { buildEngineeringDiagram } from "@/lib/engineeringDiagram";
import {
buildEngineeringDiagramSvg,
buildTableDiagramSvg,
diagramSvgFileName,
} from "@/lib/diagramSvgExport";
import {
clampDiagramZoom,
zoomFromGestureScale,
zoomFromWheelDelta,
} from "@/lib/diagramZoom";
import {
Download, KeyRound, Link2, Loader2, Maximize2, Network, RefreshCw, Search, Table2,
ZoomIn, ZoomOut,
} from "lucide-vue-next";
import { useToast } from "@/composables/useToast";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
focusTableName?: string;
}>();
const SQL_TYPES: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"];
const SCHEMA_AWARE_TYPES: DatabaseType[] = ["postgres", "sqlserver", "oracle", "redshift"];
const CARD_WIDTH = 270;
const COLUMN_ROW_HEIGHT = 24;
const CARD_HEADER_HEIGHT = 44;
const CARD_BOTTOM_PADDING = 12;
const MAX_VISIBLE_COLUMNS = 9;
const METADATA_BATCH_SIZE = 4;
const ROUTE_PADDING = 56;
const ROUTE_BLOCK_MARGIN = 18;
const connectionId = ref("");
const database = ref("");
const schema = ref("");
const databases = ref<string[]>([]);
const schemas = ref<string[]>([]);
const tables = ref<DiagramTable[]>([]);
const tableSearch = ref("");
const loadingDatabases = ref(false);
const loadingSchemas = ref(false);
const loadingDiagram = ref(false);
const loadedTableCount = ref(0);
const totalTableCount = ref(0);
const failedTableCount = ref(0);
const positions = ref<Record<string, DiagramPosition>>({});
const showAllTables = ref(false);
const diagramViewport = ref<HTMLDivElement | null>(null);
const diagramMode = ref<"table" | "engineering">("table");
const zoom = ref(1);
const gestureStartZoom = ref(1);
const dragging = ref<{
table: string;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const sqlConnections = computed(() =>
store.connections.filter((connection) => SQL_TYPES.includes(connection.db_type)),
);
const selectedConnection = computed(() =>
connectionId.value ? store.getConfig(connectionId.value) : undefined,
);
const isSchemaAware = computed(() =>
!!selectedConnection.value && SCHEMA_AWARE_TYPES.includes(selectedConnection.value.db_type),
);
const allRelationships = computed(() => buildDiagramRelationships(tables.value));
const relatedTableNames = computed(() => {
const focus = props.focusTableName;
const names = new Set<string>();
if (!focus) return names;
names.add(focus);
for (const relationship of allRelationships.value) {
if (relationship.sourceTable === focus) names.add(relationship.targetTable);
if (relationship.targetTable === focus) names.add(relationship.sourceTable);
}
return names;
});
const visibleTables = computed(() => {
const filtered = filterDiagramTables(tables.value, tableSearch.value);
if (props.focusTableName && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name));
}
return filtered;
});
const visibleTableMap = computed(() =>
new Map(visibleTables.value.map((table) => [table.name, table])),
);
const visibleRelationships = computed(() =>
buildDiagramRelationships(visibleTables.value),
);
const diagramReady = computed(() =>
!!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value),
);
const loadingText = computed(() =>
totalTableCount.value > 0
? t("diagram.loadingProgress", { loaded: loadedTableCount.value, total: totalTableCount.value })
: t("diagram.loading"),
);
function connectionIconType(id: string) {
const config = store.getConfig(id);
return config?.driver_profile || config?.db_type || "mysql";
}
function tableHeight(table: DiagramTable): number {
const visibleCount = Math.min(table.columns.length, MAX_VISIBLE_COLUMNS);
const overflowHeight = table.columns.length > MAX_VISIBLE_COLUMNS ? 24 : 0;
return CARD_HEADER_HEIGHT + visibleCount * COLUMN_ROW_HEIGHT + overflowHeight + CARD_BOTTOM_PADDING;
}
const canvasSize = computed(() => {
let width = 960;
let height = 540;
for (const table of visibleTables.value) {
const position = positions.value[table.name];
if (!position) continue;
width = Math.max(width, position.x + CARD_WIDTH + 80);
height = Math.max(height, position.y + tableHeight(table) + 80);
}
return { width, height };
});
const engineeringDiagram = computed(() =>
buildEngineeringDiagram(visibleTables.value, visibleRelationships.value, positions.value),
);
const activeCanvasSize = computed(() =>
diagramMode.value === "engineering" ? engineeringDiagram.value.canvas : canvasSize.value,
);
function resetLayout() {
const count = visibleTables.value.length;
const columnsPerRow = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(count, 1)))));
positions.value = layoutDiagramTables(visibleTables.value, {
columnsPerRow,
cardWidth: CARD_WIDTH,
rowHeight: 240,
gapX: 64,
gapY: 44,
});
}
function visibleColumns(table: DiagramTable) {
return table.columns.slice(0, MAX_VISIBLE_COLUMNS);
}
function hiddenColumnCount(table: DiagramTable): number {
return Math.max(0, table.columns.length - MAX_VISIBLE_COLUMNS);
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function relationshipTitle(relationship: DiagramRelationship): string {
return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`;
}
function columnAnchorY(tableName: string, columnName: string): number {
const table = visibleTableMap.value.get(tableName);
const position = positions.value[tableName];
if (!table || !position) return 0;
const index = table.columns.findIndex((column) => column.name === columnName);
if (index < 0) return position.y + CARD_HEADER_HEIGHT / 2;
const visibleIndex = Math.min(index, MAX_VISIBLE_COLUMNS - 1);
return position.y + CARD_HEADER_HEIGHT + visibleIndex * COLUMN_ROW_HEIGHT + COLUMN_ROW_HEIGHT / 2;
}
interface TableRect {
name: string;
x: number;
y: number;
width: number;
height: number;
}
interface DiagramGestureEvent extends Event {
scale?: number;
clientX?: number;
clientY?: number;
}
function getTableRect(tableName: string): TableRect | null {
const table = visibleTableMap.value.get(tableName);
const position = positions.value[tableName];
if (!table || !position) return null;
return {
name: tableName,
x: position.x,
y: position.y,
width: CARD_WIDTH,
height: tableHeight(table),
};
}
function rangesOverlap(a1: number, a2: number, b1: number, b2: number): boolean {
return Math.max(a1, b1) <= Math.min(a2, b2);
}
function routeSideX(rect: TableRect, routeX: number, offset = 0): number {
if (routeX < rect.x) return rect.x - offset;
return rect.x + rect.width + offset;
}
function tableRects(): TableRect[] {
return visibleTables.value
.map((table) => getTableRect(table.name))
.filter((rect): rect is TableRect => rect !== null);
}
function isVerticalRouteBlocked(routeX: number, y1: number, y2: number, ignoredTables: Set<string>): boolean {
const top = Math.min(y1, y2);
const bottom = Math.max(y1, y2);
return tableRects().some((rect) =>
!ignoredTables.has(rect.name) &&
routeX >= rect.x - ROUTE_BLOCK_MARGIN &&
routeX <= rect.x + rect.width + ROUTE_BLOCK_MARGIN &&
rangesOverlap(top, bottom, rect.y - ROUTE_BLOCK_MARGIN, rect.y + rect.height + ROUTE_BLOCK_MARGIN)
);
}
function isHorizontalRouteBlocked(y: number, x1: number, x2: number, ignoredTables: Set<string>): boolean {
const left = Math.min(x1, x2);
const right = Math.max(x1, x2);
return tableRects().some((rect) =>
!ignoredTables.has(rect.name) &&
y >= rect.y - ROUTE_BLOCK_MARGIN &&
y <= rect.y + rect.height + ROUTE_BLOCK_MARGIN &&
rangesOverlap(left, right, rect.x - ROUTE_BLOCK_MARGIN, rect.x + rect.width + ROUTE_BLOCK_MARGIN)
);
}
function candidateRouteXs(source: TableRect, target: TableRect): number[] {
const candidates = new Set<number>();
const sourceRight = source.x + source.width;
const targetRight = target.x + target.width;
const minLeft = Math.min(source.x, target.x);
const maxRight = Math.max(sourceRight, targetRight);
candidates.add(minLeft - ROUTE_PADDING);
candidates.add(maxRight + ROUTE_PADDING);
if (sourceRight + ROUTE_PADDING <= target.x) {
candidates.add((sourceRight + target.x) / 2);
}
if (targetRight + ROUTE_PADDING <= source.x) {
candidates.add((targetRight + source.x) / 2);
}
const columns = [...new Set(tableRects().map((rect) => rect.x))]
.sort((left, right) => left - right);
for (let index = 0; index < columns.length - 1; index++) {
const leftRight = columns[index] + CARD_WIDTH;
const rightLeft = columns[index + 1];
if (rightLeft - leftRight >= ROUTE_PADDING) {
candidates.add((leftRight + rightLeft) / 2);
}
}
return [...candidates].sort((left, right) => {
const leftSourceX = routeSideX(source, left);
const leftTargetX = routeSideX(target, left);
const rightSourceX = routeSideX(source, right);
const rightTargetX = routeSideX(target, right);
return (Math.abs(left - leftSourceX) + Math.abs(left - leftTargetX)) -
(Math.abs(right - rightSourceX) + Math.abs(right - rightTargetX));
});
}
function relationshipPath(relationship: DiagramRelationship): string {
const source = getTableRect(relationship.sourceTable);
const target = getTableRect(relationship.targetTable);
if (!source || !target) return "";
const y1 = columnAnchorY(relationship.sourceTable, relationship.sourceColumn);
const y2 = columnAnchorY(relationship.targetTable, relationship.targetColumn);
const ignoredTables = new Set([source.name, target.name]);
const candidates = candidateRouteXs(source, target);
const routeX = candidates.find((candidate) => {
const x1 = routeSideX(source, candidate);
const x2 = routeSideX(target, candidate);
return !isVerticalRouteBlocked(candidate, y1, y2, ignoredTables) &&
!isHorizontalRouteBlocked(y1, x1, candidate, ignoredTables) &&
!isHorizontalRouteBlocked(y2, candidate, x2, ignoredTables);
}) ?? candidates[0] ?? Math.max(source.x + source.width, target.x + target.width) + ROUTE_PADDING;
const x1 = routeSideX(source, routeX, 2);
const x2 = routeSideX(target, routeX, 2);
return `M ${x1} ${y1} L ${routeX} ${y1} L ${routeX} ${y2} L ${x2} ${y2}`;
}
function engineeringEntityCenter(tableName: string): DiagramPosition {
const entity = engineeringDiagram.value.entities.find((item) => item.name === tableName);
return entity
? { x: entity.x + entity.width / 2, y: entity.y + entity.height / 2 }
: { x: 0, y: 0 };
}
function engineeringAttributeCenter(attribute: { x: number; y: number; width: number; height: number }): DiagramPosition {
return { x: attribute.x + attribute.width / 2, y: attribute.y + attribute.height / 2 };
}
function engineeringRelationshipCenter(relationship: { x: number; y: number; width: number; height: number }): DiagramPosition {
return { x: relationship.x + relationship.width / 2, y: relationship.y + relationship.height / 2 };
}
function engineeringCardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition {
return {
x: from.x + (to.x - from.x) * 0.72,
y: from.y + (to.y - from.y) * 0.72,
};
}
async function loadDatabases(id: string) {
if (!id) return;
loadingDatabases.value = true;
databases.value = [];
try {
await store.ensureConnected(id);
const dbs = await api.listDatabases(id);
databases.value = dbs.map((db) => db.name);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
loadingDatabases.value = false;
}
}
async function loadSchemas() {
schemas.value = [];
schema.value = "";
if (!connectionId.value || !database.value) return;
if (!isSchemaAware.value) {
schema.value = database.value;
return;
}
loadingSchemas.value = true;
try {
const names = await api.listSchemas(connectionId.value, database.value);
schemas.value = names;
schema.value = props.prefillSchema && names.includes(props.prefillSchema)
? props.prefillSchema
: names.includes("public") ? "public" : (names[0] ?? "");
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
loadingSchemas.value = false;
}
}
async function setConnection(id: string) {
connectionId.value = id;
database.value = "";
schema.value = "";
tables.value = [];
positions.value = {};
await loadDatabases(id);
if (databases.value.length === 1) {
await setDatabase(databases.value[0]);
}
}
async function setDatabase(value: string) {
database.value = value;
tables.value = [];
positions.value = {};
await loadSchemas();
if (diagramReady.value) await loadDiagram();
}
async function setSchema(value: string) {
schema.value = value;
tables.value = [];
positions.value = {};
if (diagramReady.value) await loadDiagram();
}
async function loadTableDiagramData(tableName: string, querySchema: string): Promise<DiagramTable> {
try {
const [columns, foreignKeys] = await Promise.all([
api.getColumns(connectionId.value, database.value, querySchema, tableName),
api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => []),
]);
return { name: tableName, columns, foreignKeys };
} catch (e) {
failedTableCount.value += 1;
console.warn(`[diagram] failed to load table metadata: ${tableName}`, e);
return { name: tableName, columns: [], foreignKeys: [] };
}
}
async function loadDiagram() {
if (!diagramReady.value) return;
loadingDiagram.value = true;
tables.value = [];
positions.value = {};
loadedTableCount.value = 0;
totalTableCount.value = 0;
failedTableCount.value = 0;
try {
await store.ensureConnected(connectionId.value);
const querySchema = schema.value || database.value;
const tableInfos = await api.listTables(connectionId.value, database.value, querySchema);
const baseTables = tableInfos
.filter((table) => table.table_type !== "VIEW")
.sort((a, b) => a.name.localeCompare(b.name));
totalTableCount.value = baseTables.length;
const loadedTables: DiagramTable[] = [];
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
const batchTables = await Promise.all(
batch.map((table) => loadTableDiagramData(table.name, querySchema)),
);
loadedTables.push(...batchTables);
loadedTableCount.value = loadedTables.length;
}
tables.value = loadedTables;
showAllTables.value = false;
await nextTick();
resetLayout();
if (failedTableCount.value > 0) {
toast(t("diagram.partialError", { count: failedTableCount.value }), 5000);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
loadingDiagram.value = false;
}
}
async function initialize() {
connectionId.value = "";
database.value = "";
schema.value = "";
databases.value = [];
schemas.value = [];
tables.value = [];
tableSearch.value = "";
showAllTables.value = false;
diagramMode.value = "table";
zoom.value = 1;
positions.value = {};
loadedTableCount.value = 0;
totalTableCount.value = 0;
failedTableCount.value = 0;
if (props.prefillConnectionId) {
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
const initialDatabase = props.prefillDatabase && databases.value.includes(props.prefillDatabase)
? props.prefillDatabase
: (props.prefillDatabase || databases.value[0] || "");
if (initialDatabase) await setDatabase(initialDatabase);
return;
}
if (sqlConnections.value.length === 1) {
await setConnection(sqlConnections.value[0].id);
}
}
function applyZoomAt(nextZoom: number, clientX?: number, clientY?: number) {
const viewport = diagramViewport.value;
const previousZoom = zoom.value;
if (!viewport || nextZoom === previousZoom) {
zoom.value = nextZoom;
return;
}
const rect = viewport.getBoundingClientRect();
const originX = (clientX ?? rect.left + rect.width / 2) - rect.left;
const originY = (clientY ?? rect.top + rect.height / 2) - rect.top;
const contentX = (viewport.scrollLeft + originX) / previousZoom;
const contentY = (viewport.scrollTop + originY) / previousZoom;
zoom.value = nextZoom;
void nextTick(() => {
viewport.scrollLeft = contentX * nextZoom - originX;
viewport.scrollTop = contentY * nextZoom - originY;
});
}
function zoomIn() {
applyZoomAt(clampDiagramZoom(zoom.value + 0.1));
}
function zoomOut() {
applyZoomAt(clampDiagramZoom(zoom.value - 0.1));
}
function resetZoomAndLayout() {
zoom.value = 1;
resetLayout();
}
function tableRelationshipPaths(): Record<string, string> {
return Object.fromEntries(
visibleRelationships.value.map((relationship) => [relationship.id, relationshipPath(relationship)]),
);
}
function currentDiagramSvg(): string {
if (diagramMode.value === "engineering") {
return buildEngineeringDiagramSvg(engineeringDiagram.value);
}
return buildTableDiagramSvg({
tables: visibleTables.value,
relationships: visibleRelationships.value,
positions: positions.value,
relationshipPaths: tableRelationshipPaths(),
canvas: canvasSize.value,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
maxVisibleColumns: MAX_VISIBLE_COLUMNS,
cardBottomPadding: CARD_BOTTOM_PADDING,
moreColumnsLabel: (count) => t("diagram.moreColumns", { count }),
});
}
async function exportSvg() {
try {
const [{ save }, { writeTextFile }] = await Promise.all([
import("@tauri-apps/plugin-dialog"),
import("@tauri-apps/plugin-fs"),
]);
const scopeName = isSchemaAware.value && schema.value
? `${database.value}-${schema.value}`
: database.value;
const defaultPath = diagramSvgFileName(
selectedConnection.value?.name ?? "",
scopeName,
diagramMode.value,
);
const path = await save({
defaultPath,
filters: [{ name: "SVG", extensions: ["svg"] }],
});
if (!path) return;
await writeTextFile(path, currentDiagramSvg());
toast(t("diagram.exportedSvg"));
} catch (e: any) {
toast(t("diagram.exportSvgFailed", { message: e?.message || String(e) }), 5000);
}
}
function onDiagramWheel(event: WheelEvent) {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
applyZoomAt(zoomFromWheelDelta(zoom.value, event.deltaY), event.clientX, event.clientY);
}
function onDiagramGestureStart(event: DiagramGestureEvent) {
event.preventDefault();
gestureStartZoom.value = zoom.value;
}
function onDiagramGestureChange(event: DiagramGestureEvent) {
if (typeof event.scale !== "number") return;
event.preventDefault();
applyZoomAt(zoomFromGestureScale(gestureStartZoom.value, event.scale), event.clientX, event.clientY);
}
function startDrag(table: string, event: MouseEvent) {
event.preventDefault();
const position = positions.value[table];
if (!position) return;
dragging.value = {
table,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
window.addEventListener("mousemove", onDrag);
window.addEventListener("mouseup", stopDrag);
}
function onDrag(event: MouseEvent) {
if (!dragging.value) return;
const current = dragging.value;
positions.value = {
...positions.value,
[current.table]: {
x: Math.max(16, current.originX + (event.clientX - current.startX) / zoom.value),
y: Math.max(16, current.originY + (event.clientY - current.startY) / zoom.value),
},
};
}
function stopDrag() {
dragging.value = null;
window.removeEventListener("mousemove", onDrag);
window.removeEventListener("mouseup", stopDrag);
}
watch(open, (value) => {
if (value) void initialize();
});
watch(() => visibleTables.value.map((table) => table.name).join("\n"), () => {
resetLayout();
});
onUnmounted(stopDrag);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
<DialogHeader class="px-4 py-3 border-b">
<DialogTitle class="flex items-center gap-2">
<Network class="w-4 h-4" />
{{ t('diagram.title') }}
</DialogTitle>
</DialogHeader>
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
<Select :model-value="connectionId" @update:model-value="(value: any) => setConnection(String(value))">
<SelectTrigger class="h-8 w-48 text-xs">
<div v-if="connectionId" class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ selectedConnection?.name }}</span>
</div>
<SelectValue v-else :placeholder="t('diagram.selectConnection')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
<div class="flex items-center gap-2">
<DatabaseIcon :db-type="connection.driver_profile || connection.db_type" class="w-3.5 h-3.5" />
{{ connection.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => setDatabase(String(value))">
<SelectTrigger class="h-8 w-44 text-xs">
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<Select
v-if="isSchemaAware"
:model-value="schema"
:disabled="!schemas.length || loadingSchemas"
@update:model-value="(value: any) => setSchema(String(value))"
>
<SelectTrigger class="h-8 w-40 text-xs">
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="name in schemas" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tableSearch" class="h-8 pl-7 text-xs" :placeholder="t('diagram.searchTables')" />
</div>
<div class="flex h-8 shrink-0 items-center overflow-hidden rounded-md border bg-background">
<Button
variant="ghost"
size="sm"
class="h-8 rounded-none px-2 text-xs"
:class="diagramMode === 'table' ? 'bg-accent' : ''"
@click="diagramMode = 'table'"
>
<Table2 class="mr-1 h-3.5 w-3.5" />
{{ t('diagram.tableMode') }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 rounded-none border-l px-2 text-xs"
:class="diagramMode === 'engineering' ? 'bg-accent' : ''"
@click="diagramMode = 'engineering'"
>
<Network class="mr-1 h-3.5 w-3.5" />
{{ t('diagram.engineeringMode') }}
</Button>
</div>
<Button
v-if="focusTableName && tables.length > 0"
variant="outline"
size="sm"
class="h-8 px-2 text-xs"
@click="showAllTables = !showAllTables"
>
{{ showAllTables ? t('diagram.relatedTables') : t('diagram.allTables') }}
</Button>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t('diagram.tablesCount', { count: visibleTables.length }) }}
</Badge>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t('diagram.relationshipsCount', { count: visibleRelationships.length }) }}
</Badge>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || visibleTables.length === 0" :title="t('diagram.exportSvg')" @click="exportSvg">
<Download class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="loadDiagram">
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.zoomOut')" @click="zoomOut">
<ZoomOut class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.zoomIn')" @click="zoomIn">
<ZoomIn class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('diagram.resetLayout')" @click="resetZoomAndLayout">
<Maximize2 class="h-4 w-4" />
</Button>
</div>
<div class="flex-1 min-h-0 bg-muted/20">
<div v-if="loadingDiagram" class="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ loadingText }}
</div>
<div v-else-if="!diagramReady" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t('diagram.selectTarget') }}
</div>
<div v-else-if="tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t('diagram.empty') }}
</div>
<div v-else-if="visibleTables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t('diagram.noMatches') }}
</div>
<div
v-else
ref="diagramViewport"
class="h-full overflow-auto"
@wheel="onDiagramWheel"
@gesturestart="onDiagramGestureStart"
@gesturechange="onDiagramGestureChange"
>
<div
class="relative"
:style="{ width: `${activeCanvasSize.width * zoom}px`, height: `${activeCanvasSize.height * zoom}px` }"
>
<div
class="absolute left-0 top-0 origin-top-left"
:style="{ width: `${activeCanvasSize.width}px`, height: `${activeCanvasSize.height}px`, transform: `scale(${zoom})` }"
>
<template v-if="diagramMode === 'table'">
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<defs>
<marker id="diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 8 4 L 0 8 z" class="fill-primary/70" />
</marker>
</defs>
<path
v-for="relationship in visibleRelationships"
:key="relationship.id"
:d="relationshipPath(relationship)"
class="fill-none stroke-primary/55"
stroke-width="1.6"
marker-end="url(#diagram-arrow)"
>
<title>{{ relationshipTitle(relationship) }}</title>
</path>
</svg>
<div
v-for="table in visibleTables"
:key="table.name"
class="absolute overflow-hidden rounded-md border bg-background shadow-sm"
:class="table.name === focusTableName ? 'border-primary ring-1 ring-primary/30' : 'border-border'"
:style="{
width: `${CARD_WIDTH}px`,
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
}"
>
<div
class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing"
@mousedown="startDrag(table.name, $event)"
>
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
</div>
<div>
<div
v-for="column in visibleColumns(table)"
:key="column.name"
class="flex h-6 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0"
>
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Link2 v-else-if="isForeignKeyColumn(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
<span class="max-w-24 truncate text-[10px] text-muted-foreground">{{ column.data_type }}</span>
</div>
<div v-if="hiddenColumnCount(table) > 0" class="h-6 px-3 text-xs leading-6 text-muted-foreground">
{{ t('diagram.moreColumns', { count: hiddenColumnCount(table) }) }}
</div>
</div>
</div>
</template>
<template v-else>
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<g class="stroke-foreground/70">
<line
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
:x1="engineeringEntityCenter(attribute.tableName).x"
:y1="engineeringEntityCenter(attribute.tableName).y"
:x2="engineeringAttributeCenter(attribute).x"
:y2="engineeringAttributeCenter(attribute).y"
stroke-width="1.2"
/>
<template v-for="relationship in engineeringDiagram.relationships" :key="relationship.id">
<line
:x1="engineeringEntityCenter(relationship.sourceTable).x"
:y1="engineeringEntityCenter(relationship.sourceTable).y"
:x2="engineeringRelationshipCenter(relationship).x"
:y2="engineeringRelationshipCenter(relationship).y"
stroke-width="1.4"
/>
<line
:x1="engineeringRelationshipCenter(relationship).x"
:y1="engineeringRelationshipCenter(relationship).y"
:x2="engineeringEntityCenter(relationship.targetTable).x"
:y2="engineeringEntityCenter(relationship.targetTable).y"
stroke-width="1.4"
/>
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).y"
>
{{ relationship.sourceCardinality }}
</text>
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).y"
>
{{ relationship.targetCardinality }}
</text>
</template>
</g>
</svg>
<div
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
class="absolute flex items-center justify-center rounded-full border border-green-600/55 bg-green-100/80 px-3 text-center text-xs text-green-950 shadow-sm dark:bg-green-950/35 dark:text-green-100"
:class="attribute.primaryKey ? 'font-semibold underline underline-offset-2' : ''"
:title="`${attribute.tableName}.${attribute.columnName}: ${attribute.dataType}`"
:style="{
width: `${attribute.width}px`,
height: `${attribute.height}px`,
transform: `translate(${attribute.x}px, ${attribute.y}px)`,
}"
>
<span class="truncate">{{ attribute.label }}</span>
</div>
<div
v-for="relationship in engineeringDiagram.relationships"
:key="relationship.id"
class="absolute flex items-center justify-center text-center text-xs font-medium text-red-950 dark:text-red-100"
:style="{
width: `${relationship.width}px`,
height: `${relationship.height}px`,
transform: `translate(${relationship.x}px, ${relationship.y}px)`,
}"
:title="`${relationship.sourceTable} -> ${relationship.targetTable}`"
>
<div class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35" style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" />
<span class="relative max-w-[70px] truncate">{{ relationship.label }}</span>
</div>
<div
v-for="entity in engineeringDiagram.entities"
:key="entity.id"
class="absolute flex items-center justify-center border border-blue-500/70 bg-blue-100/80 px-3 text-center text-sm font-semibold text-blue-950 shadow-sm dark:bg-blue-950/35 dark:text-blue-100"
:class="entity.name === focusTableName ? 'ring-2 ring-primary/40' : ''"
:style="{
width: `${entity.width}px`,
height: `${entity.height}px`,
transform: `translate(${entity.x}px, ${entity.y}px)`,
}"
>
<span class="truncate">{{ entity.name }}</span>
</div>
</template>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -5,7 +5,7 @@ import {
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
Pin, ArrowRightLeft, Download, FileCode,
Pin, ArrowRightLeft, Download, FileCode, Network,
} from "lucide-vue-next";
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
@ -34,6 +34,7 @@ const props = defineProps<{
}>();
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
function quoteIdent(name: string): string {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
@ -348,12 +349,27 @@ function openSqlFileExecution() {
}
}
function openDiagram() {
const node = props.node;
if (!node.connectionId || !node.database) return;
connectionStore.diagramSource = {
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
tableName: node.type === "table" ? node.label : undefined,
};
}
const canExpand = !leafTypes.has(props.node.type);
const canPin = computed(() => pinnableTypes.has(props.node.type));
const canOpenSqlFileExecution = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!config && !sqlFileUnsupportedTypes.has(config.db_type);
});
const canOpenDiagram = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return !!props.node.database && !!config && diagramSupportedTypes.has(config.db_type);
});
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
const hasTypeMenu = computed(() => {
const t = props.node.type;
@ -486,6 +502,9 @@ async function showMore() {
<ContextMenuItem v-if="canOpenSqlFileExecution" @click="openSqlFileExecution">
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
</ContextMenuItem>
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>
@ -505,6 +524,9 @@ async function showMore() {
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>

View File

@ -218,6 +218,34 @@ export default {
foreignKeys: "Foreign Keys",
triggers: "Triggers",
},
diagram: {
title: "Relationship Diagram",
open: "View Diagram",
selectConnection: "Select connection",
selectDatabase: "Select database",
selectSchema: "Select schema",
searchTables: "Search tables, columns, keys...",
refresh: "Refresh diagram",
loading: "Reading relationships...",
loadingProgress: "Reading relationships... {loaded}/{total}",
partialError: "Skipped metadata for {count} tables that failed to load",
selectTarget: "Select a connection and database",
empty: "No tables to display",
noMatches: "No matching tables",
tablesCount: "{count} tables",
relationshipsCount: "{count} relationships",
tableMode: "Table View",
engineeringMode: "Engineering ER",
allTables: "All Tables",
relatedTables: "Related Tables",
moreColumns: "+ {count} columns",
exportSvg: "Export SVG",
exportedSvg: "SVG exported",
exportSvgFailed: "Failed to export SVG: {message}",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
resetLayout: "Reset layout",
},
redis: {
selectKey: "Select a key to view its value",
noKeys: "No keys found",

View File

@ -218,6 +218,34 @@ export default {
foreignKeys: "外键",
triggers: "触发器",
},
diagram: {
title: "关系图",
open: "查看关系图",
selectConnection: "选择连接",
selectDatabase: "选择数据库",
selectSchema: "选择模式",
searchTables: "搜索表/字段/外键...",
refresh: "刷新关系图",
loading: "正在读取关系...",
loadingProgress: "正在读取关系... {loaded}/{total}",
partialError: "{count} 张表的元数据读取失败,已跳过",
selectTarget: "请选择连接和数据库",
empty: "暂无可展示的表",
noMatches: "没有匹配的表",
tablesCount: "{count} 张表",
relationshipsCount: "{count} 条关系",
tableMode: "表结构图",
engineeringMode: "工程 ER 图",
allTables: "全部表",
relatedTables: "相关表",
moreColumns: "+ {count} 个字段",
exportSvg: "导出 SVG",
exportedSvg: "SVG 已导出",
exportSvgFailed: "导出 SVG 失败:{message}",
zoomIn: "放大",
zoomOut: "缩小",
resetLayout: "重置布局",
},
redis: {
selectKey: "选择一个 key 查看值",
noKeys: "未找到 key",

261
src/lib/diagramSvgExport.ts Normal file
View File

@ -0,0 +1,261 @@
import type { EngineeringDiagram, EngineeringEntityNode } from "./engineeringDiagram";
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram";
type DiagramSvgMode = "table" | "engineering";
interface DiagramCanvas {
width: number;
height: number;
}
export interface TableDiagramSvgOptions {
tables: DiagramTable[];
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
relationshipPaths: Record<string, string>;
canvas: DiagramCanvas;
cardWidth: number;
cardHeaderHeight: number;
columnRowHeight: number;
maxVisibleColumns: number;
cardBottomPadding?: number;
moreColumnsLabel?: (count: number) => string;
}
function escapeXml(value: string | number): string {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function svgNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
}
function svgHeader(canvas: DiagramCanvas): string {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" viewBox="0 0 ${svgNumber(canvas.width)} ${svgNumber(canvas.height)}">`,
"<rect width=\"100%\" height=\"100%\" fill=\"#fafafa\"/>",
].join("");
}
function svgText(
label: string,
x: number,
y: number,
options: {
size?: number;
fill?: string;
weight?: string;
anchor?: "start" | "middle" | "end";
family?: string;
decoration?: string;
} = {},
): 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}"`);
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
}
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 [
"<defs>",
"<marker id=\"dbx-diagram-arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"4\" orient=\"auto\" markerUnits=\"strokeWidth\">",
"<path d=\"M 0 0 L 8 4 L 0 8 z\" fill=\"#2563eb\"/>",
"</marker>",
"</defs>",
].join("");
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
const parts = [
svgHeader(options.canvas),
tableDiagramDefs(),
"<g fill=\"none\" stroke=\"#2563eb\" stroke-opacity=\"0.58\" stroke-width=\"1.6\">",
];
for (const relationship of options.relationships) {
const path = options.relationshipPaths[relationship.id];
if (!path) continue;
parts.push(
`<path d="${escapeXml(path)}" marker-end="url(#dbx-diagram-arrow)">` +
`<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` +
"</path>",
);
}
parts.push("</g>");
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);
parts.push(`<g transform="translate(${svgNumber(position.x)} ${svgNumber(position.y)})">`);
parts.push(`<rect width="${options.cardWidth}" height="${svgNumber(height)}" rx="6" fill="#ffffff" stroke="#d4d4d8"/>`);
parts.push(`<rect width="${options.cardWidth}" height="${options.cardHeaderHeight}" rx="6" fill="#f4f4f5"/>`);
parts.push(`<path d="M 0 ${options.cardHeaderHeight} H ${options.cardWidth}" stroke="#e4e4e7"/>`);
parts.push(svgText(table.name, 36, options.cardHeaderHeight / 2, { size: 13, weight: "600" }));
parts.push(svgText(String(table.columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, {
size: 10,
anchor: "end",
fill: "#52525b",
}));
visibleColumns.forEach((column, index) => {
const rowTop = options.cardHeaderHeight + index * options.columnRowHeight;
const rowCenter = rowTop + options.columnRowHeight / 2;
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#f0f0f1"/>`);
if (column.is_primary_key) {
parts.push(svgText("PK", 14, rowCenter, { size: 9, fill: "#d97706", weight: "700" }));
} else if (isForeignKeyColumn(table, column.name)) {
parts.push(svgText("FK", 14, rowCenter, { size: 9, fill: "#2563eb", weight: "700" }));
}
parts.push(svgText(column.name, 38, rowCenter, { size: 11, family: "Menlo, Consolas, monospace" }));
parts.push(svgText(column.data_type, options.cardWidth - 12, rowCenter, {
size: 10,
fill: "#71717a",
anchor: "end",
}));
});
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("</g>");
}
parts.push("</svg>");
return parts.join("");
}
function nodeCenter(node: { x: number; y: number; width: number; height: number }): DiagramPosition {
return {
x: node.x + node.width / 2,
y: node.y + node.height / 2,
};
}
function cardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition {
return {
x: from.x + (to.x - from.x) * 0.72,
y: from.y + (to.y - from.y) * 0.72,
};
}
function entityCenterMap(entities: EngineeringEntityNode[]): Map<string, DiagramPosition> {
return new Map(entities.map((entity) => [entity.name, nodeCenter(entity)]));
}
export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string {
const parts = [svgHeader(diagram.canvas)];
const centers = entityCenterMap(diagram.entities);
parts.push("<g stroke=\"#52525b\" stroke-width=\"1.2\">");
for (const attribute of diagram.attributes) {
const from = centers.get(attribute.tableName);
if (!from) continue;
const to = nodeCenter(attribute);
parts.push(`<line x1="${svgNumber(from.x)}" y1="${svgNumber(from.y)}" x2="${svgNumber(to.x)}" y2="${svgNumber(to.y)}"/>`);
}
for (const relationship of diagram.relationships) {
const source = centers.get(relationship.sourceTable);
const target = centers.get(relationship.targetTable);
if (!source || !target) continue;
const middle = nodeCenter(relationship);
parts.push(`<line x1="${svgNumber(source.x)}" y1="${svgNumber(source.y)}" x2="${svgNumber(middle.x)}" y2="${svgNumber(middle.y)}"/>`);
parts.push(`<line x1="${svgNumber(middle.x)}" y1="${svgNumber(middle.y)}" x2="${svgNumber(target.x)}" y2="${svgNumber(target.y)}"/>`);
const sourceLabel = cardinalityPoint(middle, source);
const targetLabel = cardinalityPoint(middle, target);
parts.push(svgText(relationship.sourceCardinality, sourceLabel.x, sourceLabel.y - 8, { size: 13, weight: "700", anchor: "middle" }));
parts.push(svgText(relationship.targetCardinality, targetLabel.x, targetLabel.y - 8, { size: 13, weight: "700", anchor: "middle" }));
}
parts.push("</g>");
for (const attribute of diagram.attributes) {
parts.push(
`<ellipse cx="${svgNumber(attribute.x + attribute.width / 2)}" cy="${svgNumber(attribute.y + attribute.height / 2)}" ` +
`rx="${svgNumber(attribute.width / 2)}" ry="${svgNumber(attribute.height / 2)}" fill="#dcfce7" stroke="#16a34a" stroke-opacity="0.65"/>`,
);
parts.push(svgText(attribute.label, attribute.x + attribute.width / 2, attribute.y + attribute.height / 2, {
size: 11,
fill: "#052e16",
weight: attribute.primaryKey ? "700" : undefined,
anchor: "middle",
decoration: attribute.primaryKey ? "underline" : undefined,
}));
}
for (const relationship of diagram.relationships) {
const cx = relationship.x + relationship.width / 2;
const cy = relationship.y + relationship.height / 2;
const points = [
[cx, relationship.y],
[relationship.x + relationship.width, cy],
[cx, relationship.y + relationship.height],
[relationship.x, cy],
].map(([x, y]) => `${svgNumber(x)},${svgNumber(y)}`).join(" ");
parts.push(`<polygon points="${points}" fill="#fee2e2" stroke="#ef4444" stroke-opacity="0.7"/>`);
parts.push(svgText(relationship.label, cx, cy, {
size: 11,
fill: "#450a0a",
weight: "600",
anchor: "middle",
}));
}
for (const entity of diagram.entities) {
parts.push(`<rect x="${svgNumber(entity.x)}" y="${svgNumber(entity.y)}" width="${entity.width}" height="${entity.height}" fill="#dbeafe" stroke="#3b82f6" stroke-opacity="0.7"/>`);
parts.push(svgText(entity.name, entity.x + entity.width / 2, entity.y + entity.height / 2, {
size: 13,
fill: "#172554",
weight: "700",
anchor: "middle",
}));
}
parts.push("</svg>");
return parts.join("");
}
function fileToken(value: string): string {
return value
.trim()
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
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";
return ["dbx", ...(context.length > 0 ? context : ["diagram"]), suffix].join("-") + ".svg";
}

16
src/lib/diagramZoom.ts Normal file
View File

@ -0,0 +1,16 @@
export const DIAGRAM_MIN_ZOOM = 0.6;
export const DIAGRAM_MAX_ZOOM = 1.5;
const WHEEL_ZOOM_SENSITIVITY = 0.003;
export function clampDiagramZoom(value: number): number {
const clamped = Math.min(DIAGRAM_MAX_ZOOM, Math.max(DIAGRAM_MIN_ZOOM, value));
return Number(clamped.toFixed(2));
}
export function zoomFromWheelDelta(currentZoom: number, deltaY: number): number {
return clampDiagramZoom(currentZoom * Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY));
}
export function zoomFromGestureScale(startZoom: number, scale: number): number {
return clampDiagramZoom(startZoom * scale);
}

View File

@ -0,0 +1,379 @@
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram";
export const ENGINEERING_ENTITY_WIDTH = 184;
export const ENGINEERING_ENTITY_HEIGHT = 58;
export const ENGINEERING_ATTRIBUTE_HEIGHT = 34;
export const ENGINEERING_RELATIONSHIP_WIDTH = 104;
export const ENGINEERING_RELATIONSHIP_HEIGHT = 58;
const ATTRIBUTE_MIN_WIDTH = 96;
const ATTRIBUTE_MAX_WIDTH = 156;
const ATTRIBUTE_GAP_X = 18;
const ATTRIBUTE_GAP_Y = 12;
const ATTRIBUTE_ENTITY_GAP = 38;
const HORIZONTAL_ATTRIBUTE_COLUMNS = 4;
const ENGINEERING_CLUSTER_GAP_X = 120;
const ENGINEERING_CLUSTER_GAP_Y = 100;
const CANVAS_PADDING = 80;
type AttributeSide = "top" | "right" | "bottom" | "left";
type EngineeringColumn = DiagramTable["columns"][number];
interface AttributeDraft {
column: EngineeringColumn;
width: number;
}
interface BlockSize {
width: number;
height: number;
}
interface EngineeringCluster {
tableName: string;
width: number;
height: number;
entityX: number;
entityY: number;
attributes: EngineeringAttributeNode[];
}
export interface EngineeringEntityNode {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
}
export interface EngineeringAttributeNode {
id: string;
tableName: string;
columnName: string;
label: string;
dataType: string;
primaryKey: boolean;
foreignKey: boolean;
x: number;
y: number;
width: number;
height: number;
}
export interface EngineeringRelationshipNode {
id: string;
label: string;
sourceTable: string;
targetTable: string;
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
x: number;
y: number;
width: number;
height: number;
}
export interface EngineeringDiagram {
entities: EngineeringEntityNode[];
attributes: EngineeringAttributeNode[];
relationships: EngineeringRelationshipNode[];
canvas: {
width: number;
height: number;
};
}
function attributeWidth(label: string): number {
return Math.min(ATTRIBUTE_MAX_WIDTH, Math.max(ATTRIBUTE_MIN_WIDTH, label.length * 10 + 30));
}
function relationshipLabel(relationship: DiagramRelationship): string {
if (relationship.name && relationship.name.length <= 16) return relationship.name;
return relationship.sourceColumn || "rel";
}
function entityCenter(entity: EngineeringEntityNode): DiagramPosition {
return {
x: entity.x + entity.width / 2,
y: entity.y + entity.height / 2,
};
}
function chunkAttributes(items: AttributeDraft[], size: number): AttributeDraft[][] {
const rows: AttributeDraft[][] = [];
for (let index = 0; index < items.length; index += size) {
rows.push(items.slice(index, index + size));
}
return rows;
}
function rowWidth(items: AttributeDraft[]): number {
if (items.length === 0) return 0;
return items.reduce((width, item) => width + item.width, 0) +
(items.length - 1) * ATTRIBUTE_GAP_X;
}
function horizontalBlockSize(items: AttributeDraft[]): BlockSize {
if (items.length === 0) return { width: 0, height: 0 };
const rows = chunkAttributes(items, HORIZONTAL_ATTRIBUTE_COLUMNS);
return {
width: Math.max(...rows.map(rowWidth)),
height: rows.length * ENGINEERING_ATTRIBUTE_HEIGHT + (rows.length - 1) * ATTRIBUTE_GAP_Y,
};
}
function verticalBlockSize(items: AttributeDraft[]): BlockSize {
if (items.length === 0) return { width: 0, height: 0 };
return {
width: Math.max(...items.map((item) => item.width)),
height: items.length * ENGINEERING_ATTRIBUTE_HEIGHT + (items.length - 1) * ATTRIBUTE_GAP_Y,
};
}
function sideGap(block: BlockSize): number {
return block.width > 0 && block.height > 0 ? ATTRIBUTE_ENTITY_GAP : 0;
}
function distributeAttributes(table: DiagramTable): Record<AttributeSide, AttributeDraft[]> {
const sides: AttributeSide[] = ["top", "right", "bottom", "left"];
const groups: Record<AttributeSide, AttributeDraft[]> = {
top: [],
right: [],
bottom: [],
left: [],
};
table.columns.forEach((column, index) => {
groups[sides[index % sides.length]].push({
column,
width: attributeWidth(column.name),
});
});
return groups;
}
function localAttributeNode(
table: DiagramTable,
item: AttributeDraft,
x: number,
y: number,
): EngineeringAttributeNode {
return {
id: `${table.name}:${item.column.name}`,
tableName: table.name,
columnName: item.column.name,
label: item.column.name,
dataType: item.column.data_type,
primaryKey: item.column.is_primary_key,
foreignKey: table.foreignKeys.some((fk) => fk.column === item.column.name),
x,
y,
width: item.width,
height: ENGINEERING_ATTRIBUTE_HEIGHT,
};
}
function buildEngineeringCluster(table: DiagramTable): EngineeringCluster {
const groups = distributeAttributes(table);
const topSize = horizontalBlockSize(groups.top);
const rightSize = verticalBlockSize(groups.right);
const bottomSize = horizontalBlockSize(groups.bottom);
const leftSize = verticalBlockSize(groups.left);
const leftGap = sideGap(leftSize);
const rightGap = sideGap(rightSize);
const topGap = sideGap(topSize);
const bottomGap = sideGap(bottomSize);
const centerWidth = Math.max(ENGINEERING_ENTITY_WIDTH, topSize.width, bottomSize.width);
const centerHeight = Math.max(ENGINEERING_ENTITY_HEIGHT, leftSize.height, rightSize.height);
const centerX = leftSize.width + leftGap;
const centerY = topSize.height + topGap;
const entityX = centerX + centerWidth / 2 - ENGINEERING_ENTITY_WIDTH / 2;
const entityY = centerY + centerHeight / 2 - ENGINEERING_ENTITY_HEIGHT / 2;
const attributes: EngineeringAttributeNode[] = [];
chunkAttributes(groups.top, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => {
let x = centerX + (centerWidth - rowWidth(row)) / 2;
const y = rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y);
row.forEach((item) => {
attributes.push(localAttributeNode(table, item, x, y));
x += item.width + ATTRIBUTE_GAP_X;
});
});
groups.right.forEach((item, index) => {
attributes.push(localAttributeNode(
table,
item,
centerX + centerWidth + rightGap + (rightSize.width - item.width) / 2,
centerY + (centerHeight - rightSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y),
));
});
chunkAttributes(groups.bottom, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => {
let x = centerX + (centerWidth - rowWidth(row)) / 2;
const y = centerY + centerHeight + bottomGap + rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y);
row.forEach((item) => {
attributes.push(localAttributeNode(table, item, x, y));
x += item.width + ATTRIBUTE_GAP_X;
});
});
groups.left.forEach((item, index) => {
attributes.push(localAttributeNode(
table,
item,
(leftSize.width - item.width) / 2,
centerY + (centerHeight - leftSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y),
));
});
return {
tableName: table.name,
width: leftSize.width + leftGap + centerWidth + rightGap + rightSize.width,
height: topSize.height + topGap + centerHeight + bottomGap + bottomSize.height,
entityX,
entityY,
attributes,
};
}
function positionKey(value: number): string {
return value.toFixed(3);
}
function orderedTableRows(tables: DiagramTable[], positions: Record<string, DiagramPosition>): DiagramTable[][] {
const columnsPerRow = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(tables.length, 1)))));
const ordered = tables.map((table, fallbackIndex) => ({
table,
position: positions[table.name] ?? {
x: fallbackIndex % columnsPerRow,
y: Math.floor(fallbackIndex / columnsPerRow),
},
}));
const ys = [...new Set(ordered.map((item) => positionKey(item.position.y)))]
.sort((left, right) => Number(left) - Number(right));
return ys.map((y) =>
ordered
.filter((item) => positionKey(item.position.y) === y)
.sort((left, right) => left.position.x - right.position.x)
.map((item) => item.table)
);
}
function normalizeDiagram(diagram: Omit<EngineeringDiagram, "canvas">): EngineeringDiagram {
const rects = [
...diagram.entities,
...diagram.attributes,
...diagram.relationships,
];
if (rects.length === 0) {
return {
...diagram,
canvas: {
width: CANVAS_PADDING * 2,
height: CANVAS_PADDING * 2,
},
};
}
const minX = Math.min(...rects.map((rect) => rect.x));
const minY = Math.min(...rects.map((rect) => rect.y));
const maxX = Math.max(...rects.map((rect) => rect.x + rect.width));
const maxY = Math.max(...rects.map((rect) => rect.y + rect.height));
const dx = CANVAS_PADDING - minX;
const dy = CANVAS_PADDING - minY;
const shift = <T extends { x: number; y: number }>(node: T): T => ({
...node,
x: node.x + dx,
y: node.y + dy,
});
return {
entities: diagram.entities.map(shift),
attributes: diagram.attributes.map(shift),
relationships: diagram.relationships.map(shift),
canvas: {
width: maxX + dx + CANVAS_PADDING,
height: maxY + dy + CANVAS_PADDING,
},
};
}
export function buildEngineeringDiagram(
tables: DiagramTable[],
relationships: DiagramRelationship[],
positions: Record<string, DiagramPosition>,
): EngineeringDiagram {
const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)]));
const rows = orderedTableRows(tables, positions);
const entities: EngineeringEntityNode[] = [];
const attributes: EngineeringAttributeNode[] = [];
let nextRowY = 0;
rows.forEach((row) => {
const rowClusters = row
.map((table) => clusters.get(table.name))
.filter((cluster): cluster is EngineeringCluster => cluster !== undefined);
const rowHeight = Math.max(...rowClusters.map((cluster) => cluster.height), ENGINEERING_ENTITY_HEIGHT);
let nextX = 0;
rowClusters.forEach((cluster) => {
const originX = nextX;
const originY = nextRowY + (rowHeight - cluster.height) / 2;
entities.push({
id: cluster.tableName,
name: cluster.tableName,
x: originX + cluster.entityX,
y: originY + cluster.entityY,
width: ENGINEERING_ENTITY_WIDTH,
height: ENGINEERING_ENTITY_HEIGHT,
});
attributes.push(...cluster.attributes.map((attribute) => ({
...attribute,
x: originX + attribute.x,
y: originY + attribute.y,
})));
nextX += cluster.width + ENGINEERING_CLUSTER_GAP_X;
});
nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y;
});
const entityMap = new Map(entities.map((entity) => [entity.name, entity]));
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 sourceCenter = entityCenter(source);
const targetCenter = entityCenter(target);
return [{
id: relationship.id,
label: relationshipLabel(relationship),
sourceTable: relationship.sourceTable,
targetTable: relationship.targetTable,
sourceCardinality: "N",
targetCardinality: "1",
x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2,
y: (sourceCenter.y + targetCenter.y) / 2 - ENGINEERING_RELATIONSHIP_HEIGHT / 2,
width: ENGINEERING_RELATIONSHIP_WIDTH,
height: ENGINEERING_RELATIONSHIP_HEIGHT,
}];
});
return normalizeDiagram({
entities: orderedEntities,
attributes,
relationships: relationshipNodes,
});
}

100
src/lib/erDiagram.ts Normal file
View File

@ -0,0 +1,100 @@
import type { ColumnInfo, ForeignKeyInfo } from "../types/database";
export interface DiagramTable {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
}
export interface DiagramRelationship {
id: string;
name: string;
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
}
export interface DiagramPosition {
x: number;
y: number;
}
export interface DiagramLayoutOptions {
columnsPerRow?: number;
cardWidth?: number;
rowHeight?: number;
gapX?: number;
gapY?: number;
margin?: number;
}
function relationshipId(sourceTable: string, fk: ForeignKeyInfo): string {
return [
sourceTable,
fk.name || "foreign_key",
fk.column,
fk.ref_table,
fk.ref_column,
].join(":");
}
export function buildDiagramRelationships(tables: DiagramTable[]): DiagramRelationship[] {
const visibleTableNames = new Set(tables.map((table) => table.name));
return tables.flatMap((table) =>
table.foreignKeys
.filter((fk) => visibleTableNames.has(fk.ref_table))
.map((fk) => ({
id: relationshipId(table.name, fk),
name: fk.name,
sourceTable: table.name,
sourceColumn: fk.column,
targetTable: fk.ref_table,
targetColumn: fk.ref_column,
})),
);
}
export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] {
const q = query.trim().toLowerCase();
if (!q) return tables;
return tables.filter((table) => {
if (table.name.toLowerCase().includes(q)) return true;
if (table.columns.some((column) =>
column.name.toLowerCase().includes(q) ||
column.data_type.toLowerCase().includes(q)
)) return true;
return table.foreignKeys.some((fk) =>
fk.name.toLowerCase().includes(q) ||
fk.column.toLowerCase().includes(q) ||
fk.ref_table.toLowerCase().includes(q) ||
fk.ref_column.toLowerCase().includes(q)
);
});
}
export function layoutDiagramTables(
tables: Pick<DiagramTable, "name" | "columns">[],
options: DiagramLayoutOptions = {},
): Record<string, DiagramPosition> {
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;
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),
},
];
}));
}

View File

@ -146,7 +146,7 @@ function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompl
label: table.name,
type: "table" as const,
detail: table.schema ? `${table.schema}.${table.name}` : table.type,
boost: computeBoost(table.name, prefix),
boost: computeBoost(table.name, prefix) + 1000,
}));
}

View File

@ -19,6 +19,7 @@ export const useConnectionStore = defineStore("connection", () => {
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null);
function startEditing(id: string) {
editingConnectionId.value = id;
@ -655,5 +656,6 @@ export const useConnectionStore = defineStore("connection", () => {
transferSource,
schemaDiffSource,
sqlFileSource,
diagramSource,
};
});

View File

@ -0,0 +1,87 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildEngineeringDiagram } from "../src/lib/engineeringDiagram.ts";
import {
buildEngineeringDiagramSvg,
buildTableDiagramSvg,
diagramSvgFileName,
} from "../src/lib/diagramSvgExport.ts";
import { buildDiagramRelationships, type DiagramTable } from "../src/lib/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: "name & note", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, 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" },
],
},
];
test("exports the table diagram as standalone SVG", () => {
const relationships = buildDiagramRelationships(tables);
const svg = buildTableDiagramSvg({
tables,
relationships,
positions: {
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
},
relationshipPaths: {
[relationships[0].id]: "M 360 96 L 310 96",
},
canvas: { width: 720, height: 320 },
cardWidth: 270,
cardHeaderHeight: 44,
columnRowHeight: 24,
maxVisibleColumns: 9,
moreColumnsLabel: (count) => `+ ${count} columns`,
});
assert.match(svg, /^<svg /);
assert.match(svg, /<path d="M 360 96 L 310 96"/);
assert.match(svg, />users</);
assert.match(svg, />orders</);
assert.match(svg, />name &amp; note</);
assert.doesNotMatch(svg, /<foreignObject/);
});
test("exports the engineering ER diagram with Chen-style shapes and cardinalities", () => {
const relationships = buildDiagramRelationships(tables);
const diagram = buildEngineeringDiagram(tables, relationships, {
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
});
const svg = buildEngineeringDiagramSvg(diagram);
assert.match(svg, /^<svg /);
assert.match(svg, /<ellipse /);
assert.match(svg, /<polygon /);
assert.match(svg, /<rect /);
assert.match(svg, />N</);
assert.match(svg, />1</);
assert.match(svg, /text-decoration="underline"/);
assert.doesNotMatch(svg, /<foreignObject/);
});
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",
);
});

23
tests/diagramZoom.test.ts Normal file
View File

@ -0,0 +1,23 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
clampDiagramZoom,
zoomFromGestureScale,
zoomFromWheelDelta,
} from "../src/lib/diagramZoom.ts";
test("clamps diagram zoom to supported bounds", () => {
assert.equal(clampDiagramZoom(0.2), 0.6);
assert.equal(clampDiagramZoom(2), 1.5);
assert.equal(clampDiagramZoom(1.234), 1.23);
});
test("maps trackpad pinch wheel delta to smooth zoom changes", () => {
assert.ok(zoomFromWheelDelta(1, -120) > 1);
assert.ok(zoomFromWheelDelta(1, 120) < 1);
});
test("maps WebKit gesture scale from the gesture start zoom", () => {
assert.equal(zoomFromGestureScale(1, 1.25), 1.25);
assert.equal(zoomFromGestureScale(1, 4), 1.5);
});

View File

@ -0,0 +1,107 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildEngineeringDiagram } from "../src/lib/engineeringDiagram.ts";
import type { DiagramRelationship, DiagramTable } from "../src/lib/erDiagram.ts";
const tables: DiagramTable[] = [
{
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 },
{ name: "status", data_type: "varchar", 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" },
],
},
{
name: "users",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
},
];
const relationships: DiagramRelationship[] = [
{
id: "orders:orders_user_id_fk:user_id:users:id",
name: "orders_user_id_fk",
sourceTable: "orders",
sourceColumn: "user_id",
targetTable: "users",
targetColumn: "id",
},
];
test("builds engineering ER nodes from tables, columns, and relationships", () => {
const diagram = buildEngineeringDiagram(tables, relationships, {
orders: { x: 300, y: 200 },
users: { x: 40, y: 200 },
});
assert.deepEqual(diagram.entities.map((entity) => entity.name), ["orders", "users"]);
assert.equal(diagram.attributes.filter((attr) => attr.tableName === "orders").length, 3);
assert.equal(diagram.relationships[0]?.sourceCardinality, "N");
assert.equal(diagram.relationships[0]?.targetCardinality, "1");
});
test("sizes the engineering canvas around attributes and relationship diamonds", () => {
const diagram = buildEngineeringDiagram(tables, relationships, {
orders: { x: 300, y: 200 },
users: { x: 40, y: 200 },
});
assert.ok(diagram.canvas.width > 500);
assert.ok(diagram.canvas.height > 300);
});
test("keeps dense attribute clouds from overlapping", () => {
const denseTables: DiagramTable[] = [{
name: "roles",
columns: Array.from({ length: 36 }, (_, index) => ({
name: `column_${index + 1}`,
data_type: "varchar",
is_nullable: true,
column_default: null,
is_primary_key: index === 0,
extra: null,
})),
foreignKeys: [],
}];
const diagram = buildEngineeringDiagram(denseTables, [], {
roles: { x: 40, y: 40 },
});
const rects = [
...diagram.entities,
...diagram.attributes,
];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const left = rects[i];
const right = rects[j];
const overlaps = left.x < right.x + right.width &&
left.x + left.width > right.x &&
left.y < right.y + right.height &&
left.y + left.height > right.y;
assert.equal(overlaps, false, `${left.id} overlaps ${right.id}`);
}
}
});
test("keeps adjacent entity centers reasonably close", () => {
const diagram = buildEngineeringDiagram(tables, relationships, {
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
});
const users = diagram.entities.find((entity) => entity.name === "users")!;
const orders = diagram.entities.find((entity) => entity.name === "orders")!;
const userCenter = users.x + users.width / 2;
const orderCenter = orders.x + orders.width / 2;
assert.ok(Math.abs(orderCenter - userCenter) <= 560);
});

72
tests/erDiagram.test.ts Normal file
View File

@ -0,0 +1,72 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
buildDiagramRelationships,
filterDiagramTables,
layoutDiagramTables,
} from "../src/lib/erDiagram.ts";
test("builds relationships only between tables in the diagram", () => {
const relationships = buildDiagramRelationships([
{
name: "orders",
columns: [],
foreignKeys: [
{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" },
{ name: "orders_external_fk", column: "external_id", ref_table: "external_accounts", ref_column: "id" },
],
},
{
name: "users",
columns: [],
foreignKeys: [],
},
]);
assert.deepEqual(relationships, [
{
id: "orders:orders_user_id_fk:user_id:users:id",
name: "orders_user_id_fk",
sourceTable: "orders",
sourceColumn: "user_id",
targetTable: "users",
targetColumn: "id",
},
]);
});
test("filters diagram tables by table, column, and foreign key names", () => {
const tables = [
{
name: "orders",
columns: [{ name: "user_id", data_type: "int", 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" }],
},
{
name: "audit_log",
columns: [{ name: "payload", data_type: "json", is_nullable: true, column_default: null, is_primary_key: false, extra: null }],
foreignKeys: [],
},
];
assert.deepEqual(filterDiagramTables(tables, "payload").map((table) => table.name), ["audit_log"]);
assert.deepEqual(filterDiagramTables(tables, "orders_user").map((table) => table.name), ["orders"]);
assert.deepEqual(filterDiagramTables(tables, "").map((table) => table.name), ["orders", "audit_log"]);
});
test("lays out diagram tables in stable rows", () => {
const positions = layoutDiagramTables(
[
{ name: "users", columns: [] },
{ name: "orders", columns: [] },
{ name: "line_items", columns: [] },
],
{ columnsPerRow: 2, cardWidth: 240, rowHeight: 180, gapX: 40, gapY: 30 },
);
assert.deepEqual(positions, {
users: { x: 40, y: 40 },
orders: { x: 320, y: 40 },
line_items: { x: 40, y: 250 },
});
});