Merge pull request #65 from SuLea-IT/codex/field-lineage
[codex] Add field lineage viewer and review fixes
This commit is contained in:
commit
1b8d56a43b
|
|
@ -6,6 +6,7 @@ use tokio::time::timeout;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::commands::connection::{AppState, PoolKind};
|
||||
use crate::commands::sql_file::split_sql_statements;
|
||||
use crate::db;
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
|
@ -269,9 +270,8 @@ pub async fn cancel_query(
|
|||
Ok(state.running_queries.cancel(&execution_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_batch(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
async fn execute_statements(
|
||||
state: &Arc<AppState>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
statements: Vec<String>,
|
||||
|
|
@ -312,6 +312,26 @@ pub async fn execute_batch(
|
|||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_batch(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
statements: Vec<String>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
execute_statements(&state, connection_id, database, statements).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_script(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
sql: String,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
execute_statements(&state, connection_id, database, split_sql_statements(&sql)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -225,6 +225,13 @@ impl SqlStatementSplitter {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
let mut splitter = SqlStatementSplitter::default();
|
||||
let mut statements = splitter.push_chunk(sql);
|
||||
statements.extend(splitter.finish());
|
||||
statements
|
||||
}
|
||||
|
||||
fn starts_with_chars(chars: &[char], start: usize, needle: &[char]) -> bool {
|
||||
start + needle.len() <= chars.len() && chars[start..start + needle.len()] == *needle
|
||||
}
|
||||
|
|
|
|||
|
|
@ -707,6 +707,8 @@ pub async fn start_transfer(
|
|||
status: TransferStatus::Error,
|
||||
error: Some(e),
|
||||
});
|
||||
CANCELLED.write().await.remove(&transfer_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ pub fn run() {
|
|||
commands::query::execute_query,
|
||||
commands::query::cancel_query,
|
||||
commands::query::execute_batch,
|
||||
commands::query::execute_script,
|
||||
commands::sql_file::preview_sql_file,
|
||||
commands::sql_file::execute_sql_file,
|
||||
commands::sql_file::cancel_sql_file_execution,
|
||||
|
|
|
|||
89
src/App.vue
89
src/App.vue
|
|
@ -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";
|
||||
|
|
@ -55,6 +56,7 @@ import * as api from "@/lib/tauri";
|
|||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts";
|
||||
|
||||
|
|
@ -128,6 +130,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 +149,18 @@ 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("");
|
||||
type LineageNavigationTarget = {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
};
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
|
|
@ -233,6 +248,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) {
|
||||
|
|
@ -255,6 +282,42 @@ async function onStructureEditorSaved() {
|
|||
}
|
||||
}
|
||||
|
||||
async function openLineageTarget(target: LineageNavigationTarget) {
|
||||
showFieldLineageDialog.value = false;
|
||||
connectionStore.activeConnectionId = target.connectionId;
|
||||
const config = connectionStore.getConfig(target.connectionId);
|
||||
const tabTitle = target.schema ? `${target.schema}.${target.tableName}` : target.tableName;
|
||||
const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data");
|
||||
queryStore.setExecuting(tabId, true);
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const querySchema = target.schema || target.database;
|
||||
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
|
||||
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
primaryKeys,
|
||||
});
|
||||
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
} catch (e: any) {
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
}
|
||||
}
|
||||
|
||||
function onConnectionConnectStarted(name: string) {
|
||||
toast(t("connection.connecting", { name }), 30000);
|
||||
}
|
||||
|
|
@ -593,11 +656,15 @@ function buildTableSql(
|
|||
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
|
||||
): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const fallbackOrderColumns = config?.db_type === "sqlserver" && !tab.tableMeta?.primaryKeys?.length
|
||||
? tab.tableMeta?.columns.slice(0, 1).map((column) => column.name)
|
||||
: undefined;
|
||||
return buildTableSelectSql({
|
||||
databaseType: config?.db_type,
|
||||
schema: tab.tableMeta?.schema,
|
||||
tableName: tab.tableMeta?.tableName ?? "",
|
||||
primaryKeys: tab.tableMeta?.primaryKeys,
|
||||
fallbackOrderColumns,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
@ -628,7 +695,10 @@ const isDark = ref(localStorage.getItem("dbx-theme") === "dark");
|
|||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle("dark", isDark.value);
|
||||
getCurrentWindow().setTheme(isDark.value ? "dark" as Theme : "light" as Theme);
|
||||
if (!isTauriRuntime()) return;
|
||||
getCurrentWindow()
|
||||
.setTheme(isDark.value ? "dark" as Theme : "light" as Theme)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
|
|
@ -710,9 +780,11 @@ onMounted(() => {
|
|||
});
|
||||
settingsStore.initAiConfig();
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
setupFileDrop();
|
||||
checkUpdates({ silent: true });
|
||||
getVersion().then((v) => { appVersion.value = v; });
|
||||
if (isTauriRuntime()) {
|
||||
setupFileDrop().catch(() => {});
|
||||
checkUpdates({ silent: true });
|
||||
getVersion().then((v) => { appVersion.value = v; }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -1411,6 +1483,15 @@ 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"
|
||||
@open-target="openLineageTarget"
|
||||
/>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
|
@ -161,14 +162,7 @@ async function executeSql() {
|
|||
executing.value = true;
|
||||
try {
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncSql.value
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !s.startsWith("--"));
|
||||
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(targetConnectionId.value, targetDatabase.value, sql);
|
||||
}
|
||||
await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value);
|
||||
toast(t("diff.syncSuccess"), 2000);
|
||||
open.value = false;
|
||||
} catch (e: any) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useHistoryStore();
|
||||
|
|
@ -34,6 +35,18 @@ function copySql(sql: string) {
|
|||
navigator.clipboard.writeText(sql);
|
||||
}
|
||||
|
||||
function confirmDeleteEntry(id: string) {
|
||||
if (shouldDeleteHistoryEntry(() => window.confirm(t("history.confirmDelete")))) {
|
||||
store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
if (shouldClearHistory(store.entries.length, () => window.confirm(t("history.confirmClear")))) {
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
|
@ -54,7 +67,7 @@ onMounted(() => store.load());
|
|||
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span class="text-xs font-medium">{{ t('history.title') }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="store.clear()">
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
|
||||
|
|
@ -94,7 +107,7 @@ onMounted(() => store.load());
|
|||
<ContextMenuContent class="w-40">
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="store.remove(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import {
|
|||
type CellPosition,
|
||||
type CellSelectionRange,
|
||||
} from "@/lib/gridSelection";
|
||||
import { buildTableSelectSql, normalizeWhereInput } from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql, normalizeWhereInput, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { formatMarkdownTable } from "@/lib/markdownTable";
|
||||
import {
|
||||
matchesRowStatusFilter,
|
||||
rowStatusFilterAfterAddingRow,
|
||||
|
|
@ -537,6 +539,14 @@ function formatCell(value: CellValue): string {
|
|||
return String(value);
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
return quoteTableIdentifier(props.databaseType, name);
|
||||
}
|
||||
|
||||
function escapeVal(value: CellValue): string {
|
||||
return formatGridSqlLiteral(value);
|
||||
}
|
||||
|
||||
function isNull(value: unknown): boolean { return value === null; }
|
||||
|
||||
function rowNumberStatusClass(item: RowItem): string {
|
||||
|
|
@ -741,65 +751,17 @@ function deleteSelectedRow() {
|
|||
requestDeleteRow(contextCell.value.rowId);
|
||||
}
|
||||
|
||||
function escapeVal(v: CellValue): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
const s = String(v);
|
||||
if (s === "") return "''";
|
||||
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
if (props.databaseType === "mysql") {
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function qualifiedTableName(): string {
|
||||
if (!props.tableMeta) return "";
|
||||
const { schema, tableName } = props.tableMeta;
|
||||
if ((props.databaseType === "postgres" || props.databaseType === "oracle") && schema) {
|
||||
return `${quoteIdent(schema)}.${quoteIdent(tableName)}`;
|
||||
}
|
||||
return quoteIdent(tableName);
|
||||
}
|
||||
|
||||
function generateSaveStatements(): string[] {
|
||||
if (!props.tableMeta) return [];
|
||||
const { primaryKeys } = props.tableMeta;
|
||||
const cols = props.result.columns;
|
||||
const stmts: string[] = [];
|
||||
const tbl = qualifiedTableName();
|
||||
|
||||
for (const [rowIdx, changes] of dirtyRows.value) {
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const sets = Array.from(changes.entries())
|
||||
.map(([colIdx, val]) => `${quoteIdent(cols[colIdx])} = ${escapeVal(val)}`)
|
||||
.join(", ");
|
||||
const where = primaryKeys
|
||||
.map((pk) => `${quoteIdent(pk)} = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`UPDATE ${tbl} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIdx of deletedRows.value) {
|
||||
const row = props.result.rows[rowIdx];
|
||||
if (!row) continue;
|
||||
const where = primaryKeys
|
||||
.map((pk) => `${quoteIdent(pk)} = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`DELETE FROM ${tbl} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const newRow of newRows.value) {
|
||||
const colNames = cols.map((c) => quoteIdent(c)).join(", ");
|
||||
const vals = newRow.map((v) => escapeVal(v)).join(", ");
|
||||
stmts.push(`INSERT INTO ${tbl} (${colNames}) VALUES (${vals});`);
|
||||
}
|
||||
return stmts;
|
||||
return buildDataGridSaveStatements({
|
||||
databaseType: props.databaseType,
|
||||
tableMeta: props.tableMeta,
|
||||
columns: props.result.columns,
|
||||
rows: props.result.rows,
|
||||
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]),
|
||||
deletedRows: [...deletedRows.value],
|
||||
newRows: newRows.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
|
|
@ -1012,16 +974,9 @@ async function exportJson() {
|
|||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
const pad = (s: string, len: number) => s.padEnd(len);
|
||||
const cols = props.result.columns;
|
||||
const visibleRows = displayItems.value.map((item) => item.data);
|
||||
const widths = cols.map((c, i) => Math.max(c.length, ...visibleRows.map((r) => formatCell(r[i]).length), 3));
|
||||
const header = `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`;
|
||||
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
||||
const body = visibleRows.map((row) =>
|
||||
`| ${row.map((c, i) => pad(formatCell(c), widths[i])).join(" | ")} |`
|
||||
).join("\n");
|
||||
const md = `${header}\n${sep}\n${body}\n`;
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
const path = await savePath({ filters: [{ name: "Markdown", extensions: ["md"] }] });
|
||||
if (path) await writeTextFile(path, md);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowUpRight, Check, Columns3, Copy, Eye, Filter, History, Link, Loader2, RefreshCw, Search, 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 { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import {
|
||||
analyzeFieldLineage,
|
||||
summarizeLineageCounts,
|
||||
type FieldLineageConfidence,
|
||||
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];
|
||||
"open-target": [value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
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);
|
||||
const confidenceFilter = ref<"all" | FieldLineageConfidence>("all");
|
||||
const searchText = ref("");
|
||||
const copiedId = ref("");
|
||||
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 confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(() => [
|
||||
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
||||
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
||||
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
||||
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
||||
]);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase();
|
||||
return (result.value?.items ?? [])
|
||||
.filter((item) => confidenceFilter.value === "all" || item.confidence === confidenceFilter.value)
|
||||
.filter((item) => {
|
||||
if (!query) return true;
|
||||
return [
|
||||
item.title,
|
||||
item.schema,
|
||||
item.table,
|
||||
item.column,
|
||||
item.sqlSnippet,
|
||||
itemKindLabel(item),
|
||||
itemDescription(item),
|
||||
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
||||
})
|
||||
.sort((a, b) => itemRank(a) - itemRank(b));
|
||||
});
|
||||
|
||||
const filteredCounts = computed(() => summarizeLineageCounts(filteredItems.value));
|
||||
|
||||
watch(dialogOpen, (open) => {
|
||||
if (open) {
|
||||
confidenceFilter.value = "all";
|
||||
searchText.value = "";
|
||||
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";
|
||||
}
|
||||
|
||||
function confidenceTone(confidence: FieldLineageItem["confidence"]) {
|
||||
if (confidence === "certain") return "text-emerald-600 bg-emerald-50 border-emerald-200";
|
||||
if (confidence === "likely") return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
return "text-zinc-600 bg-zinc-50 border-zinc-200";
|
||||
}
|
||||
|
||||
function itemRank(item: FieldLineageItem) {
|
||||
const confidenceRank = item.confidence === "certain" ? 0 : item.confidence === "likely" ? 10 : 20;
|
||||
const kindRank = item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
||||
return confidenceRank + kindRank;
|
||||
}
|
||||
|
||||
function itemKindLabel(item: FieldLineageItem) {
|
||||
return t(`lineage.kind.${item.kind}`);
|
||||
}
|
||||
|
||||
function itemPrimaryLabel(item: FieldLineageItem) {
|
||||
if (item.kind === "historyReference") return t("lineage.queryHistory");
|
||||
if (item.table && item.column) return `${item.table}.${item.column}`;
|
||||
if (item.table) return `${item.table}.${props.prefillColumn}`;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function itemDescription(item: FieldLineageItem) {
|
||||
if (item.kind === "foreignKey") {
|
||||
if (item.direction === "incoming") {
|
||||
return t("lineage.description.foreignKeyIncoming", { target: `${item.table}.${item.column}` });
|
||||
}
|
||||
return t("lineage.description.foreignKeyOutgoing", { target: `${item.table}.${item.column}` });
|
||||
}
|
||||
if (item.kind === "viewReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.viewLikely")
|
||||
: t("lineage.description.viewPossible");
|
||||
}
|
||||
if (item.kind === "historyReference") {
|
||||
return item.confidence === "likely"
|
||||
? t("lineage.description.historyLikely")
|
||||
: t("lineage.description.historyPossible");
|
||||
}
|
||||
return t("lineage.description.sameName");
|
||||
}
|
||||
|
||||
function copyItem(item: FieldLineageItem) {
|
||||
const text = itemPrimaryLabel(item);
|
||||
navigator.clipboard.writeText(text);
|
||||
copiedId.value = item.id;
|
||||
toast(t("lineage.copied"));
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === item.id) copiedId.value = "";
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function openItemTarget(item: FieldLineageItem) {
|
||||
if (!item.table) return;
|
||||
emit("open-target", {
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase,
|
||||
schema: item.schema || props.prefillSchema,
|
||||
tableName: item.table,
|
||||
columnName: item.column || props.prefillColumn,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]">
|
||||
<DialogHeader class="border-b px-6 py-4 pr-12">
|
||||
<DialogTitle class="flex items-center gap-2 text-lg">
|
||||
<Link class="h-5 w-5" />
|
||||
{{ t('lineage.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex min-h-0 flex-col overflow-hidden">
|
||||
<div class="border-b bg-muted/10 px-6 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-muted-foreground">{{ t('lineage.targetField') }}</div>
|
||||
<div class="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<span class="max-w-[520px] truncate font-semibold">{{ targetLabel }}</span>
|
||||
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
||||
<Badge v-if="props.prefillSchema" variant="outline">{{ props.prefillSchema }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result" 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>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="searchText" class="h-8 pl-8 text-sm" :placeholder="t('lineage.searchPlaceholder')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<Filter class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Button
|
||||
v-for="option in confidenceOptions"
|
||||
:key="option.value"
|
||||
size="sm"
|
||||
:variant="confidenceFilter === option.value ? 'default' : 'outline'"
|
||||
class="h-8 shrink-0 px-3"
|
||||
@click="confidenceFilter = option.value"
|
||||
>
|
||||
{{ option.label }} {{ option.count }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div v-if="loading" class="rounded-md 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-md 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-md 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="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('lineage.showing', { shown: filteredItems.length, total: result.items.length }) }}</span>
|
||||
<span v-if="filteredItems.length">
|
||||
{{ t('lineage.certain') }} {{ filteredCounts.certain }} ·
|
||||
{{ t('lineage.likely') }} {{ filteredCounts.likely }} ·
|
||||
{{ t('lineage.possible') }} {{ filteredCounts.possible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredItems.length === 0" class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground">
|
||||
<SearchX class="h-8 w-8" />
|
||||
{{ t('lineage.noFiltered') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="item in filteredItems" :key="item.id" class="rounded-md border bg-background transition-colors hover:bg-muted/25">
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<div :class="['mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border', confidenceTone(item.confidence)]">
|
||||
<component :is="itemIcon(item)" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="item.table"
|
||||
type="button"
|
||||
class="group inline-flex min-w-0 max-w-[560px] items-center gap-1 truncate rounded-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="t('lineage.openTable')"
|
||||
@click="openItemTarget(item)"
|
||||
>
|
||||
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
||||
<ArrowUpRight class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
||||
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
||||
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
||||
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{ t(`lineage.${item.confidence}`) }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ itemDescription(item) }}</p>
|
||||
<pre v-if="item.sqlSnippet" class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap">{{ item.sqlSnippet }}</pre>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 shrink-0 p-0" :title="t('lineage.copy')" @click="copyItem(item)">
|
||||
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="border-t px-6 py-4">
|
||||
<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') }}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|||
import * as api from "@/lib/tauri";
|
||||
import type { TransferProgress } from "@/lib/tauri";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import {
|
||||
ArrowRightLeft, Check, X, Loader2, Square, CheckSquare,
|
||||
} from "lucide-vue-next";
|
||||
|
|
@ -237,11 +238,14 @@ async function startTransfer() {
|
|||
transferProgress.value = new Map(transferProgress.value);
|
||||
currentTable.value = progress.table;
|
||||
|
||||
if (progress.status === "done") {
|
||||
overallDone.value = true;
|
||||
} else if (progress.status === "cancelled") {
|
||||
overallCancelled.value = true;
|
||||
}
|
||||
const nextState = nextTransferTerminalState({
|
||||
done: overallDone.value,
|
||||
cancelled: overallCancelled.value,
|
||||
error: overallError.value,
|
||||
}, progress);
|
||||
overallDone.value = nextState.done;
|
||||
overallCancelled.value = nextState.cancelled;
|
||||
overallError.value = nextState.error;
|
||||
});
|
||||
} catch (e: any) {
|
||||
overallError.value = true;
|
||||
|
|
@ -440,6 +444,7 @@ const totalTransferred = computed(() =>
|
|||
</span>
|
||||
<span v-if="overallDone" class="text-green-600 font-medium">{{ t('transfer.completed') }}</span>
|
||||
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t('transfer.cancelled') }}</span>
|
||||
<span v-else-if="overallError" class="text-destructive font-medium">{{ t('transfer.failed') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
|
|
@ -496,7 +501,7 @@ const totalTransferred = computed(() =>
|
|||
{{ t('transfer.start') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="overallDone || overallCancelled">
|
||||
<template v-else-if="overallDone || overallCancelled || overallError">
|
||||
<Button size="sm" @click="open = false">
|
||||
{{ t('common.close') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -187,6 +187,41 @@ 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",
|
||||
noFiltered: "No results match the current filters",
|
||||
targetField: "Current Field",
|
||||
searchPlaceholder: "Search tables, fields, views, or SQL snippets...",
|
||||
showing: "Showing {shown}/{total} results",
|
||||
all: "All",
|
||||
certain: "Certain",
|
||||
likely: "Likely",
|
||||
possible: "Possible",
|
||||
queryHistory: "Query History",
|
||||
openTable: "Open table",
|
||||
copy: "Copy name",
|
||||
copied: "Copied",
|
||||
kind: {
|
||||
foreignKey: "Foreign Key",
|
||||
viewReference: "View",
|
||||
historyReference: "SQL History",
|
||||
sameName: "Same Name",
|
||||
},
|
||||
description: {
|
||||
foreignKeyIncoming: "{target} points to the current field through a foreign key. This is a verified dependency.",
|
||||
foreignKeyOutgoing: "The current field references {target} through a foreign key. This is a verified dependency.",
|
||||
viewLikely: "The view definition mentions both the target table and field, usually indicating query dependency.",
|
||||
viewPossible: "The view definition mentions a same-name field but not the target table, so it needs confirmation.",
|
||||
historyLikely: "A historical SQL statement mentions both the target table and field. Use it as impact-analysis context.",
|
||||
historyPossible: "A historical SQL statement mentions a same-name field. It may be related but needs context.",
|
||||
sameName: "Another table has a same-name field. This may share business meaning but is not a verified database dependency.",
|
||||
},
|
||||
cancel: "Cancel",
|
||||
refresh: "Analyze Again",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "Describe your query in natural language...",
|
||||
settings: "AI Settings",
|
||||
|
|
@ -354,6 +389,8 @@ export default {
|
|||
restore: "Restore to editor",
|
||||
copy: "Copy SQL",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete this query history entry?",
|
||||
confirmClear: "Clear all query history?",
|
||||
},
|
||||
dangerDialog: {
|
||||
title: "Dangerous Operation",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,41 @@ export default {
|
|||
emptySql: "当前没有可分析的 SQL",
|
||||
unsafe: "第一版执行计划仅支持 SELECT / WITH / TABLE / VALUES",
|
||||
},
|
||||
lineage: {
|
||||
title: "字段血缘",
|
||||
open: "查看字段血缘",
|
||||
loading: "正在读取元数据 {done}/{total}",
|
||||
empty: "没有找到相关血缘",
|
||||
noFiltered: "当前筛选条件下没有结果",
|
||||
targetField: "当前字段",
|
||||
searchPlaceholder: "搜索表、字段、视图或 SQL 片段...",
|
||||
showing: "显示 {shown}/{total} 条结果",
|
||||
all: "全部",
|
||||
certain: "确定",
|
||||
likely: "高置信",
|
||||
possible: "可能相关",
|
||||
queryHistory: "查询历史",
|
||||
openTable: "打开对应表",
|
||||
copy: "复制名称",
|
||||
copied: "已复制",
|
||||
kind: {
|
||||
foreignKey: "外键",
|
||||
viewReference: "视图",
|
||||
historyReference: "历史 SQL",
|
||||
sameName: "同名字段",
|
||||
},
|
||||
description: {
|
||||
foreignKeyIncoming: "{target} 通过外键指向当前字段,这是已确认的依赖。",
|
||||
foreignKeyOutgoing: "当前字段通过外键引用 {target},这是已确认的依赖。",
|
||||
viewLikely: "视图定义同时提到目标表和字段,通常表示查询依赖。",
|
||||
viewPossible: "视图定义提到了同名字段,但没有同时命中目标表,需要人工确认。",
|
||||
historyLikely: "历史 SQL 同时提到目标表和字段,可作为排查影响面的参考。",
|
||||
historyPossible: "历史 SQL 提到了同名字段,可能相关但需要确认上下文。",
|
||||
sameName: "其他表存在同名字段,可能代表相同业务含义,但不是已验证的数据库依赖。",
|
||||
},
|
||||
cancel: "取消读取",
|
||||
refresh: "重新分析",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "描述你想查什么...",
|
||||
settings: "AI 设置",
|
||||
|
|
@ -354,6 +389,8 @@ export default {
|
|||
restore: "恢复到编辑器",
|
||||
copy: "复制 SQL",
|
||||
delete: "删除",
|
||||
confirmDelete: "确认删除这条查询历史吗?",
|
||||
confirmClear: "确认清空所有查询历史吗?",
|
||||
},
|
||||
dangerDialog: {
|
||||
title: "危险操作",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import type { DatabaseType } from "@/types/database";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
|
||||
export type GridCellValue = string | number | boolean | null;
|
||||
|
||||
export interface DataGridTableMeta {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
primaryKeys: string[];
|
||||
}
|
||||
|
||||
export interface DataGridSaveStatementOptions {
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta: DataGridTableMeta;
|
||||
columns: string[];
|
||||
rows: GridCellValue[][];
|
||||
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
|
||||
deletedRows: number[];
|
||||
newRows: GridCellValue[][];
|
||||
}
|
||||
|
||||
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
tableName: options.tableMeta.tableName,
|
||||
});
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const [rowIndex, changes] of options.dirtyRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const sets = changes
|
||||
.map(([columnIndex, value]) => `${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(value)}`)
|
||||
.join(", ");
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIndex of options.deletedRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row);
|
||||
statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const row of options.newRows) {
|
||||
const columns = options.columns.map((column) => quoteIdent(options.databaseType, column)).join(", ");
|
||||
const values = row.map(formatGridSqlLiteral).join(", ");
|
||||
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
export function formatGridSqlLiteral(value: GridCellValue): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
const text = String(value);
|
||||
if (text === "") return "''";
|
||||
return `'${text.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function buildPrimaryKeyWhere(
|
||||
databaseType: DatabaseType | undefined,
|
||||
primaryKeys: string[],
|
||||
columns: string[],
|
||||
row: GridCellValue[],
|
||||
): string {
|
||||
return primaryKeys
|
||||
.map((primaryKey) => {
|
||||
const value = row[columns.indexOf(primaryKey)];
|
||||
return `${quoteIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(value)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
}
|
||||
|
||||
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
|
||||
return quoteTableIdentifier(databaseType, name);
|
||||
}
|
||||
|
|
@ -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, "\\$&");
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export function shouldDeleteHistoryEntry(confirmDelete: () => boolean): boolean {
|
||||
return confirmDelete();
|
||||
}
|
||||
|
||||
export function shouldClearHistory(entryCount: number, confirmClear: () => boolean): boolean {
|
||||
return entryCount > 0 && confirmClear();
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { GridCellValue } from "./dataGridSql.ts";
|
||||
|
||||
export interface MarkdownTableData {
|
||||
columns: string[];
|
||||
rows: GridCellValue[][];
|
||||
}
|
||||
|
||||
export function formatMarkdownTable(data: MarkdownTableData): string {
|
||||
const normalizedColumns = data.columns.map(markdownCell);
|
||||
const normalizedRows = data.rows.map((row) => row.map((cell) => markdownCell(displayCell(cell))));
|
||||
const widths = normalizedColumns.map((column, index) =>
|
||||
Math.max(column.length, ...normalizedRows.map((row) => row[index]?.length ?? 0), 3)
|
||||
);
|
||||
const header = `| ${normalizedColumns.map((column, index) => pad(column, widths[index])).join(" | ")} |`;
|
||||
const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
|
||||
const body = normalizedRows
|
||||
.map((row) => `| ${row.map((cell, index) => pad(cell, widths[index])).join(" | ")} |`)
|
||||
.join("\n");
|
||||
return `${[header, separator, body].filter(Boolean).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function displayCell(value: GridCellValue): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function markdownCell(value: string): string {
|
||||
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
|
||||
function pad(value: string, width: number): string {
|
||||
return value.padEnd(width);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export interface BuildTableSelectSqlOptions {
|
|||
schema?: string;
|
||||
tableName: string;
|
||||
primaryKeys?: string[];
|
||||
fallbackOrderColumns?: string[];
|
||||
orderBy?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
|
@ -13,6 +14,7 @@ export interface BuildTableSelectSqlOptions {
|
|||
|
||||
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +39,8 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
|
|||
const where = predicate ? ` WHERE (${predicate})` : "";
|
||||
const defaultOrderBy = options.primaryKeys?.length
|
||||
? options.primaryKeys.map((pk) => `${quoteTableIdentifier(databaseType, pk)} ASC`).join(", ")
|
||||
: options.fallbackOrderColumns?.length
|
||||
? options.fallbackOrderColumns.map((column) => `${quoteTableIdentifier(databaseType, column)} ASC`).join(", ")
|
||||
: undefined;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy;
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
|
|
@ -47,7 +51,8 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
|
|||
}
|
||||
|
||||
if (databaseType === "sqlserver") {
|
||||
return `SELECT TOP ${limit} * FROM ${table}${where}${order}`;
|
||||
const stableOrder = order || " ORDER BY (SELECT NULL)";
|
||||
return `SELECT * FROM ${table}${where}${stableOrder} OFFSET ${options.offset ?? 0} ROWS FETCH NEXT ${limit} ROWS ONLY`;
|
||||
}
|
||||
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ export async function executeBatch(connectionId: string, database: string, state
|
|||
return invoke("execute_batch", { connectionId, database, statements });
|
||||
}
|
||||
|
||||
export async function executeScript(connectionId: string, database: string, sql: string): Promise<QueryResult> {
|
||||
return invoke("execute_script", { connectionId, database, sql });
|
||||
}
|
||||
|
||||
export async function listIndexes(connectionId: string, database: string, schema: string, table: string): Promise<IndexInfo[]> {
|
||||
return invoke("list_indexes", { connectionId, database, schema, table });
|
||||
}
|
||||
|
|
@ -394,9 +398,7 @@ export async function startTransfer(
|
|||
if (event.payload.transferId === request.transferId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
if (event.payload.tableIndex === event.payload.totalTables - 1 || event.payload.status !== "error") {
|
||||
unlisten();
|
||||
}
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export function isTauriRuntime(globalObject: Record<string, unknown> = globalThis as Record<string, unknown>): boolean {
|
||||
return Boolean(globalObject.__TAURI_INTERNALS__ || globalObject.__TAURI__);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export interface TransferTerminalState {
|
||||
done: boolean;
|
||||
cancelled: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface TransferStatusLike {
|
||||
status: "running" | "tableDone" | "done" | "error" | "cancelled";
|
||||
}
|
||||
|
||||
export function nextTransferTerminalState(
|
||||
state: TransferTerminalState,
|
||||
progress: TransferStatusLike,
|
||||
): TransferTerminalState {
|
||||
if (progress.status === "done") return { ...state, done: true };
|
||||
if (progress.status === "cancelled") return { ...state, cancelled: true };
|
||||
if (progress.status === "error") return { ...state, error: true };
|
||||
return state;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildDataGridSaveStatements } from "../src/lib/dataGridSql.ts";
|
||||
|
||||
test("builds SQL Server grid save statements with schema and bracket quoting", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "sqlserver",
|
||||
tableMeta: {
|
||||
schema: "game",
|
||||
tableName: "player states",
|
||||
primaryKeys: ["role id"],
|
||||
},
|
||||
columns: ["role id", "state", "updated at"],
|
||||
rows: [[42, "old", "2026-05-03"]],
|
||||
dirtyRows: [[0, [[1, "ready"], [2, "2026-05-04"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[43, "new", "2026-05-05"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE [game].[player states] SET [state] = 'ready', [updated at] = '2026-05-04' WHERE [role id] = 42;",
|
||||
"DELETE FROM [game].[player states] WHERE [role id] = 42;",
|
||||
"INSERT INTO [game].[player states] ([role id], [state], [updated at]) VALUES (43, 'new', '2026-05-05');",
|
||||
]);
|
||||
});
|
||||
|
|
@ -53,7 +53,7 @@ test("builds capped export page queries", () => {
|
|||
tableName: "accounts",
|
||||
limit: DATABASE_EXPORT_ROW_LIMIT,
|
||||
}),
|
||||
`SELECT TOP ${DATABASE_EXPORT_ROW_LIMIT} * FROM "dbo"."accounts"`,
|
||||
`SELECT * FROM [dbo].[accounts] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT ${DATABASE_EXPORT_ROW_LIMIT} ROWS ONLY`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { analyzeFieldLineage, identifierInSql, summarizeLineageCounts } from "../src/lib/fieldLineage.ts";
|
||||
|
||||
test("detects outgoing and incoming foreign key lineage as certain", () => {
|
||||
const result = analyzeFieldLineage({
|
||||
target: { schema: "public", table: "orders", column: "user_id" },
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
columns: ["id", "user_id"],
|
||||
foreignKeys: [{ name: "orders_user_id_fkey", column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
{
|
||||
schema: "public",
|
||||
name: "order_events",
|
||||
columns: ["id", "order_user_id"],
|
||||
foreignKeys: [{ name: "events_user_fkey", column: "order_user_id", ref_table: "orders", ref_column: "user_id" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => [item.kind, item.confidence, item.direction, item.table, item.column]),
|
||||
[
|
||||
["foreignKey", "certain", "outgoing", "users", "id"],
|
||||
["foreignKey", "certain", "incoming", "order_events", "order_user_id"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("finds view, query history, and same-name column references without scanning table data", () => {
|
||||
const result = analyzeFieldLineage({
|
||||
target: { schema: "public", table: "users", column: "email" },
|
||||
tables: [
|
||||
{ schema: "public", name: "users", columns: ["id", "email"], foreignKeys: [] },
|
||||
{ schema: "public", name: "newsletter_subscribers", columns: ["id", "email"], foreignKeys: [] },
|
||||
],
|
||||
views: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "active_user_emails",
|
||||
ddl: "CREATE VIEW active_user_emails AS SELECT u.email FROM public.users u WHERE u.active = true",
|
||||
},
|
||||
],
|
||||
histories: [
|
||||
{
|
||||
id: "h1",
|
||||
sql: "select email from users where email like '%@example.com'",
|
||||
executed_at: "2026-05-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => [item.kind, item.confidence, item.table, item.column]),
|
||||
[
|
||||
["viewReference", "likely", "active_user_emails", undefined],
|
||||
["historyReference", "likely", undefined, undefined],
|
||||
["sameName", "possible", "newsletter_subscribers", "email"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(summarizeLineageCounts(result.items), {
|
||||
certain: 0,
|
||||
likely: 2,
|
||||
possible: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("matches quoted and bare identifiers safely", () => {
|
||||
assert.equal(identifierInSql('select "User Email" from users', "User Email"), true);
|
||||
assert.equal(identifierInSql("select `email` from users", "email"), true);
|
||||
assert.equal(identifierInSql("select user_email from users", "email"), false);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { shouldDeleteHistoryEntry, shouldClearHistory } from "../src/lib/historyActions.ts";
|
||||
|
||||
test("requires confirmation before deleting history entries", () => {
|
||||
assert.equal(shouldDeleteHistoryEntry(() => false), false);
|
||||
assert.equal(shouldDeleteHistoryEntry(() => true), true);
|
||||
});
|
||||
|
||||
test("requires existing entries and confirmation before clearing history", () => {
|
||||
assert.equal(shouldClearHistory(0, () => true), false);
|
||||
assert.equal(shouldClearHistory(2, () => false), false);
|
||||
assert.equal(shouldClearHistory(2, () => true), true);
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatMarkdownTable } from "../src/lib/markdownTable.ts";
|
||||
|
||||
test("escapes markdown table pipes and normalizes newlines", () => {
|
||||
const markdown = formatMarkdownTable({
|
||||
columns: ["id", "payload|kind"],
|
||||
rows: [
|
||||
[1, "a|b"],
|
||||
[2, "line one\nline two"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(markdown, [
|
||||
"| id | payload\\|kind |",
|
||||
"| --- | -------------------- |",
|
||||
"| 1 | a\\|b |",
|
||||
"| 2 | line one<br>line two |",
|
||||
"",
|
||||
].join("\n"));
|
||||
});
|
||||
|
|
@ -27,14 +27,41 @@ test("builds a schema-qualified PostgreSQL table WHERE query", () => {
|
|||
assert.equal(sql, 'SELECT * FROM "public"."orders" WHERE (amount > 10) LIMIT 50 OFFSET 100;');
|
||||
});
|
||||
|
||||
test("builds SQL Server WHERE query with TOP", () => {
|
||||
test("builds SQL Server first page query with schema-aware brackets", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
whereInput: "where id = 1",
|
||||
limit: 25,
|
||||
primaryKeys: ["id"],
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT TOP 25 * FROM "dbo"."accounts" WHERE (id = 1)');
|
||||
assert.equal(sql, "SELECT * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY");
|
||||
});
|
||||
|
||||
test("builds SQL Server later pages with OFFSET and FETCH", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "sales",
|
||||
tableName: "orders",
|
||||
primaryKeys: ["order_id"],
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM [sales].[orders] ORDER BY [order_id] ASC OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY");
|
||||
});
|
||||
|
||||
test("builds SQL Server pages with fallback order columns when there is no primary key", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
fallbackOrderColumns: ["created_at"],
|
||||
limit: 50,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM [dbo].[logs] ORDER BY [created_at] ASC OFFSET 50 ROWS FETCH NEXT 50 ROWS ONLY");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { isTauriRuntime } from "../src/lib/tauriRuntime.ts";
|
||||
|
||||
test("detects plain browser-like globals as non-Tauri runtime", () => {
|
||||
assert.equal(isTauriRuntime({}), false);
|
||||
});
|
||||
|
||||
test("detects Tauri globals", () => {
|
||||
assert.equal(isTauriRuntime({ __TAURI_INTERNALS__: {} }), true);
|
||||
assert.equal(isTauriRuntime({ __TAURI__: {} }), true);
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { nextTransferTerminalState } from "../src/lib/transferProgressState.ts";
|
||||
|
||||
test("marks transfer as failed when a table progress event reports error", () => {
|
||||
const state = nextTransferTerminalState(
|
||||
{ done: false, cancelled: false, error: false },
|
||||
{ status: "error" },
|
||||
);
|
||||
|
||||
assert.deepEqual(state, { done: false, cancelled: false, error: true });
|
||||
});
|
||||
|
||||
test("keeps terminal flags for done and cancelled progress events", () => {
|
||||
assert.deepEqual(
|
||||
nextTransferTerminalState({ done: false, cancelled: false, error: false }, { status: "done" }),
|
||||
{ done: true, cancelled: false, error: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
nextTransferTerminalState({ done: false, cancelled: false, error: false }, { status: "cancelled" }),
|
||||
{ done: false, cancelled: true, error: false },
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue