897 lines
34 KiB
Vue
897 lines
34 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch, onBeforeUnmount } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { useToast } from "@/composables/useToast";
|
|
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
|
|
import * as api from "@/lib/backend/api";
|
|
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
|
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
|
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
|
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
|
import SchemaDiffDdlPanel from "@/components/diff/SchemaDiffDdlPanel.vue";
|
|
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
|
|
import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue";
|
|
|
|
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
|
|
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, shouldFetchSchemaDiffDdl } from "@/lib/schema/schemaDiffMetadataLoad";
|
|
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
|
import type { SchemaDiffCompareOptions, SchemaDiffConfig } from "@/types/schemaDiff";
|
|
import type { ObjectSourceKind, TableInfo } from "@/types/database";
|
|
import { buildDeploySqlForObjects, convertToSchemaDiffObjects, groupDiffObjects, schemaDiffDeployTargetSchema, type OperationGroup, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type SchemaDiffPreparation, type TableSchemaDetail } from "@/lib/schema/schemaDiff";
|
|
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
|
|
import { Splitpanes, Pane } from "splitpanes";
|
|
import "splitpanes/dist/splitpanes.css";
|
|
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const open = defineModel<boolean>("open", { default: false });
|
|
const store = useConnectionStore();
|
|
|
|
const props = defineProps<{
|
|
prefillConnectionId?: string;
|
|
prefillDatabase?: string;
|
|
prefillSchema?: string;
|
|
}>();
|
|
|
|
// Wizard state
|
|
const step = ref<"config" | "compare" | "result" | "deploy-review">("config");
|
|
|
|
// Deploy confirm dialog
|
|
const showConfirmDialog = ref(false);
|
|
|
|
// Source/Target selections
|
|
const sourceConnectionId = ref("");
|
|
const sourceDatabase = ref("");
|
|
const sourceSchema = ref("");
|
|
const targetConnectionId = ref("");
|
|
const targetDatabase = ref("");
|
|
const targetSchema = ref("");
|
|
const ignoreComments = ref(false);
|
|
|
|
// Options panel
|
|
const showOptionsPanel = ref(false);
|
|
const optionTree = computed(() => {
|
|
const targetConfig = store.getConfig(targetConnectionId.value);
|
|
const dbType = targetConfig?.db_type || "postgres";
|
|
return getSchemaDiffOptionsForDbType(dbType);
|
|
});
|
|
|
|
// Compare state
|
|
const loading = ref(false);
|
|
const diffObjects = ref<SchemaDiffObject[]>([]);
|
|
const diffGroups = ref<OperationGroup[]>([]);
|
|
const selectedObjectId = ref<string | null>(null);
|
|
const deploySql = ref("");
|
|
const deploySqlAll = ref("");
|
|
const executing = ref(false);
|
|
const lastDiffResult = ref<SchemaDiffPreparation | null>(null);
|
|
const targetDbVersion = ref<string | null>(null);
|
|
const showResultDialog = ref(false);
|
|
const deployResult = ref<{ success: boolean; message: string; affectedRows?: number } | null>(null);
|
|
|
|
// Dialog size memory (width + height + splitpanes ratio)
|
|
const DIALOG_SIZE_KEY = "dbx-schema-diff-size";
|
|
const SPLITPANES_SIZE_KEY = "dbx-schema-diff-splitpanes-v2";
|
|
const savedSize = JSON.parse(localStorage.getItem(DIALOG_SIZE_KEY) || "null");
|
|
|
|
const savedSplitpanes = (() => {
|
|
try {
|
|
const raw = localStorage.getItem(SPLITPANES_SIZE_KEY);
|
|
if (!raw) return null;
|
|
const val = JSON.parse(raw);
|
|
return typeof val === "number" && val >= 10 && val <= 90 ? val : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
// Splitpanes size memory (percentage for first pane)
|
|
const splitpanesSize = ref(savedSplitpanes ?? 60);
|
|
|
|
function handleSplitpanesResized(payload: { panes: { size: number }[] }) {
|
|
if (payload.panes && payload.panes.length > 0) {
|
|
const size = payload.panes[0].size;
|
|
splitpanesSize.value = size;
|
|
localStorage.setItem(SPLITPANES_SIZE_KEY, JSON.stringify(size));
|
|
}
|
|
}
|
|
|
|
const isMaximized = ref(false);
|
|
|
|
// Config step: always use default size 1100x820
|
|
// Result step: use saved size if exists
|
|
const dialogStyle = computed(() => {
|
|
if (isMaximized.value) {
|
|
return {
|
|
width: "100%",
|
|
height: "100%",
|
|
maxWidth: "100%",
|
|
maxHeight: "100%",
|
|
borderRadius: "0",
|
|
};
|
|
}
|
|
if (step.value === "result") {
|
|
return {
|
|
width: savedSize?.width || "1100px",
|
|
height: savedSize?.height || "820px",
|
|
maxWidth: "calc(100vw - 2rem)",
|
|
maxHeight: "calc(100vh - 2rem)",
|
|
};
|
|
}
|
|
return {
|
|
width: "1100px",
|
|
height: "820px",
|
|
maxWidth: "calc(100vw - 2rem)",
|
|
maxHeight: "calc(100vh - 2rem)",
|
|
};
|
|
});
|
|
|
|
function toggleMaximize() {
|
|
isMaximized.value = !isMaximized.value;
|
|
}
|
|
|
|
function handleDialogEscape(event: KeyboardEvent) {
|
|
if (!showOptionsPanel.value) return;
|
|
|
|
event.preventDefault();
|
|
showOptionsPanel.value = false;
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let saveTimeout: number | null = null;
|
|
|
|
function setupResizeObserver() {
|
|
const el = document.querySelector('[data-slot="dialog-content"]') as HTMLElement;
|
|
if (!el) return;
|
|
|
|
resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect;
|
|
if (saveTimeout) clearTimeout(saveTimeout);
|
|
saveTimeout = window.setTimeout(() => {
|
|
localStorage.setItem(
|
|
DIALOG_SIZE_KEY,
|
|
JSON.stringify({
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
}),
|
|
);
|
|
}, 500);
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(el);
|
|
}
|
|
|
|
function teardownResizeObserver() {
|
|
if (saveTimeout) clearTimeout(saveTimeout);
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = null;
|
|
}
|
|
|
|
// Only enable resize observer in result step
|
|
watch(
|
|
() => step.value,
|
|
(newStep) => {
|
|
if (newStep === "result") {
|
|
setTimeout(setupResizeObserver, 100);
|
|
} else {
|
|
teardownResizeObserver();
|
|
}
|
|
},
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
teardownResizeObserver();
|
|
});
|
|
|
|
// Config management
|
|
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
|
|
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
|
|
|
|
const selectedObject = computed(() => {
|
|
if (!selectedObjectId.value) return null;
|
|
for (const group of diffGroups.value) {
|
|
for (const typeGroup of group.typeGroups) {
|
|
const obj = typeGroup.objects.find((o) => o.id === selectedObjectId.value);
|
|
if (obj) return obj;
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const canDeploy = computed(() => {
|
|
return diffObjects.value.some((o) => o.selected && o.operationType !== "none");
|
|
});
|
|
|
|
// Watch for prefilled values
|
|
watch(
|
|
() => open.value,
|
|
(isOpen) => {
|
|
if (isOpen) {
|
|
ensureDefaultConfig();
|
|
if (props.prefillConnectionId) {
|
|
sourceConnectionId.value = props.prefillConnectionId;
|
|
if (props.prefillDatabase) {
|
|
sourceDatabase.value = props.prefillDatabase;
|
|
}
|
|
if (props.prefillSchema) {
|
|
sourceSchema.value = props.prefillSchema;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
// Config sync
|
|
watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, targetDatabase, targetSchema], ([srcConn, srcDb, srcSchema, tgtConn, tgtDb, tgtSchema]) => {
|
|
updateActiveConfigConnection({
|
|
sourceConnectionId: srcConn,
|
|
sourceDatabase: srcDb,
|
|
sourceSchema: srcSchema,
|
|
targetConnectionId: tgtConn,
|
|
targetDatabase: tgtDb,
|
|
targetSchema: tgtSchema,
|
|
});
|
|
});
|
|
|
|
// Auto-fetch target database version when connection/database changes
|
|
watch(
|
|
() => [targetConnectionId.value, targetDatabase.value],
|
|
async ([connId, db]) => {
|
|
if (connId && db) {
|
|
await fetchDbVersion(connId, db, targetSchema.value);
|
|
} else {
|
|
targetDbVersion.value = null;
|
|
}
|
|
},
|
|
);
|
|
|
|
function getDbType(): string {
|
|
const targetConfig = store.getConfig(targetConnectionId.value);
|
|
return targetConfig?.db_type || "postgres";
|
|
}
|
|
|
|
function handleSwap() {
|
|
const tempConn = sourceConnectionId.value;
|
|
const tempDb = sourceDatabase.value;
|
|
const tempSchema = sourceSchema.value;
|
|
sourceConnectionId.value = targetConnectionId.value;
|
|
sourceDatabase.value = targetDatabase.value;
|
|
sourceSchema.value = targetSchema.value;
|
|
targetConnectionId.value = tempConn;
|
|
targetDatabase.value = tempDb;
|
|
targetSchema.value = tempSchema;
|
|
}
|
|
|
|
function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
|
|
if (activeConfig.value) {
|
|
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(options, getDbType()));
|
|
}
|
|
}
|
|
|
|
/** Map a JDBC table_type to an ObjectSourceKind for getTableDdl routing.
|
|
* Views and materialized views need the object_type parameter so the
|
|
* backend can call DBMS_METADATA.GET_DDL with the correct type. */
|
|
function isViewOrMaterializedView(tableType: string): ObjectSourceKind | undefined {
|
|
switch (tableType.toUpperCase().replace(/\s+/g, "_")) {
|
|
case "VIEW":
|
|
return "VIEW";
|
|
case "MATERIALIZED_VIEW":
|
|
return "MATERIALIZED_VIEW";
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
interface SchemaDetailLoadContext {
|
|
connectionId: string;
|
|
database: string;
|
|
schema: string;
|
|
dbType: string;
|
|
options: SchemaDiffCompareOptions;
|
|
}
|
|
|
|
function shouldLoadIndexes(options: SchemaDiffCompareOptions): boolean {
|
|
return options.indexes || options.primaryKeys || options.uniqueKeys;
|
|
}
|
|
|
|
async function loadSchemaDetails(tables: TableInfo[], context: SchemaDetailLoadContext): Promise<TableSchemaDetail[]> {
|
|
const concurrency = schemaDiffMetadataConcurrency(context.dbType, tables.length);
|
|
const runMetadataQuery = createConcurrencyLimiter(concurrency);
|
|
|
|
return mapWithConcurrency(tables, concurrency, async (table) => {
|
|
const objectType = isViewOrMaterializedView(table.table_type);
|
|
const shouldFetchDdl = shouldFetchSchemaDiffDdl(Boolean(objectType), context.options);
|
|
const ddlPromise = shouldFetchDdl ? runMetadataQuery(() => api.getTableDdl(context.connectionId, context.database, context.schema, table.name, objectType)) : Promise.resolve("");
|
|
const [columns, indexes, foreignKeys, triggers, ddl] = await Promise.all([
|
|
runMetadataQuery(() => api.getColumns(context.connectionId, context.database, context.schema, table.name)),
|
|
shouldLoadIndexes(context.options) ? runMetadataQuery(() => api.listIndexes(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
|
|
context.options.foreignKeys ? runMetadataQuery(() => api.listForeignKeys(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
|
|
context.options.triggers ? runMetadataQuery(() => api.listTriggers(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
|
|
ddlPromise,
|
|
]);
|
|
|
|
return { name: table.name, columns, indexes, foreignKeys, triggers, ddl };
|
|
});
|
|
}
|
|
|
|
async function handleCompare() {
|
|
loading.value = true;
|
|
step.value = "compare";
|
|
|
|
try {
|
|
const sourceConfig = store.getConfig(sourceConnectionId.value);
|
|
const targetConfig = store.getConfig(targetConnectionId.value);
|
|
const dbType = targetConfig?.db_type || "mysql";
|
|
const sourceDbType = sourceConfig?.db_type || dbType;
|
|
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
|
|
const tableFilter = compileSchemaDiffTableFilter(opts);
|
|
|
|
await store.ensureConnected(sourceConnectionId.value);
|
|
await store.ensureConnected(targetConnectionId.value);
|
|
|
|
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
|
|
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter);
|
|
|
|
const sourceDetails = await loadSchemaDetails(sourceTables, {
|
|
connectionId: sourceConnectionId.value,
|
|
database: sourceDatabase.value,
|
|
schema: sourceSchema.value,
|
|
dbType: sourceDbType,
|
|
options: opts,
|
|
});
|
|
|
|
const targetDetails = await loadSchemaDetails(targetTables, {
|
|
connectionId: targetConnectionId.value,
|
|
database: targetDatabase.value,
|
|
schema: targetSchema.value,
|
|
dbType,
|
|
options: opts,
|
|
});
|
|
|
|
const isPostgresLike = dbType === "postgres" || dbType === "opengauss";
|
|
|
|
// Fetch new object types for PostgreSQL-like databases
|
|
const promises: Promise<any>[] = [];
|
|
if (isPostgresLike && opts?.functions) {
|
|
promises.push(api.listFunctions(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
|
promises.push(api.listFunctions(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
|
}
|
|
if (isPostgresLike && opts?.sequences) {
|
|
promises.push(api.listSequences(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, !!opts?.sequenceLastValues));
|
|
promises.push(api.listSequences(targetConnectionId.value, targetDatabase.value, targetSchema.value, !!opts?.sequenceLastValues));
|
|
}
|
|
if (isPostgresLike && opts?.rules) {
|
|
promises.push(api.listRules(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
|
promises.push(api.listRules(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
|
}
|
|
if (isPostgresLike && opts?.owners) {
|
|
promises.push(api.listOwners(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
|
|
promises.push(api.listOwners(targetConnectionId.value, targetDatabase.value, targetSchema.value));
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
let idx = 0;
|
|
const srcFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
|
|
const tgtFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
|
|
const srcSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
|
|
const tgtSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
|
|
const srcRules = opts?.rules && isPostgresLike ? results[idx++] : [];
|
|
const tgtRules = opts?.rules && isPostgresLike ? results[idx++] : [];
|
|
const srcOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
|
|
const tgtOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
|
|
|
|
const result = await api.prepareSchemaDiff({
|
|
sourceTables,
|
|
targetTables,
|
|
sourceDetails,
|
|
targetDetails,
|
|
sourceFunctions: srcFunctions,
|
|
targetFunctions: tgtFunctions,
|
|
sourceSequences: srcSequences,
|
|
targetSequences: tgtSequences,
|
|
sourceRules: srcRules,
|
|
targetRules: tgtRules,
|
|
sourceOwners: srcOwners,
|
|
targetOwners: tgtOwners,
|
|
databaseType: dbType,
|
|
targetSchema: schemaDiffDeployTargetSchema(dbType, targetDatabase.value, targetSchema.value),
|
|
ignoreComments: ignoreComments.value,
|
|
cascadeDelete: opts?.cascadeDelete ?? false,
|
|
compareColumnOrder: opts.compareColumnOrder,
|
|
});
|
|
|
|
// Convert to unified objects
|
|
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs);
|
|
|
|
// Group by operation type and object kind
|
|
diffGroups.value = groupDiffObjects(diffObjects.value);
|
|
|
|
// Save full result and generate deploy SQL
|
|
lastDiffResult.value = result;
|
|
deploySqlAll.value = result.syncSql;
|
|
regenerateDeploySql();
|
|
|
|
step.value = "result";
|
|
} catch (e: any) {
|
|
toast(e?.message || String(e), 5000);
|
|
step.value = "config";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function handleToggleGroup(operationType: DiffOperationType) {
|
|
diffGroups.value = diffGroups.value.map((g) => (g.operationType === operationType ? { ...g, expanded: !g.expanded } : g));
|
|
}
|
|
|
|
function handleToggleTypeGroup(operationType: DiffOperationType, kind: DiffObjectKind) {
|
|
diffGroups.value = diffGroups.value.map((g) => {
|
|
if (g.operationType !== operationType) return g;
|
|
return {
|
|
...g,
|
|
typeGroups: g.typeGroups.map((tg) => (tg.kind === kind ? { ...tg, expanded: !tg.expanded } : tg)),
|
|
};
|
|
});
|
|
}
|
|
|
|
function handleToggleGroupSelection(operationType: DiffOperationType, selected: boolean) {
|
|
diffGroups.value = diffGroups.value.map((g) => {
|
|
if (g.operationType !== operationType) return g;
|
|
return {
|
|
...g,
|
|
selectedCount: selected ? g.count : 0,
|
|
typeGroups: g.typeGroups.map((tg) => {
|
|
for (const obj of tg.objects) {
|
|
obj.selected = selected;
|
|
}
|
|
return { ...tg, selectedCount: selected ? tg.objects.length : 0 };
|
|
}),
|
|
};
|
|
});
|
|
regenerateDeploySql();
|
|
}
|
|
|
|
function handleToggleTypeSelection(operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean) {
|
|
diffGroups.value = diffGroups.value.map((g) => {
|
|
if (g.operationType !== operationType) return g;
|
|
const newTypeGroups = g.typeGroups.map((tg) => {
|
|
if (tg.kind !== kind) return tg;
|
|
for (const obj of tg.objects) {
|
|
obj.selected = selected;
|
|
}
|
|
return { ...tg, selectedCount: selected ? tg.objects.length : 0 };
|
|
});
|
|
const newSelectedCount = newTypeGroups.reduce((sum, tg) => sum + tg.selectedCount, 0);
|
|
return { ...g, selectedCount: newSelectedCount, typeGroups: newTypeGroups };
|
|
});
|
|
regenerateDeploySql();
|
|
}
|
|
|
|
function handleToggleObjectSelection(objectId: string, selected: boolean) {
|
|
const obj = diffObjects.value.find((o) => o.id === objectId);
|
|
if (!obj) return;
|
|
|
|
obj.selected = selected;
|
|
|
|
// Update diffGroups with new references to trigger reactivity
|
|
diffGroups.value = diffGroups.value.map((g) => {
|
|
if (g.operationType !== obj.operationType) return g;
|
|
const newTypeGroups = g.typeGroups.map((tg) => {
|
|
if (tg.kind !== obj.objectKind) return tg;
|
|
const newSelectedCount = tg.objects.filter((o) => o.selected).length;
|
|
return { ...tg, selectedCount: newSelectedCount };
|
|
});
|
|
const newSelectedCount = newTypeGroups.reduce((sum, tg) => sum + tg.selectedCount, 0);
|
|
return { ...g, selectedCount: newSelectedCount, typeGroups: newTypeGroups };
|
|
});
|
|
|
|
regenerateDeploySql();
|
|
}
|
|
|
|
function regenerateDeploySql() {
|
|
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
|
|
}
|
|
|
|
async function handleExecuteScript() {
|
|
if (!deploySql.value || deploySql.value.startsWith("-- ")) {
|
|
toast(t("diff.noObjectsSelected"), 3000);
|
|
return;
|
|
}
|
|
|
|
executing.value = true;
|
|
try {
|
|
const result = await executeWithProductionSqlGuard({
|
|
connection: store.getConfig(targetConnectionId.value),
|
|
database: targetDatabase.value,
|
|
sql: deploySql.value,
|
|
source: t("production.sourceSchemaDiff"),
|
|
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
|
});
|
|
if (!result) return;
|
|
toast(t("diff.executeSuccess"), 3000);
|
|
} catch (e: any) {
|
|
toast(e?.message || String(e), 5000);
|
|
} finally {
|
|
executing.value = false;
|
|
}
|
|
}
|
|
async function handleSelectObject(obj: SchemaDiffObject) {
|
|
selectedObjectId.value = obj.id;
|
|
|
|
// Dynamically fetch DDL for objects that don't have pre-generated DDL
|
|
// (views need runtime retrieval; functions should already have definition)
|
|
const objectTypeMap: Record<string, ObjectSourceKind> = {
|
|
function: "FUNCTION",
|
|
view: "VIEW",
|
|
};
|
|
const objectType = objectTypeMap[obj.objectKind];
|
|
if (!objectType) return;
|
|
|
|
try {
|
|
// For "create" objects: source has it, target doesn't → fetch source DDL
|
|
if (obj.operationType === "create" && !obj.sourceDdl) {
|
|
const result = await api.getObjectSource(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name, objectType, obj.arguments);
|
|
if (result?.source) obj.sourceDdl = result.source;
|
|
}
|
|
|
|
// For "delete" objects: target has it, source doesn't → fetch target DDL
|
|
if (obj.operationType === "delete" && !obj.targetDdl) {
|
|
const result = await api.getObjectSource(targetConnectionId.value, targetDatabase.value, targetSchema.value, obj.name, objectType, obj.arguments);
|
|
if (result?.source) obj.targetDdl = result.source;
|
|
}
|
|
|
|
// For "modify" objects: fetch whichever side is missing
|
|
if (obj.operationType === "modify") {
|
|
if (!obj.sourceDdl) {
|
|
const result = await api.getObjectSource(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name, objectType, obj.arguments);
|
|
if (result?.source) obj.sourceDdl = result.source;
|
|
}
|
|
if (!obj.targetDdl) {
|
|
const result = await api.getObjectSource(targetConnectionId.value, targetDatabase.value, targetSchema.value, obj.name, objectType, obj.arguments);
|
|
if (result?.source) obj.targetDdl = result.source;
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently ignore errors
|
|
}
|
|
}
|
|
function handleLoadHistoryConfig(config: SchemaDiffConfig) {
|
|
sourceConnectionId.value = config.sourceConnectionId;
|
|
sourceDatabase.value = config.sourceDatabase;
|
|
sourceSchema.value = config.sourceSchema;
|
|
targetConnectionId.value = config.targetConnectionId;
|
|
targetDatabase.value = config.targetDatabase;
|
|
targetSchema.value = config.targetSchema;
|
|
if (config.options) {
|
|
updateActiveConfigOptions(config.options);
|
|
}
|
|
}
|
|
|
|
function handleSaveConfig() {
|
|
if (activeConfig.value) {
|
|
const name = window.prompt(t("diff.saveConfigPrompt") || "Please enter config name:", activeConfig.value.name || "Default");
|
|
if (name === null) return; // User cancelled
|
|
const configToSave = { ...activeConfig.value, name: name.trim() || "Default" };
|
|
saveToHistory(configToSave);
|
|
toast(t("diff.configSaved"), 2000);
|
|
}
|
|
}
|
|
|
|
function handleDeleteHistoryConfig(configId: string) {
|
|
deleteFromHistory(configId);
|
|
toast(t("diff.configDeleted"), 2000);
|
|
}
|
|
|
|
async function fetchDbVersion(connectionId: string, database: string, schema: string) {
|
|
try {
|
|
await store.ensureConnected(connectionId);
|
|
const config = store.getConfig(connectionId);
|
|
const dbType = config?.db_type;
|
|
let sql = "";
|
|
switch (dbType) {
|
|
case "postgres":
|
|
case "opengauss":
|
|
sql = "SELECT version()";
|
|
break;
|
|
case "mysql":
|
|
sql = "SELECT VERSION()";
|
|
break;
|
|
case "sqlite":
|
|
sql = "SELECT sqlite_version()";
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
|
|
if (result.rows && result.rows.length > 0) {
|
|
targetDbVersion.value = String(result.rows[0][0]);
|
|
} else {
|
|
console.warn("[fetchDbVersion] No rows returned");
|
|
}
|
|
} catch (e) {
|
|
console.error("[fetchDbVersion] Failed to fetch version:", e);
|
|
targetDbVersion.value = null;
|
|
}
|
|
}
|
|
|
|
function handleDeployReview() {
|
|
const selectedObjects = diffObjects.value.filter((o) => o.selected && o.operationType !== "none");
|
|
if (selectedObjects.length === 0) {
|
|
toast(t("diff.noObjectsSelected"), 3000);
|
|
return;
|
|
}
|
|
step.value = "deploy-review";
|
|
fetchDbVersion(targetConnectionId.value, targetDatabase.value, targetSchema.value);
|
|
}
|
|
|
|
async function handleDeploy() {
|
|
showConfirmDialog.value = true;
|
|
}
|
|
|
|
async function onConfirmDeploy() {
|
|
showConfirmDialog.value = false;
|
|
executing.value = true;
|
|
try {
|
|
const result = await executeWithProductionSqlGuard({
|
|
connection: store.getConfig(targetConnectionId.value),
|
|
database: targetDatabase.value,
|
|
sql: deploySql.value,
|
|
source: t("production.sourceSchemaDiff"),
|
|
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
|
});
|
|
if (!result) return;
|
|
deployResult.value = {
|
|
success: true,
|
|
message: t("diff.deploySuccess"),
|
|
affectedRows: result.affected_rows,
|
|
};
|
|
showResultDialog.value = true;
|
|
} catch (e: any) {
|
|
deployResult.value = {
|
|
success: false,
|
|
message: e?.message || String(e),
|
|
};
|
|
showResultDialog.value = true;
|
|
} finally {
|
|
executing.value = false;
|
|
}
|
|
}
|
|
|
|
const deployStats = computed(() => {
|
|
const selected = diffObjects.value.filter((o) => o.selected && o.operationType !== "none");
|
|
const isTopLevel = (o: SchemaDiffObject) => !o.id.startsWith("col-") && !o.id.startsWith("idx-") && !o.id.startsWith("fk-") && !o.id.startsWith("trg-");
|
|
const topLevel = selected.filter(isTopLevel);
|
|
return {
|
|
create: topLevel.filter((o) => o.operationType === "create").length,
|
|
modify: topLevel.filter((o) => o.operationType === "modify").length,
|
|
delete: topLevel.filter((o) => o.operationType === "delete").length,
|
|
total: topLevel.length,
|
|
};
|
|
});
|
|
|
|
const targetConnectionInfo = computed(() => {
|
|
const config = store.getConfig(targetConnectionId.value);
|
|
if (!config) return null;
|
|
return {
|
|
host: config.host || "-",
|
|
port: config.port || "-",
|
|
dbType: config.db_type || "-",
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogContent :class="['flex flex-col overflow-hidden', isMaximized ? 'min-w-0' : 'min-w-[800px] resize']" :portal-class="isMaximized ? 'p-0' : undefined" :style="dialogStyle" @interact-outside.prevent @escape-key-down="handleDialogEscape">
|
|
<Button variant="ghost" size="icon-sm" class="absolute top-2 right-10 z-10" @click="toggleMaximize">
|
|
<Maximize2 v-if="!isMaximized" class="w-4 h-4" />
|
|
<Minimize2 v-else class="w-4 h-4" />
|
|
<span class="sr-only">{{ isMaximized ? t("diff.restore") : t("diff.maximize") }}</span>
|
|
</Button>
|
|
|
|
<DialogHeader>
|
|
<DialogTitle class="flex items-center gap-2">
|
|
<GitCompareArrows class="w-4 h-4" />
|
|
{{ t("diff.title") }}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
<!-- Config Step -->
|
|
<SchemaDiffConfigStep
|
|
v-if="step === 'config'"
|
|
v-model:source-connection-id="sourceConnectionId"
|
|
v-model:source-database="sourceDatabase"
|
|
v-model:source-schema="sourceSchema"
|
|
v-model:target-connection-id="targetConnectionId"
|
|
v-model:target-database="targetDatabase"
|
|
v-model:target-schema="targetSchema"
|
|
v-model:ignore-comments="ignoreComments"
|
|
:configs="configs"
|
|
:active-config-id="activeConfigId"
|
|
:options="activeConfig?.options"
|
|
:loading="loading"
|
|
:recent-configs="recentConfigs"
|
|
@compare="handleCompare"
|
|
@swap="handleSwap"
|
|
@show-options="showOptionsPanel = true"
|
|
@save-config="handleSaveConfig"
|
|
@load-history-config="handleLoadHistoryConfig"
|
|
@delete-history-config="handleDeleteHistoryConfig"
|
|
/>
|
|
|
|
<!-- Compare Loading -->
|
|
<div v-else-if="step === 'compare'" class="flex items-center justify-center py-20">
|
|
<Loader2 class="w-6 h-6 animate-spin mr-2" />
|
|
<span class="text-sm text-muted-foreground">{{ t("diff.comparing") }}</span>
|
|
</div>
|
|
|
|
<!-- Result Step -->
|
|
<template v-else-if="step === 'result'">
|
|
<Splitpanes horizontal class="flex-1 min-h-0" @resized="handleSplitpanesResized">
|
|
<Pane :size="splitpanesSize" min-size="20">
|
|
<div class="h-full overflow-auto">
|
|
<SchemaDiffObjectTree
|
|
:groups="diffGroups"
|
|
:selected-object-id="selectedObject?.id ?? null"
|
|
@toggle-group="handleToggleGroup"
|
|
@toggle-type-group="handleToggleTypeGroup"
|
|
@toggle-group-selection="handleToggleGroupSelection"
|
|
@toggle-type-selection="handleToggleTypeSelection"
|
|
@toggle-object-selection="handleToggleObjectSelection"
|
|
@select-object="handleSelectObject"
|
|
/>
|
|
</div>
|
|
</Pane>
|
|
<Pane :size="100 - splitpanesSize" min-size="20">
|
|
<SchemaDiffDdlPanel :selected-object="selectedObject" :deploy-sql="deploySql" :deploy-sql-all="deploySqlAll" @execute-script="handleExecuteScript" />
|
|
</Pane>
|
|
</Splitpanes>
|
|
</template>
|
|
|
|
<!-- Deploy Review Step -->
|
|
<template v-else-if="step === 'deploy-review'">
|
|
<SchemaDiffDeployStep v-model:deploy-sql="deploySql" :selected-objects="diffObjects" :target-connection-id="targetConnectionId" :target-database="targetDatabase" :target-schema="targetSchema" :executing="executing" @back="step = 'result'" @deploy="handleDeploy" />
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<DialogFooter class="flex items-center justify-between">
|
|
<div v-if="step === 'result'" class="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" @click="step = 'config'">
|
|
<ArrowLeft class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.prevStep") }}
|
|
</Button>
|
|
<Button variant="outline" size="sm" :disabled="loading" @click="handleCompare">
|
|
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.recompare") }}
|
|
</Button>
|
|
</div>
|
|
<div v-else></div>
|
|
|
|
<div v-if="step === 'result'" class="flex items-center gap-2">
|
|
<Button size="sm" :disabled="!canDeploy || executing" @click="handleDeployReview">
|
|
<Loader2 v-if="executing" class="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
<Play v-else class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.nextStepDeploy") }}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
|
|
<!-- Deploy Confirm Dialog -->
|
|
<Dialog v-model:open="showConfirmDialog">
|
|
<DialogContent class="sm:max-w-[520px]">
|
|
<DialogHeader>
|
|
<DialogTitle class="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle class="h-5 w-5" />
|
|
{{ t("diff.deployConfirmTitle") }}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="py-2 space-y-3">
|
|
<p class="text-sm text-muted-foreground">{{ t("diff.deployConfirmMessage") }}</p>
|
|
|
|
<div class="bg-muted p-3 rounded text-xs font-mono space-y-1">
|
|
<div v-if="targetConnectionInfo">
|
|
{{ t("diff.targetServer") }}: {{ targetConnectionInfo.host }}:{{ targetConnectionInfo.port }}
|
|
<span class="text-muted-foreground">({{ targetConnectionInfo.dbType }})</span>
|
|
</div>
|
|
<div v-if="targetDbVersion">{{ t("diff.dbVersion") }}: {{ targetDbVersion }}</div>
|
|
<div>
|
|
{{ t("diff.targetDatabase") }}:
|
|
<span class="text-primary font-bold">{{ targetDatabase }}</span>
|
|
</div>
|
|
<div>
|
|
{{ t("diff.targetSchema") }}:
|
|
<span class="text-primary font-bold">{{ targetSchema || "-" }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-4 text-sm">
|
|
<span class="text-green-600">{{ t("diff.create") }}: {{ deployStats.create }}</span>
|
|
<span class="text-blue-600">{{ t("diff.modify") }}: {{ deployStats.modify }}</span>
|
|
<span class="text-red-600">{{ t("diff.delete") }}: {{ deployStats.delete }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="showConfirmDialog = false">{{ t("diff.cancel") }}</Button>
|
|
<Button variant="destructive" :disabled="executing" @click="onConfirmDeploy">
|
|
<Loader2 v-if="executing" class="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
{{ t("diff.confirmDeploy") }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Deploy Result Dialog -->
|
|
<Dialog v-model:open="showResultDialog">
|
|
<DialogContent class="sm:max-w-[480px]">
|
|
<DialogHeader>
|
|
<DialogTitle class="flex items-center gap-2" :class="deployResult?.success ? 'text-green-500' : 'text-destructive'">
|
|
<AlertTriangle v-if="!deployResult?.success" class="h-5 w-5" />
|
|
<CircleCheck v-else class="h-5 w-5" />
|
|
{{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="py-2">
|
|
<div v-if="deployResult?.success" class="space-y-2">
|
|
<p class="text-sm text-muted-foreground">{{ t("diff.deploySuccessMessage") }}</p>
|
|
<div class="bg-muted p-3 rounded text-xs font-mono">
|
|
<div>{{ t("diff.affectedRows") }}: {{ deployResult.affectedRows ?? 0 }}</div>
|
|
<div>{{ t("diff.executedStatements") }}: {{ deployStats.total }}</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="space-y-2">
|
|
<p class="text-sm text-muted-foreground">{{ t("diff.deployFailedMessage") }}</p>
|
|
<pre class="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ deployResult?.message }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="showResultDialog = false">{{ t("diff.close") }}</Button>
|
|
<Button
|
|
v-if="deployResult?.success"
|
|
@click="
|
|
showResultDialog = false;
|
|
step = 'result';
|
|
"
|
|
>
|
|
{{ t("diff.backToResult") }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Options Panel Overlay -->
|
|
<div v-if="showOptionsPanel" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="showOptionsPanel = false">
|
|
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
|
|
<Button variant="ghost" size="sm" @click="showOptionsPanel = false">✕</Button>
|
|
</div>
|
|
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.splitpanes--horizontal > .splitpanes__splitter) {
|
|
height: 8px;
|
|
background: var(--border);
|
|
cursor: row-resize;
|
|
}
|
|
:deep(.splitpanes--horizontal > .splitpanes__splitter:hover) {
|
|
background: var(--primary);
|
|
}
|
|
</style>
|