Merge pull request #52 from SuLea-IT/codex/er-diagram
[codex] add database relationship diagrams
This commit is contained in:
commit
2ad2e138b5
|
|
@ -1548,7 +1548,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
src/App.vue
24
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & 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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue