mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 16:24:33 +00:00
feat: add field lineage viewer
This commit is contained in:
+27
@@ -41,6 +41,7 @@ const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diag
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -128,6 +129,7 @@ const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showStructureEditorDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const transferPrefillConnectionId = ref("");
|
||||
const transferPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
@@ -146,6 +148,11 @@ const structurePrefillConnectionId = ref("");
|
||||
const structurePrefillDatabase = ref("");
|
||||
const structurePrefillSchema = ref("");
|
||||
const structurePrefillTable = ref("");
|
||||
const lineagePrefillConnectionId = ref("");
|
||||
const lineagePrefillDatabase = ref("");
|
||||
const lineagePrefillSchema = ref("");
|
||||
const lineagePrefillTable = ref("");
|
||||
const lineagePrefillColumn = ref("");
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
@@ -233,6 +240,18 @@ watch(() => connectionStore.structureEditorSource, (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => connectionStore.fieldLineageSource, (v) => {
|
||||
if (v) {
|
||||
lineagePrefillConnectionId.value = v.connectionId;
|
||||
lineagePrefillDatabase.value = v.database;
|
||||
lineagePrefillSchema.value = v.schema ?? "";
|
||||
lineagePrefillTable.value = v.tableName;
|
||||
lineagePrefillColumn.value = v.columnName;
|
||||
showFieldLineageDialog.value = true;
|
||||
connectionStore.fieldLineageSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function onStructureEditorSaved() {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "data" && tab.tableMeta?.tableName === structurePrefillTable.value) {
|
||||
@@ -1411,6 +1430,14 @@ async function setupFileDrop() {
|
||||
:prefill-table="structurePrefillTable"
|
||||
@saved="onStructureEditorSaved"
|
||||
/>
|
||||
<FieldLineageDialog
|
||||
v-model:open="showFieldLineageDialog"
|
||||
:prefill-connection-id="lineagePrefillConnectionId"
|
||||
:prefill-database="lineagePrefillDatabase"
|
||||
:prefill-schema="lineagePrefillSchema"
|
||||
:prefill-table="lineagePrefillTable"
|
||||
:prefill-column="lineagePrefillColumn"
|
||||
/>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Columns3, Eye, History, Link, Loader2, RefreshCw, SearchX, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import {
|
||||
analyzeFieldLineage,
|
||||
summarizeLineageCounts,
|
||||
type FieldLineageItem,
|
||||
type FieldLineageResult,
|
||||
type FieldLineageTable,
|
||||
type FieldLineageView,
|
||||
} from "@/lib/fieldLineage";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
prefillConnectionId: string;
|
||||
prefillDatabase: string;
|
||||
prefillSchema?: string;
|
||||
prefillTable: string;
|
||||
prefillColumn: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit("update:open", value),
|
||||
});
|
||||
|
||||
const MAX_TABLES = 180;
|
||||
const MAX_VIEW_DDLS = 60;
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
const loading = ref(false);
|
||||
const cancelled = ref(false);
|
||||
const error = ref("");
|
||||
const progressDone = ref(0);
|
||||
const progressTotal = ref(0);
|
||||
const result = ref<FieldLineageResult | null>(null);
|
||||
let runId = 0;
|
||||
|
||||
const targetLabel = computed(() => {
|
||||
const scope = props.prefillSchema ? `${props.prefillSchema}.${props.prefillTable}` : props.prefillTable;
|
||||
return `${scope}.${props.prefillColumn}`;
|
||||
});
|
||||
|
||||
const counts = computed(() => summarizeLineageCounts(result.value?.items ?? []));
|
||||
|
||||
const groupedItems = computed(() => ({
|
||||
certain: (result.value?.items ?? []).filter((item) => item.confidence === "certain"),
|
||||
likely: (result.value?.items ?? []).filter((item) => item.confidence === "likely"),
|
||||
possible: (result.value?.items ?? []).filter((item) => item.confidence === "possible"),
|
||||
}));
|
||||
|
||||
watch(dialogOpen, (open) => {
|
||||
if (open) void loadLineage();
|
||||
else cancelLoad();
|
||||
});
|
||||
|
||||
function cancelLoad() {
|
||||
cancelled.value = true;
|
||||
runId++;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadLineage() {
|
||||
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable || !props.prefillColumn) return;
|
||||
const currentRun = ++runId;
|
||||
loading.value = true;
|
||||
cancelled.value = false;
|
||||
error.value = "";
|
||||
result.value = null;
|
||||
progressDone.value = 0;
|
||||
progressTotal.value = 0;
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.prefillConnectionId);
|
||||
if (isStale(currentRun)) return;
|
||||
|
||||
const schema = props.prefillSchema || props.prefillDatabase;
|
||||
const tableInfos = prioritizeTargetTable(
|
||||
await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema),
|
||||
props.prefillTable,
|
||||
).slice(0, MAX_TABLES);
|
||||
const viewInfos = tableInfos.filter((table) => table.table_type.toUpperCase().includes("VIEW")).slice(0, MAX_VIEW_DDLS);
|
||||
progressTotal.value = tableInfos.length + viewInfos.length + 1;
|
||||
|
||||
const tables: FieldLineageTable[] = [];
|
||||
for (let i = 0; i < tableInfos.length; i += BATCH_SIZE) {
|
||||
if (isStale(currentRun)) return;
|
||||
const batch = tableInfos.slice(i, i + BATCH_SIZE);
|
||||
const loaded = await Promise.all(batch.map(async (table) => {
|
||||
try {
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
const foreignKeys = await api.listForeignKeys(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
||||
return {
|
||||
schema,
|
||||
name: table.name,
|
||||
columns: columns.map((column) => column.name),
|
||||
foreignKeys,
|
||||
};
|
||||
} catch {
|
||||
return { schema, name: table.name, columns: [], foreignKeys: [] };
|
||||
}
|
||||
}));
|
||||
tables.push(...loaded);
|
||||
progressDone.value += batch.length;
|
||||
}
|
||||
|
||||
const views: FieldLineageView[] = [];
|
||||
for (const view of viewInfos) {
|
||||
if (isStale(currentRun)) return;
|
||||
try {
|
||||
const ddl = await api.getTableDdl(props.prefillConnectionId, props.prefillDatabase, schema, view.name);
|
||||
views.push({ schema, name: view.name, ddl });
|
||||
} catch {
|
||||
// Some drivers may not expose view DDL consistently; keep the rest of the lineage usable.
|
||||
} finally {
|
||||
progressDone.value++;
|
||||
}
|
||||
}
|
||||
|
||||
const histories = (await api.loadHistory(200, 0))
|
||||
.filter((entry) => !entry.database || entry.database === props.prefillDatabase)
|
||||
.map((entry) => ({ id: entry.id, sql: entry.sql, executed_at: entry.executed_at }));
|
||||
progressDone.value++;
|
||||
if (isStale(currentRun)) return;
|
||||
|
||||
result.value = analyzeFieldLineage({
|
||||
target: {
|
||||
schema,
|
||||
table: props.prefillTable,
|
||||
column: props.prefillColumn,
|
||||
},
|
||||
tables,
|
||||
views,
|
||||
histories,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isStale(currentRun)) error.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (!isStale(currentRun)) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isStale(id: number) {
|
||||
return cancelled.value || id !== runId;
|
||||
}
|
||||
|
||||
function prioritizeTargetTable<T extends { name: string }>(tables: T[], targetTable: string): T[] {
|
||||
return [...tables].sort((a, b) => Number(a.name !== targetTable) - Number(b.name !== targetTable));
|
||||
}
|
||||
|
||||
function itemIcon(item: FieldLineageItem) {
|
||||
if (item.kind === "foreignKey") return Link;
|
||||
if (item.kind === "viewReference") return Eye;
|
||||
if (item.kind === "historyReference") return History;
|
||||
return Columns3;
|
||||
}
|
||||
|
||||
function confidenceVariant(confidence: FieldLineageItem["confidence"]) {
|
||||
return confidence === "certain" ? "default" : "secondary";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent class="sm:max-w-[900px] max-h-[86vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Link class="h-4 w-4" />
|
||||
{{ t('lineage.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-medium">{{ targetLabel }}</span>
|
||||
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
||||
<Badge v-if="props.prefillSchema" variant="outline">{{ props.prefillSchema }}</Badge>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded border bg-muted/20 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('lineage.loading', { done: progressDone, total: progressTotal || '-' }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="rounded border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="result && result.items.length === 0" class="flex flex-col items-center justify-center gap-2 rounded border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="result">
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<Badge>{{ t('lineage.certain') }} {{ counts.certain }}</Badge>
|
||||
<Badge variant="secondary">{{ t('lineage.likely') }} {{ counts.likely }}</Badge>
|
||||
<Badge variant="outline">{{ t('lineage.possible') }} {{ counts.possible }}</Badge>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-for="group in [
|
||||
{ key: 'certain', label: t('lineage.certain'), items: groupedItems.certain },
|
||||
{ key: 'likely', label: t('lineage.likely'), items: groupedItems.likely },
|
||||
{ key: 'possible', label: t('lineage.possible'), items: groupedItems.possible },
|
||||
]"
|
||||
:key="group.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h3 v-if="group.items.length" class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.label }}</h3>
|
||||
<div v-for="item in group.items" :key="item.id" class="rounded border p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<component :is="itemIcon(item)" class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ item.title }}</span>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{ t(`lineage.${item.confidence}`) }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ item.description }}</p>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-24 overflow-auto rounded bg-muted/40 p-2 text-xs whitespace-pre-wrap">{{ item.sqlSnippet }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button v-if="loading" variant="outline" @click="cancelLoad">
|
||||
<X class="h-4 w-4" />
|
||||
{{ t('lineage.cancel') }}
|
||||
</Button>
|
||||
<Button v-else variant="outline" @click="loadLineage">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{{ t('lineage.refresh') }}
|
||||
</Button>
|
||||
<Button @click="dialogOpen = false">{{ t('common.close') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -45,6 +45,7 @@ const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]);
|
||||
const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]);
|
||||
const fieldLineageSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const isExportingDatabase = ref(false);
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
@@ -537,6 +538,19 @@ function openStructureEditor() {
|
||||
};
|
||||
}
|
||||
|
||||
function openFieldLineage() {
|
||||
const node = props.node;
|
||||
const column = node.type === "column" && node.meta && "name" in node.meta ? node.meta.name : node.label;
|
||||
if (node.type !== "column" || !node.connectionId || !node.database || !node.tableName || !column) return;
|
||||
connectionStore.fieldLineageSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.schema,
|
||||
tableName: node.tableName,
|
||||
columnName: column,
|
||||
};
|
||||
}
|
||||
|
||||
const canExpand = !leafTypes.has(props.node.type);
|
||||
const canPin = computed(() => pinnableTypes.has(props.node.type));
|
||||
const canOpenSqlFileExecution = computed(() => {
|
||||
@@ -555,10 +569,14 @@ const canOpenStructureEditor = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableStructureSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const canOpenFieldLineage = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "column" && !!props.node.database && !!props.node.tableName && !!config && fieldLineageSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
|
||||
const hasTypeMenu = computed(() => {
|
||||
const t = props.node.type;
|
||||
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || isGroupLabel(props.node);
|
||||
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || isGroupLabel(props.node);
|
||||
});
|
||||
const columnComment = computed(() => props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null);
|
||||
const paddingLeft = `${props.depth * 16 + 8}px`;
|
||||
@@ -743,6 +761,12 @@ async function showMore() {
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'column'">
|
||||
<ContextMenuItem v-if="canOpenFieldLineage" @click="openFieldLineage">
|
||||
<Network class="w-4 h-4" /> {{ t('lineage.open') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="isGroupLabel(node)">
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
|
||||
@@ -184,6 +184,17 @@ export default {
|
||||
emptySql: "No SQL to explain",
|
||||
unsafe: "This first version only explains SELECT / WITH / TABLE / VALUES statements",
|
||||
},
|
||||
lineage: {
|
||||
title: "Field Lineage",
|
||||
open: "View Field Lineage",
|
||||
loading: "Reading metadata {done}/{total}",
|
||||
empty: "No related lineage found",
|
||||
certain: "Certain",
|
||||
likely: "Likely",
|
||||
possible: "Possible",
|
||||
cancel: "Cancel",
|
||||
refresh: "Analyze Again",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "Describe your query in natural language...",
|
||||
settings: "AI Settings",
|
||||
|
||||
@@ -184,6 +184,17 @@ export default {
|
||||
emptySql: "当前没有可分析的 SQL",
|
||||
unsafe: "第一版执行计划仅支持 SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
lineage: {
|
||||
title: "字段血缘",
|
||||
open: "查看字段血缘",
|
||||
loading: "正在读取元数据 {done}/{total}",
|
||||
empty: "没有找到相关血缘",
|
||||
certain: "确定",
|
||||
likely: "高置信",
|
||||
possible: "可能相关",
|
||||
cancel: "取消读取",
|
||||
refresh: "重新分析",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "描述你想查什么...",
|
||||
settings: "AI 设置",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { ForeignKeyInfo } from "@/types/database";
|
||||
|
||||
export type FieldLineageConfidence = "certain" | "likely" | "possible";
|
||||
export type FieldLineageKind = "foreignKey" | "viewReference" | "historyReference" | "sameName";
|
||||
export type FieldLineageDirection = "incoming" | "outgoing" | "reference";
|
||||
|
||||
export interface FieldLineageTarget {
|
||||
schema?: string;
|
||||
table: string;
|
||||
column: string;
|
||||
}
|
||||
|
||||
export interface FieldLineageTable {
|
||||
schema?: string;
|
||||
name: string;
|
||||
columns: string[];
|
||||
foreignKeys?: ForeignKeyInfo[];
|
||||
}
|
||||
|
||||
export interface FieldLineageView {
|
||||
schema?: string;
|
||||
name: string;
|
||||
ddl: string;
|
||||
}
|
||||
|
||||
export interface FieldLineageHistory {
|
||||
id: string;
|
||||
sql: string;
|
||||
executed_at?: string;
|
||||
}
|
||||
|
||||
export interface FieldLineageItem {
|
||||
id: string;
|
||||
kind: FieldLineageKind;
|
||||
confidence: FieldLineageConfidence;
|
||||
direction: FieldLineageDirection;
|
||||
title: string;
|
||||
description: string;
|
||||
schema?: string;
|
||||
table?: string;
|
||||
column?: string;
|
||||
sqlSnippet?: string;
|
||||
}
|
||||
|
||||
export interface FieldLineageResult {
|
||||
target: FieldLineageTarget;
|
||||
items: FieldLineageItem[];
|
||||
}
|
||||
|
||||
export function analyzeFieldLineage(options: {
|
||||
target: FieldLineageTarget;
|
||||
tables?: FieldLineageTable[];
|
||||
views?: FieldLineageView[];
|
||||
histories?: FieldLineageHistory[];
|
||||
}): FieldLineageResult {
|
||||
const target = normalizeTarget(options.target);
|
||||
const items: FieldLineageItem[] = [
|
||||
...foreignKeyLineage(target, options.tables ?? []),
|
||||
...viewLineage(target, options.views ?? []),
|
||||
...historyLineage(target, options.histories ?? []),
|
||||
...sameNameLineage(target, options.tables ?? []),
|
||||
];
|
||||
|
||||
return { target: options.target, items };
|
||||
}
|
||||
|
||||
export function summarizeLineageCounts(items: FieldLineageItem[]) {
|
||||
return {
|
||||
certain: items.filter((item) => item.confidence === "certain").length,
|
||||
likely: items.filter((item) => item.confidence === "likely").length,
|
||||
possible: items.filter((item) => item.confidence === "possible").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function identifierInSql(sql: string, identifier: string): boolean {
|
||||
const escaped = escapeRegExp(identifier);
|
||||
const quoted = [
|
||||
`"${escapeRegExp(identifier)}"`,
|
||||
`\`${escapeRegExp(identifier)}\``,
|
||||
`\\[${escapeRegExp(identifier)}\\]`,
|
||||
];
|
||||
const pattern = `(?:${quoted.join("|")}|(?<![\\w$])${escaped}(?![\\w$]))`;
|
||||
return new RegExp(pattern, "i").test(sql);
|
||||
}
|
||||
|
||||
function foreignKeyLineage(target: Required<FieldLineageTarget>, tables: FieldLineageTable[]): FieldLineageItem[] {
|
||||
const items: FieldLineageItem[] = [];
|
||||
for (const table of tables) {
|
||||
for (const fk of table.foreignKeys ?? []) {
|
||||
const tableMatches = sameIdentifier(table.name, target.table) && schemaMatches(table.schema, target.schema);
|
||||
if (tableMatches && sameIdentifier(fk.column, target.column)) {
|
||||
items.push({
|
||||
id: `fk-out:${table.schema ?? ""}:${table.name}:${fk.name}:${fk.column}`,
|
||||
kind: "foreignKey",
|
||||
confidence: "certain",
|
||||
direction: "outgoing",
|
||||
title: `${table.name}.${fk.column} -> ${fk.ref_table}.${fk.ref_column}`,
|
||||
description: `Foreign key ${fk.name} references ${fk.ref_table}.${fk.ref_column}.`,
|
||||
schema: table.schema,
|
||||
table: fk.ref_table,
|
||||
column: fk.ref_column,
|
||||
});
|
||||
}
|
||||
|
||||
if (sameIdentifier(fk.ref_table, target.table) && sameIdentifier(fk.ref_column, target.column)) {
|
||||
items.push({
|
||||
id: `fk-in:${table.schema ?? ""}:${table.name}:${fk.name}:${fk.column}`,
|
||||
kind: "foreignKey",
|
||||
confidence: "certain",
|
||||
direction: "incoming",
|
||||
title: `${table.name}.${fk.column} -> ${target.table}.${target.column}`,
|
||||
description: `Foreign key ${fk.name} points to this field.`,
|
||||
schema: table.schema,
|
||||
table: table.name,
|
||||
column: fk.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function viewLineage(target: Required<FieldLineageTarget>, views: FieldLineageView[]): FieldLineageItem[] {
|
||||
return views
|
||||
.filter((view) => identifierInSql(view.ddl, target.column))
|
||||
.map((view) => {
|
||||
const confidence: FieldLineageConfidence = identifierInSql(view.ddl, target.table) ? "likely" : "possible";
|
||||
return {
|
||||
id: `view:${view.schema ?? ""}:${view.name}`,
|
||||
kind: "viewReference" as const,
|
||||
confidence,
|
||||
direction: "reference" as const,
|
||||
title: `${view.name} references ${target.column}`,
|
||||
description: confidence === "likely"
|
||||
? `View definition mentions both ${target.table} and ${target.column}.`
|
||||
: `View definition mentions ${target.column}.`,
|
||||
schema: view.schema,
|
||||
table: view.name,
|
||||
sqlSnippet: snippetAround(view.ddl, target.column),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function historyLineage(target: Required<FieldLineageTarget>, histories: FieldLineageHistory[]): FieldLineageItem[] {
|
||||
return histories
|
||||
.filter((entry) => identifierInSql(entry.sql, target.column))
|
||||
.slice(0, 20)
|
||||
.map((entry) => {
|
||||
const confidence: FieldLineageConfidence = identifierInSql(entry.sql, target.table) ? "likely" : "possible";
|
||||
return {
|
||||
id: `history:${entry.id}`,
|
||||
kind: "historyReference" as const,
|
||||
confidence,
|
||||
direction: "reference" as const,
|
||||
title: confidence === "likely" ? "Query history references table and field" : "Query history references field",
|
||||
description: entry.executed_at ? `Executed at ${entry.executed_at}.` : "Found in query history.",
|
||||
sqlSnippet: snippetAround(entry.sql, target.column),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sameNameLineage(target: Required<FieldLineageTarget>, tables: FieldLineageTable[]): FieldLineageItem[] {
|
||||
const items: FieldLineageItem[] = [];
|
||||
for (const table of tables) {
|
||||
if (sameIdentifier(table.name, target.table) && schemaMatches(table.schema, target.schema)) continue;
|
||||
for (const column of table.columns) {
|
||||
if (!sameIdentifier(column, target.column)) continue;
|
||||
items.push({
|
||||
id: `same:${table.schema ?? ""}:${table.name}:${column}`,
|
||||
kind: "sameName",
|
||||
confidence: "possible",
|
||||
direction: "reference",
|
||||
title: `${table.name}.${column}`,
|
||||
description: "Another field has the same name. This is a possible semantic relationship, not a verified dependency.",
|
||||
schema: table.schema,
|
||||
table: table.name,
|
||||
column,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.slice(0, 40);
|
||||
}
|
||||
|
||||
function normalizeTarget(target: FieldLineageTarget): Required<FieldLineageTarget> {
|
||||
return {
|
||||
schema: target.schema ?? "",
|
||||
table: target.table,
|
||||
column: target.column,
|
||||
};
|
||||
}
|
||||
|
||||
function sameIdentifier(left: string | undefined, right: string | undefined): boolean {
|
||||
return normalizeIdentifier(left) === normalizeIdentifier(right);
|
||||
}
|
||||
|
||||
function schemaMatches(left: string | undefined, right: string | undefined): boolean {
|
||||
return normalizeIdentifier(left) === normalizeIdentifier(right);
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: string | undefined): string {
|
||||
return (value ?? "").replace(/^[`"\[]|[`"\]]$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function snippetAround(sql: string, needle: string): string {
|
||||
const index = sql.toLowerCase().indexOf(needle.toLowerCase());
|
||||
if (index < 0) return sql.slice(0, 180);
|
||||
const start = Math.max(0, index - 80);
|
||||
const end = Math.min(sql.length, index + needle.length + 80);
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < sql.length ? "..." : "";
|
||||
return `${prefix}${sql.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null);
|
||||
const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const structureEditorSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const fieldLineageSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string; columnName: string } | null>(null);
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
@@ -416,6 +417,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
tableName: table,
|
||||
meta: col,
|
||||
})));
|
||||
node.isExpanded = true;
|
||||
@@ -661,5 +663,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
diagramSource,
|
||||
tableImportSource,
|
||||
structureEditorSource,
|
||||
fieldLineageSource,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user