feat(schema-diff): compare routines across same-structure databases
This commit is contained in:
1 parent
bd2ac50ac1
commit
1191118b5c
31 files changed
+2256
-281
No files matched your search
@@ -3,19 +3,34 @@ import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { buildSchemaDiffTableMatches, availableSchemaDiffTargetTables, areSchemaDiffTableMappingsEqual, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, updateSchemaDiffTableMapping, type SchemaDiffTableMatch } from "@/lib/schema/schemaDiffTableMapping";
|
||||
import {
|
||||
areSchemaDiffRoutineMappingsEqual,
|
||||
buildSchemaDiffRoutineMatches,
|
||||
identitySchemaDiffRoutineMappings,
|
||||
isSchemaDiffUnrestrictedRoutineLoadTooLarge,
|
||||
pruneSchemaDiffRoutineMappings,
|
||||
reconcileSchemaDiffRoutineMappings,
|
||||
reconcileSchemaDiffSelectedRoutines,
|
||||
schemaDiffRoutineKey,
|
||||
SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT,
|
||||
type SchemaDiffRoutineMatch,
|
||||
} from "@/lib/schema/schemaDiffRoutine";
|
||||
import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
|
||||
import { schemaDiffRoutineObjectTypes, schemaDiffRoutineObjectTypesIntersection, supportsSchemaDiffRoutines } from "@/lib/database/databaseObjectCapabilities";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
@@ -32,6 +47,7 @@ const props = defineProps<{
|
||||
ignoreComments: boolean;
|
||||
options: SchemaDiffCompareOptions;
|
||||
selectedTables?: string[];
|
||||
selectedRoutines?: string[];
|
||||
tableListLoader: SchemaDiffTableListLoader;
|
||||
loading: boolean;
|
||||
recentConfigs: SchemaDiffConfig[];
|
||||
@@ -47,6 +63,7 @@ const emit = defineEmits<{
|
||||
(e: "update:ignoreComments", value: boolean): void;
|
||||
(e: "update:fieldMappings", value: FieldMappingEntry[]): void;
|
||||
(e: "update:tableMappings", value: SchemaDiffTableMapping[]): void;
|
||||
(e: "update:routineMappings", value: SchemaDiffRoutineMapping[]): void;
|
||||
(e: "open-field-mapping"): void;
|
||||
(e: "compare"): void;
|
||||
(e: "saveConfig"): void;
|
||||
@@ -56,6 +73,8 @@ const emit = defineEmits<{
|
||||
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
|
||||
(e: "deleteHistoryConfig", configId: string): void;
|
||||
(e: "update:selectedTables", value?: string[]): void;
|
||||
(e: "update:selectedRoutines", value?: string[]): void;
|
||||
(e: "update:compareScope", value: { tables?: boolean; views?: boolean; functions?: boolean }): void;
|
||||
}>();
|
||||
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
@@ -206,32 +225,232 @@ const tableListCoordinator = createSchemaDiffTableListCoordinator({
|
||||
onTargetTablesLoaded: reconcileTargetTablesAfterSuccessfulLoad,
|
||||
});
|
||||
|
||||
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
|
||||
const tablesCompareEnabled = computed(() => !!props.options.tables);
|
||||
const routinesCompareEnabled = computed(() => !!props.options.functions);
|
||||
const canEnableRoutinesCompare = computed(() => schemaDiffRoutineObjectTypesIntersection(sourceDbType.value as DatabaseType | undefined, targetDbType.value as DatabaseType | undefined).length > 0);
|
||||
/** User preference AND supported pair — drives compare/canCompare/Switch display. */
|
||||
const effectiveRoutinesEnabled = computed(() => routinesCompareEnabled.value && canEnableRoutinesCompare.value);
|
||||
|
||||
watch(
|
||||
() => [restrictTables.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value],
|
||||
() => [tablesCompareEnabled.value, restrictTables.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value],
|
||||
() => {
|
||||
const shouldLoad = shouldLoadSchemaDiffTableList("source", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("source"));
|
||||
const shouldLoad = tablesCompareEnabled.value && shouldLoadSchemaDiffTableList("source", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("source"));
|
||||
tableListCoordinator.refresh("source", shouldLoad).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [restrictTables.value, localSelectedTables.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value],
|
||||
() => [tablesCompareEnabled.value, restrictTables.value, localSelectedTables.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value],
|
||||
() => {
|
||||
const shouldLoad = shouldLoadSchemaDiffTableList("target", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("target"));
|
||||
const shouldLoad = tablesCompareEnabled.value && shouldLoadSchemaDiffTableList("target", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("target"));
|
||||
tableListCoordinator.refresh("target", shouldLoad).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
|
||||
// ---- Visual (explicit) routine selection ----
|
||||
const sourceRoutineList = ref<string[]>([]);
|
||||
const targetRoutineList = ref<string[]>([]);
|
||||
const targetRoutineListLoaded = ref(false);
|
||||
const restrictRoutines = ref(false);
|
||||
const localSelectedRoutines = ref<string[]>([]);
|
||||
const loadingRoutines = ref(false);
|
||||
|
||||
const canConfigureRoutineSelection = computed(() => isTableIdentityReady("source") && supportsSchemaDiffRoutines(sourceDbType.value as DatabaseType));
|
||||
const activeRoutineMappings = computed(() => identitySchemaDiffRoutineMappings(props.options?.routineMappings ?? []));
|
||||
const routineMatches = computed<SchemaDiffRoutineMatch[]>(() => buildSchemaDiffRoutineMatches(localSelectedRoutines.value, targetRoutineList.value, activeRoutineMappings.value));
|
||||
const matchedRoutineCount = computed(() => routineMatches.value.filter((match) => match.targetRoutine).length);
|
||||
const missingTargetRoutines = computed(() => routineMatches.value.filter((match) => !match.targetRoutine).map((match) => match.sourceRoutine));
|
||||
|
||||
function emitRoutineMappings(nextMappings: SchemaDiffRoutineMapping[]) {
|
||||
const identityOnly = identitySchemaDiffRoutineMappings(nextMappings);
|
||||
if (!areSchemaDiffRoutineMappingsEqual(identityOnly, activeRoutineMappings.value)) emit("update:routineMappings", identityOnly);
|
||||
}
|
||||
|
||||
function reconcileRoutineMappings(selectedRoutines: string[], targetRoutines = targetRoutineList.value) {
|
||||
const nextMappings = targetRoutineListLoaded.value ? reconcileSchemaDiffRoutineMappings(selectedRoutines, targetRoutines, activeRoutineMappings.value) : pruneSchemaDiffRoutineMappings(selectedRoutines, activeRoutineMappings.value);
|
||||
emitRoutineMappings(nextMappings);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedRoutines,
|
||||
(value) => {
|
||||
restrictRoutines.value = Array.isArray(value);
|
||||
localSelectedRoutines.value = value && Array.isArray(value) ? [...value] : [];
|
||||
if (!restrictRoutines.value) emitRoutineMappings([]);
|
||||
else if (targetRoutineListLoaded.value) reconcileRoutineMappings(localSelectedRoutines.value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.options?.routineMappings,
|
||||
() => {
|
||||
if (restrictRoutines.value && targetRoutineListLoaded.value) reconcileRoutineMappings(localSelectedRoutines.value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function handleToggleRestrictRoutines(enabled: boolean) {
|
||||
restrictRoutines.value = enabled;
|
||||
emit("update:selectedRoutines", enabled ? [...localSelectedRoutines.value] : undefined);
|
||||
if (!enabled) emitRoutineMappings([]);
|
||||
else reconcileRoutineMappings(localSelectedRoutines.value);
|
||||
}
|
||||
|
||||
function handleUpdateSelectedRoutines(value: string[]) {
|
||||
localSelectedRoutines.value = value;
|
||||
if (!restrictRoutines.value) return;
|
||||
emit("update:selectedRoutines", [...value]);
|
||||
reconcileRoutineMappings(value);
|
||||
}
|
||||
|
||||
function clearUnavailableRoutineSelection() {
|
||||
sourceRoutineList.value = [];
|
||||
targetRoutineList.value = [];
|
||||
targetRoutineListLoaded.value = false;
|
||||
if (props.selectedRoutines === undefined && !restrictRoutines.value && localSelectedRoutines.value.length === 0) {
|
||||
emitRoutineMappings([]);
|
||||
return;
|
||||
}
|
||||
restrictRoutines.value = false;
|
||||
localSelectedRoutines.value = [];
|
||||
emit("update:selectedRoutines", undefined);
|
||||
emitRoutineMappings([]);
|
||||
}
|
||||
|
||||
async function loadRoutineList(side: "source" | "target") {
|
||||
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
||||
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
||||
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
||||
const dbType = side === "source" ? sourceDbType.value : targetDbType.value;
|
||||
if (!connectionId || !database || !supportsSchemaDiffRoutines(dbType as DatabaseType)) {
|
||||
if (side === "source") sourceRoutineList.value = [];
|
||||
else {
|
||||
targetRoutineList.value = [];
|
||||
targetRoutineListLoaded.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loadingRoutines.value = true;
|
||||
await store.ensureConnected(connectionId);
|
||||
// Names/signatures only — avoid listFunctions N+1 source fetch on the config step.
|
||||
const kinds = schemaDiffRoutineObjectTypes(dbType as DatabaseType);
|
||||
const objects = kinds.length === 0 ? [] : await api.listObjects(connectionId, database, schema, kinds);
|
||||
const keys = objects.map((object) => schemaDiffRoutineKey(object.name, object.signature ?? ""));
|
||||
if (side === "source") {
|
||||
sourceRoutineList.value = keys;
|
||||
if (restrictRoutines.value) {
|
||||
const next = reconcileSchemaDiffSelectedRoutines(localSelectedRoutines.value, keys);
|
||||
if (next.length !== localSelectedRoutines.value.length || next.some((key, index) => key !== localSelectedRoutines.value[index])) {
|
||||
localSelectedRoutines.value = next;
|
||||
emit("update:selectedRoutines", [...next]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetRoutineList.value = keys;
|
||||
targetRoutineListLoaded.value = true;
|
||||
if (restrictRoutines.value) reconcileRoutineMappings(localSelectedRoutines.value, keys);
|
||||
}
|
||||
} catch {
|
||||
if (side === "source") sourceRoutineList.value = [];
|
||||
else {
|
||||
targetRoutineList.value = [];
|
||||
targetRoutineListLoaded.value = false;
|
||||
}
|
||||
} finally {
|
||||
loadingRoutines.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [restrictRoutines.value, effectiveRoutinesEnabled.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value] as const,
|
||||
([restrictEnabled, routinesEnabled]) => {
|
||||
if (!canConfigureRoutineSelection.value) {
|
||||
clearUnavailableRoutineSelection();
|
||||
return;
|
||||
}
|
||||
// Load names whenever routines are effectively on (count guard + restrict picker).
|
||||
if (routinesEnabled || restrictEnabled) void loadRoutineList("source");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [restrictRoutines.value, localSelectedRoutines.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value] as const,
|
||||
([enabled, selectedCount]) => {
|
||||
if (!enabled || selectedCount === 0 || !isTableIdentityReady("target") || !supportsSchemaDiffRoutines(targetDbType.value as DatabaseType)) {
|
||||
if (!enabled) {
|
||||
targetRoutineList.value = [];
|
||||
targetRoutineListLoaded.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
void loadRoutineList("target");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
source: [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] as const,
|
||||
selectedRoutines: props.selectedRoutines,
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (!isTableIdentityReady("source") || !supportsSchemaDiffRoutines(sourceDbType.value as DatabaseType)) {
|
||||
clearUnavailableRoutineSelection();
|
||||
return;
|
||||
}
|
||||
if (!previous) return;
|
||||
if (current.source.every((value, index) => value === previous.source[index])) return;
|
||||
if (current.selectedRoutines !== previous.selectedRoutines) return;
|
||||
if (current.selectedRoutines === undefined && !restrictRoutines.value) return;
|
||||
clearUnavailableRoutineSelection();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleTablesCompareEnabled(enabled: boolean) {
|
||||
if (enabled) emit("update:compareScope", { tables: true });
|
||||
else emit("update:compareScope", { tables: false, views: false });
|
||||
}
|
||||
|
||||
function handleRoutinesCompareEnabled(enabled: boolean) {
|
||||
if (enabled && !canEnableRoutinesCompare.value) return;
|
||||
emit("update:compareScope", { functions: enabled });
|
||||
}
|
||||
|
||||
const unrestrictedRoutineLoadTooLarge = computed(() => {
|
||||
if (!effectiveRoutinesEnabled.value || restrictRoutines.value) return false;
|
||||
return isSchemaDiffUnrestrictedRoutineLoadTooLarge(sourceRoutineList.value.length, props.selectedRoutines);
|
||||
});
|
||||
|
||||
const canCompare = computed(() => {
|
||||
const hasSelectedTables = props.selectedTables === undefined || props.selectedTables.length > 0;
|
||||
const tablesEnabled = tablesCompareEnabled.value;
|
||||
const routinesEnabled = effectiveRoutinesEnabled.value;
|
||||
if (!tablesEnabled && !routinesEnabled) return false;
|
||||
if (unrestrictedRoutineLoadTooLarge.value) return false;
|
||||
const hasSelectedTables = !tablesEnabled || props.selectedTables === undefined || props.selectedTables.length > 0;
|
||||
const hasSelectedRoutines = !routinesEnabled || props.selectedRoutines === undefined || props.selectedRoutines.length > 0;
|
||||
// Unmapped source tables are not a blocker: they flow through the comparison as
|
||||
// source-only tables (CREATE TABLE in the target), matching the pre-mapping behavior.
|
||||
const hasValidTableMappings = !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target");
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && hasValidTableMappings && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
const hasValidTableMappings = !tablesEnabled || !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target");
|
||||
const hasValidRoutineMappings = !routinesEnabled || !restrictRoutines.value || localSelectedRoutines.value.length === 0 || isTableIdentityReady("target");
|
||||
return (
|
||||
props.sourceConnectionId &&
|
||||
props.targetConnectionId &&
|
||||
props.sourceDatabase &&
|
||||
props.targetDatabase &&
|
||||
hasSelectedTables &&
|
||||
hasSelectedRoutines &&
|
||||
hasValidTableMappings &&
|
||||
hasValidRoutineMappings &&
|
||||
(!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) &&
|
||||
(!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema)
|
||||
);
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
@@ -599,28 +818,36 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<div class="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.tableSelection") }}</Label>
|
||||
<Button v-if="restrictTables || canConfigureTableSelection" variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="handleToggleRestrict(!restrictTables)">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Switch size="sm" :model-value="tablesCompareEnabled" :aria-label="t('diff.compareTablesSwitch')" @update:model-value="handleTablesCompareEnabled(Boolean($event))" />
|
||||
<Label class="text-xs font-medium">{{ t("diff.tableSelection") }}</Label>
|
||||
</div>
|
||||
<Button v-if="tablesCompareEnabled && (restrictTables || canConfigureTableSelection)" variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="handleToggleRestrict(!restrictTables)">
|
||||
{{ restrictTables ? t("diff.compareAllTables") : t("diff.chooseTables") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.tableSelectionUnrestricted") }}
|
||||
<div v-if="!tablesCompareEnabled" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.tableCompareDisabled") }}
|
||||
</div>
|
||||
<TableMultiSelect
|
||||
v-if="restrictTables && canConfigureTableSelection"
|
||||
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
|
||||
:model-value="localSelectedTables"
|
||||
@update:model-value="handleUpdateSelectedTables"
|
||||
:tables="sourceTableList"
|
||||
:title="t('diff.sourceTables')"
|
||||
:empty-text="t('dataCompare.noTables')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.tableSelectionUnrestricted") }}
|
||||
</div>
|
||||
<TableMultiSelect
|
||||
v-if="restrictTables && canConfigureTableSelection"
|
||||
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
|
||||
:model-value="localSelectedTables"
|
||||
@update:model-value="handleUpdateSelectedTables"
|
||||
:tables="sourceTableList"
|
||||
:title="t('diff.sourceTables')"
|
||||
:empty-text="t('dataCompare.noTables')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Target Same-Name Match -->
|
||||
<!-- Source → target table mapping -->
|
||||
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-2 rounded-lg border p-3 text-xs">
|
||||
<div v-if="tablesCompareEnabled && restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-2 rounded-lg border p-3 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-medium">{{ t("diff.targetTableMatching") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
@@ -668,6 +895,77 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
{{ t("diff.targetTableInUse") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 border-t border-border/60 pt-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Switch size="sm" :model-value="effectiveRoutinesEnabled" :disabled="!canEnableRoutinesCompare" :aria-label="t('diff.compareRoutinesSwitch')" @update:model-value="handleRoutinesCompareEnabled(Boolean($event))" />
|
||||
<Label class="text-xs font-medium" :class="!canEnableRoutinesCompare ? 'text-muted-foreground' : ''">{{ t("diff.routineSelection") }}</Label>
|
||||
</div>
|
||||
<Button v-if="effectiveRoutinesEnabled && (restrictRoutines || canConfigureRoutineSelection)" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="loadingRoutines" @click="handleToggleRestrictRoutines(!restrictRoutines)">
|
||||
{{ restrictRoutines ? t("diff.compareAllRoutines") : t("diff.chooseRoutines") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!canEnableRoutinesCompare" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.routineSelectionUnsupported") }}
|
||||
</div>
|
||||
<div v-else-if="!routinesCompareEnabled" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.routineCompareDisabled") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!restrictRoutines" class="text-[11px] text-muted-foreground">
|
||||
{{ !isTableIdentityReady("source") ? t("diff.routineSelectionNeedSource") : t("diff.routineSelectionUnrestricted") }}
|
||||
</div>
|
||||
<div v-if="unrestrictedRoutineLoadTooLarge" class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
{{ t("diff.routineSelectionTooLarge", { count: sourceRoutineList.length, limit: SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT }) }}
|
||||
</div>
|
||||
<TableMultiSelect
|
||||
v-if="restrictRoutines && canConfigureRoutineSelection"
|
||||
:key="`routines-${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
|
||||
:model-value="localSelectedRoutines"
|
||||
@update:model-value="handleUpdateSelectedRoutines"
|
||||
:tables="sourceRoutineList"
|
||||
:title="t('diff.sourceRoutines')"
|
||||
:empty-text="t('diff.noRoutines')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveRoutinesEnabled && restrictRoutines && localSelectedRoutines.length && canConfigureRoutineSelection && isTableIdentityReady('target')" class="space-y-2 rounded-lg border p-3 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-medium">{{ t("diff.targetRoutineMatching") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ t("diff.matchedRoutines", { matched: matchedRoutineCount, total: localSelectedRoutines.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("diff.routineSameNameMatchingOnly") }}</p>
|
||||
<div class="overflow-x-auto rounded border">
|
||||
<table class="w-full min-w-[520px]">
|
||||
<thead class="border-b bg-muted/30 text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceRoutines") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetRoutine") }}</th>
|
||||
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="match in routineMatches" :key="match.sourceRoutine" class="border-b last:border-b-0">
|
||||
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.sourceRoutine">{{ match.sourceRoutine }}</td>
|
||||
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.targetRoutine || undefined">
|
||||
<span v-if="match.targetRoutine">{{ match.targetRoutine }}</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="targetRoutineListLoaded && missingTargetRoutines.length" class="text-muted-foreground">
|
||||
{{ t("diff.unmatchedRoutineWarning", { count: missingTargetRoutines.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount } from "vue";
|
||||
import { computed, ref, watch, onBeforeUnmount, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,20 +8,28 @@ import { useToast } from "@/composables/useToast";
|
||||
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck, ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
|
||||
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
||||
import SchemaDiffRoutineList from "@/components/diff/SchemaDiffRoutineList.vue";
|
||||
import SchemaDiffDdlPanel from "@/components/diff/SchemaDiffDdlPanel.vue";
|
||||
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
|
||||
import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue";
|
||||
import SideBySideTextDiff, { type TextDiffSide } from "@/components/common/SideBySideTextDiff.vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
|
||||
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
|
||||
import { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
|
||||
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjectPhase, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
|
||||
import { createSchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
|
||||
import { countSchemaDiffActionableObjects, partitionSchemaDiffObjectsByResultTab, swapSchemaDiffRoutineMappings } from "@/lib/schema/schemaDiffRoutine";
|
||||
import { schemaDiffRoutineObjectTypesIntersection } from "@/lib/database/databaseObjectCapabilities";
|
||||
import { loadObjectSourceWithRoutineFallback } from "@/lib/table/objectSourceLoad";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
|
||||
import { getSchemaDiffSession, schemaDiffSessionObjects, startSchemaDiffSession, type SchemaDiffSession } from "@/composables/useSchemaDiffSession";
|
||||
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
|
||||
import {
|
||||
@@ -57,6 +65,8 @@ import { swapSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
type SchemaDiffResultTab = "tables" | "routines";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
@@ -70,6 +80,8 @@ const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillSelectedRoutines?: string[];
|
||||
prefillResultTab?: "tables" | "routines";
|
||||
sessionId?: string | null;
|
||||
}>();
|
||||
|
||||
@@ -122,8 +134,19 @@ let appliedSessionResult: SchemaDiffPreparation | null = null;
|
||||
let shownSessionError = "";
|
||||
let componentUnmounted = false;
|
||||
const diffObjects = ref<SchemaDiffObject[]>([]);
|
||||
const diffGroups = ref<OperationGroup[]>([]);
|
||||
const selectedObjectId = ref<string | null>(null);
|
||||
const tableDiffGroups = ref<OperationGroup[]>([]);
|
||||
const routineDiffGroups = ref<OperationGroup[]>([]);
|
||||
const resultTab = ref<SchemaDiffResultTab>("tables");
|
||||
const preferredResultTab = ref<SchemaDiffResultTab | null>(null);
|
||||
const selectedTableObjectId = ref<string | null>(null);
|
||||
const selectedRoutineObjectId = ref<string | null>(null);
|
||||
const selectedObjectId = computed({
|
||||
get: () => (resultTab.value === "routines" ? selectedRoutineObjectId.value : selectedTableObjectId.value),
|
||||
set: (value: string | null) => {
|
||||
if (resultTab.value === "routines") selectedRoutineObjectId.value = value;
|
||||
else selectedTableObjectId.value = value;
|
||||
},
|
||||
});
|
||||
const focusedDeploySql = ref("");
|
||||
const focusedForwardDeploySql = ref("");
|
||||
const focusedRollbackSql = ref("");
|
||||
@@ -135,6 +158,23 @@ const targetDbVersion = ref<string | null>(null);
|
||||
const showResultDialog = ref(false);
|
||||
const deployResult = ref<{ success: boolean; status?: string; message: string; affectedRows?: number; error?: string } | null>(null);
|
||||
|
||||
const partitionedDiffObjects = computed(() => partitionSchemaDiffObjectsByResultTab(diffObjects.value));
|
||||
const tableDiffObjects = computed(() => partitionedDiffObjects.value.tableObjects);
|
||||
const routineDiffObjects = computed(() => partitionedDiffObjects.value.routineObjects);
|
||||
const currentTabDiffObjects = computed(() => (resultTab.value === "routines" ? routineDiffObjects.value : tableDiffObjects.value));
|
||||
const activeDiffGroups = computed(() => (resultTab.value === "routines" ? routineDiffGroups.value : tableDiffGroups.value));
|
||||
const tableActionableCount = computed(() => countSchemaDiffActionableObjects(tableDiffObjects.value));
|
||||
const routineActionableCount = computed(() => countSchemaDiffActionableObjects(routineDiffObjects.value));
|
||||
const tablesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.tables);
|
||||
const routinesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.functions);
|
||||
const canEnableRoutinesCompare = computed(() => schemaDiffRoutineObjectTypesIntersection(sourceDbType.value as DatabaseType | undefined, targetDbType.value as DatabaseType | undefined).length > 0);
|
||||
const effectiveRoutinesEnabled = computed(() => routinesCompareEnabled.value && canEnableRoutinesCompare.value);
|
||||
const showTableResultTab = computed(() => tablesCompareEnabled.value);
|
||||
const showRoutineResultTab = computed(() => effectiveRoutinesEnabled.value);
|
||||
const showResultTabList = computed(() => showTableResultTab.value && showRoutineResultTab.value);
|
||||
const showNoDifferences = computed(() => showTableResultTab.value && tableDiffObjects.value.length === 0);
|
||||
const showRoutineNoDifferences = computed(() => showRoutineResultTab.value && routineDiffObjects.value.length === 0);
|
||||
|
||||
// Phase 4 result fields
|
||||
const rollbackSql = ref("");
|
||||
const rollbackCompleteness = ref<RollbackCompleteness>("complete");
|
||||
@@ -284,19 +324,28 @@ const selectedObject = computed(() => {
|
||||
|
||||
const selectedTreeObject = computed(() => {
|
||||
if (!selectedObjectId.value) return null;
|
||||
for (const group of diffGroups.value) {
|
||||
for (const group of activeDiffGroups.value) {
|
||||
for (const typeGroup of group.typeGroups) {
|
||||
const object = flattenSchemaDiffObjects(typeGroup.objects).find((candidate) => candidate.id === selectedObjectId.value);
|
||||
if (object) return object;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return findSchemaDiffObject(diffObjects.value, selectedObjectId.value);
|
||||
});
|
||||
|
||||
const canDeployRoutines = computed(() => isSchemaDiffPostgresLike(targetDbType.value));
|
||||
|
||||
const canDeploy = computed(() => {
|
||||
return selectedSchemaDiffObjects(diffObjects.value).length > 0;
|
||||
// MySQL/SQL Server routine sync is deferred (copy DDL only). Postgres-family
|
||||
// already has function DDL templates, so keep deploy on the routines tab.
|
||||
if (resultTab.value === "routines" && !canDeployRoutines.value) return false;
|
||||
return selectedSchemaDiffObjects(currentTabDiffObjects.value).length > 0;
|
||||
});
|
||||
|
||||
const showRoutineDeployDeferredHint = computed(() => step.value === "result" && resultTab.value === "routines" && !canDeployRoutines.value);
|
||||
|
||||
const deployButtonLabel = computed(() => (resultTab.value === "routines" ? t("diff.nextStepDeployRoutines") : t("diff.nextStepDeploy")));
|
||||
|
||||
const schemaDiffProgressCount = computed(() => {
|
||||
const progress = schemaDiffProgress.value;
|
||||
return progress && progress.total !== undefined && progress.total > 0 && progress.current !== undefined ? { current: progress.current, total: progress.total } : null;
|
||||
@@ -339,8 +388,11 @@ function resetComparisonResultState() {
|
||||
schemaDiffHasExtraObjectPhase.value = false;
|
||||
step.value = "config";
|
||||
diffObjects.value = [];
|
||||
diffGroups.value = [];
|
||||
selectedObjectId.value = null;
|
||||
tableDiffGroups.value = [];
|
||||
routineDiffGroups.value = [];
|
||||
resultTab.value = "tables";
|
||||
selectedTableObjectId.value = null;
|
||||
selectedRoutineObjectId.value = null;
|
||||
focusedDeploySql.value = "";
|
||||
focusedForwardDeploySql.value = "";
|
||||
focusedRollbackSql.value = "";
|
||||
@@ -367,12 +419,14 @@ function comparisonEndpointLabel(connectionId: string, database: string, schema:
|
||||
|
||||
function restoreSchemaDiffSession(session: SchemaDiffSession): void {
|
||||
const config = session.config;
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
sourceDatabase.value = config.sourceDatabase;
|
||||
sourceSchema.value = config.sourceSchema;
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
runWithoutTargetIdentityReset(() => {
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
sourceDatabase.value = config.sourceDatabase;
|
||||
sourceSchema.value = config.sourceSchema;
|
||||
targetConnectionId.value = config.targetConnectionId;
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
});
|
||||
ignoreComments.value = config.ignoreComments;
|
||||
ensureDefaultConfig(config.targetDbType);
|
||||
updateActiveConfigConnection({
|
||||
@@ -384,12 +438,12 @@ function restoreSchemaDiffSession(session: SchemaDiffSession): void {
|
||||
targetSchema: config.targetSchema,
|
||||
});
|
||||
updateActiveConfigOptions(config.options);
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(config.targetDbType, config.options);
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(config.sourceDbType, config.targetDbType, config.options);
|
||||
}
|
||||
|
||||
function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
|
||||
if (!session) return;
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(session.config.targetDbType, session.config.options);
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(session.config.sourceDbType, session.config.targetDbType, session.config.options);
|
||||
if (session.status === "running") {
|
||||
loading.value = true;
|
||||
step.value = "compare";
|
||||
@@ -420,7 +474,26 @@ function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
|
||||
permissionDiffs.value = session.result.permissionDiffs ?? [];
|
||||
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(session.result.dependencyGraph);
|
||||
diffObjects.value = schemaDiffSessionObjects(session.result);
|
||||
diffGroups.value = groupDiffObjects(diffObjects.value);
|
||||
rebuildDiffGroups();
|
||||
const hasTableActions = showTableResultTab.value && countSchemaDiffActionableObjects(tableDiffObjects.value) > 0;
|
||||
const hasRoutineActions = showRoutineResultTab.value && countSchemaDiffActionableObjects(routineDiffObjects.value) > 0;
|
||||
if (!showTableResultTab.value && showRoutineResultTab.value) {
|
||||
resultTab.value = "routines";
|
||||
preferredResultTab.value = null;
|
||||
} else if (showTableResultTab.value && !showRoutineResultTab.value) {
|
||||
resultTab.value = "tables";
|
||||
preferredResultTab.value = null;
|
||||
} else if (preferredResultTab.value === "routines" && showRoutineResultTab.value) {
|
||||
resultTab.value = "routines";
|
||||
preferredResultTab.value = null;
|
||||
} else if (preferredResultTab.value) {
|
||||
resultTab.value = "tables";
|
||||
preferredResultTab.value = null;
|
||||
} else {
|
||||
resultTab.value = !hasTableActions && hasRoutineActions ? "routines" : "tables";
|
||||
}
|
||||
selectedTableObjectId.value = null;
|
||||
selectedRoutineObjectId.value = null;
|
||||
lastDiffResult.value = session.result;
|
||||
deploySqlMode.value = "forward";
|
||||
void regenerateSelectedDeploySql();
|
||||
@@ -450,6 +523,14 @@ watch(
|
||||
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
||||
if (props.prefillSchema) sourceSchema.value = props.prefillSchema;
|
||||
}
|
||||
if (props.prefillSelectedRoutines?.length) {
|
||||
handleCompareScopeUpdate({ functions: true });
|
||||
handleSelectedRoutinesUpdate([...props.prefillSelectedRoutines]);
|
||||
}
|
||||
preferredResultTab.value = props.prefillResultTab ?? null;
|
||||
if (props.prefillResultTab) {
|
||||
resultTab.value = props.prefillResultTab;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -462,6 +543,12 @@ watch(
|
||||
() => applySchemaDiffSession(getSchemaDiffSession(activeSessionId.value)),
|
||||
);
|
||||
|
||||
watch(resultTab, () => {
|
||||
if (step.value !== "result") return;
|
||||
void regenerateSelectedDeploySql();
|
||||
void regenerateFocusedDeploySql();
|
||||
});
|
||||
|
||||
// Config sync
|
||||
watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, targetDatabase, targetSchema], ([srcConn, srcDb, srcSchema, tgtConn, tgtDb, tgtSchema]) => {
|
||||
updateActiveConfigConnection({
|
||||
@@ -474,12 +561,69 @@ watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, tar
|
||||
});
|
||||
});
|
||||
|
||||
/** Suppress target-identity resets while restoring session/history/swap. */
|
||||
let suppressTargetIdentityReset = false;
|
||||
|
||||
function runWithoutTargetIdentityReset(action: () => void): void {
|
||||
suppressTargetIdentityReset = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
void nextTick(() => {
|
||||
suppressTargetIdentityReset = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearCompareObjectSelection(): void {
|
||||
if (!activeConfig.value) return;
|
||||
updateActiveConfigOptions(
|
||||
normalizeSchemaDiffCompareOptions(
|
||||
{
|
||||
...activeConfig.value.options,
|
||||
selectedTables: undefined,
|
||||
selectedRoutines: undefined,
|
||||
tableMappings: [],
|
||||
routineMappings: [],
|
||||
},
|
||||
getDbType(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Switching target must drop stale schema (Oracle→MySQL) and object selection/mappings.
|
||||
watch(targetConnectionId, (connId, previousConnId) => {
|
||||
if (suppressTargetIdentityReset) return;
|
||||
if (connId === previousConnId) return;
|
||||
targetDatabase.value = "";
|
||||
targetSchema.value = "";
|
||||
clearCompareObjectSelection();
|
||||
});
|
||||
|
||||
watch(targetDatabase, (database, previousDatabase) => {
|
||||
if (suppressTargetIdentityReset) return;
|
||||
if (database === previousDatabase) return;
|
||||
targetSchema.value = "";
|
||||
clearCompareObjectSelection();
|
||||
});
|
||||
|
||||
watch(targetDbType, (dbType, previousDbType) => {
|
||||
if (suppressTargetIdentityReset) return;
|
||||
if (!isSchemaAware(dbType as DatabaseType) && targetSchema.value) {
|
||||
targetSchema.value = "";
|
||||
}
|
||||
if (previousDbType && dbType && previousDbType !== dbType) {
|
||||
clearCompareObjectSelection();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-fetch target database version when connection/database changes
|
||||
watch(
|
||||
() => [targetConnectionId.value, targetDatabase.value],
|
||||
async ([connId, db]) => {
|
||||
() => [targetConnectionId.value, targetDatabase.value, targetSchema.value, targetDbType.value] as const,
|
||||
async ([connId, db, schema, dbType]) => {
|
||||
if (connId && db) {
|
||||
await fetchDbVersion(connId, db, targetSchema.value);
|
||||
const effectiveSchema = isSchemaAware(dbType as DatabaseType) ? schema : "";
|
||||
await fetchDbVersion(connId, db, effectiveSchema);
|
||||
} else {
|
||||
targetDbVersion.value = null;
|
||||
}
|
||||
@@ -495,6 +639,8 @@ function handleSwap() {
|
||||
const currentOptions = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
|
||||
const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);
|
||||
const swappedSelectedTables = Array.isArray(currentOptions.selectedTables) ? swappedMappings.map((mapping) => mapping.sourceTable) : undefined;
|
||||
const swappedRoutineMappings = swapSchemaDiffRoutineMappings(currentOptions.routineMappings ?? []);
|
||||
const swappedSelectedRoutines = Array.isArray(currentOptions.selectedRoutines) ? swappedRoutineMappings.map((mapping) => mapping.sourceRoutine) : undefined;
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(
|
||||
normalizeSchemaDiffCompareOptions(
|
||||
@@ -502,21 +648,25 @@ function handleSwap() {
|
||||
...currentOptions,
|
||||
selectedTables: swappedSelectedTables,
|
||||
tableMappings: swappedMappings,
|
||||
selectedRoutines: swappedSelectedRoutines,
|
||||
routineMappings: swappedRoutineMappings,
|
||||
},
|
||||
getDbType(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
runWithoutTargetIdentityReset(() => {
|
||||
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) {
|
||||
@@ -531,12 +681,32 @@ function handleSelectedTablesUpdate(value?: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectedRoutinesUpdate(value?: string[]) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, selectedRoutines: value }, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompareScopeUpdate(value: { tables?: boolean; views?: boolean; functions?: boolean }) {
|
||||
if (activeConfig.value) {
|
||||
const next = { ...activeConfig.value.options, ...value };
|
||||
if (value.tables === false) next.views = false;
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(next, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTableMappingsUpdate(value: SchemaDiffTableMapping[]) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, tableMappings: value }, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoutineMappingsUpdate(value: SchemaDiffRoutineMapping[]) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, routineMappings: value }, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
if (activeConfig.value) {
|
||||
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
|
||||
@@ -554,7 +724,7 @@ function handleCompare(): void {
|
||||
appliedSessionResult = null;
|
||||
shownSessionError = "";
|
||||
resetComparisonResultState();
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(targetDbType, options);
|
||||
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(sourceDbType, targetDbType, options);
|
||||
const session = startSchemaDiffSession(
|
||||
{
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
@@ -576,11 +746,12 @@ function handleCompare(): void {
|
||||
}
|
||||
|
||||
function handleToggleGroup(operationType: DiffOperationType) {
|
||||
diffGroups.value = diffGroups.value.map((g) => (g.operationType === operationType ? { ...g, expanded: !g.expanded } : g));
|
||||
const groups = resultTab.value === "routines" ? routineDiffGroups : tableDiffGroups;
|
||||
groups.value = groups.value.map((g) => (g.operationType === operationType ? { ...g, expanded: !g.expanded } : g));
|
||||
}
|
||||
|
||||
function handleToggleGroupSelection(operationType: DiffOperationType, selected: boolean) {
|
||||
const group = diffGroups.value.find((candidate) => candidate.operationType === operationType);
|
||||
const group = activeDiffGroups.value.find((candidate) => candidate.operationType === operationType);
|
||||
for (const object of group?.typeGroups.flatMap((typeGroup) => typeGroup.objects) ?? []) {
|
||||
for (const target of schemaDiffSelectionTargets(object)) {
|
||||
updateObjectSelection(target.id, selected);
|
||||
@@ -605,16 +776,22 @@ function updateObjectSelection(objectId: string, selected: boolean): boolean {
|
||||
}
|
||||
|
||||
function rebuildDiffGroups() {
|
||||
const expanded = new Map(diffGroups.value.map((group) => [group.operationType, group.expanded]));
|
||||
const typeExpanded = new Map(diffGroups.value.flatMap((group) => group.typeGroups.map((typeGroup) => [`${group.operationType}:${typeGroup.kind}`, typeGroup.expanded] as const)));
|
||||
diffGroups.value = groupDiffObjects(diffObjects.value).map((group) => ({
|
||||
...group,
|
||||
expanded: expanded.get(group.operationType) ?? group.expanded,
|
||||
typeGroups: group.typeGroups.map((typeGroup) => ({
|
||||
...typeGroup,
|
||||
expanded: typeExpanded.get(`${group.operationType}:${typeGroup.kind}`) ?? typeGroup.expanded,
|
||||
})),
|
||||
}));
|
||||
const preserveExpanded = (groups: OperationGroup[], objects: SchemaDiffObject[]) => {
|
||||
const expanded = new Map(groups.map((group) => [group.operationType, group.expanded]));
|
||||
const typeExpanded = new Map(groups.flatMap((group) => group.typeGroups.map((typeGroup) => [`${group.operationType}:${typeGroup.kind}`, typeGroup.expanded] as const)));
|
||||
return groupDiffObjects(objects).map((group) => ({
|
||||
...group,
|
||||
expanded: expanded.get(group.operationType) ?? group.expanded,
|
||||
typeGroups: group.typeGroups.map((typeGroup) => ({
|
||||
...typeGroup,
|
||||
expanded: typeExpanded.get(`${group.operationType}:${typeGroup.kind}`) ?? typeGroup.expanded,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
const { tableObjects, routineObjects } = partitionSchemaDiffObjectsByResultTab(diffObjects.value);
|
||||
tableDiffGroups.value = preserveExpanded(tableDiffGroups.value, tableObjects);
|
||||
routineDiffGroups.value = preserveExpanded(routineDiffGroups.value, routineObjects);
|
||||
}
|
||||
|
||||
function buildSchemaSyncPlanOptions(options: SchemaDiffCompareOptions) {
|
||||
@@ -655,7 +832,7 @@ async function regenerateSelectedDeploySql() {
|
||||
}
|
||||
|
||||
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
|
||||
const input = selectSchemaDiffInput(result, diffObjects.value);
|
||||
const input = selectSchemaDiffInput(result, currentTabDiffObjects.value);
|
||||
let plan;
|
||||
try {
|
||||
plan = await api.generateSchemaSyncPlan(input, buildSchemaSyncPlanOptions(options));
|
||||
@@ -822,49 +999,90 @@ async function handleSelectObject(reviewObject: SchemaDiffObject) {
|
||||
const obj = reviewObject.parentId ? (findSchemaDiffObject(diffObjects.value, reviewObject.parentId) ?? reviewObject) : reviewObject;
|
||||
|
||||
// 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);
|
||||
// (views need runtime retrieval; routines may have empty definitions from list_functions_via_objects)
|
||||
if (obj.objectKind === "view") {
|
||||
try {
|
||||
if (obj.operationType === "create" && !obj.sourceDdl) {
|
||||
const result = await api.getObjectSource(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name, "VIEW", 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 (obj.operationType === "delete" && !obj.targetDdl) {
|
||||
const result = await api.getObjectSource(targetConnectionId.value, targetDatabase.value, targetSchema.value, obj.name, "VIEW", obj.arguments);
|
||||
if (result?.source) obj.targetDdl = result.source;
|
||||
}
|
||||
if (obj.operationType === "modify") {
|
||||
if (!obj.sourceDdl) {
|
||||
const result = await api.getObjectSource(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name, "VIEW", 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, "VIEW", obj.arguments);
|
||||
if (result?.source) obj.targetDdl = result.source;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.objectKind !== "function") return;
|
||||
|
||||
const preferredKind: ObjectSourceKind = obj.routineType === "PROCEDURE" ? "PROCEDURE" : "FUNCTION";
|
||||
|
||||
async function fetchRoutineDdl(connectionId: string, database: string, schema: string, name: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { source } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, connectionId, database, schema, name, preferredKind, obj.arguments);
|
||||
return source?.source?.trim() ? source.source : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.operationType === "create" && !obj.sourceDdl) {
|
||||
const ddl = await fetchRoutineDdl(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name);
|
||||
if (ddl) obj.sourceDdl = ddl;
|
||||
}
|
||||
if (obj.operationType === "delete" && !obj.targetDdl) {
|
||||
const ddl = await fetchRoutineDdl(targetConnectionId.value, targetDatabase.value, targetSchema.value, obj.name);
|
||||
if (ddl) obj.targetDdl = ddl;
|
||||
}
|
||||
if (obj.operationType === "modify") {
|
||||
if (!obj.sourceDdl) {
|
||||
const ddl = await fetchRoutineDdl(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, obj.name);
|
||||
if (ddl) obj.sourceDdl = ddl;
|
||||
}
|
||||
if (!obj.targetDdl) {
|
||||
const ddl = await fetchRoutineDdl(targetConnectionId.value, targetDatabase.value, targetSchema.value, obj.name);
|
||||
if (ddl) obj.targetDdl = ddl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewRoutineDiff(object: SchemaDiffObject) {
|
||||
await handleSelectObject(object);
|
||||
}
|
||||
|
||||
async function copyRoutineDiffSide(side: TextDiffSide) {
|
||||
const object = selectedObject.value;
|
||||
if (!object) return;
|
||||
const text = side === "before" ? (object.sourceDdl ?? "") : (object.targetDdl ?? "");
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast(t("diff.routineDdlCopied"), 2000);
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
}
|
||||
}
|
||||
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;
|
||||
runWithoutTargetIdentityReset(() => {
|
||||
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(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
|
||||
}
|
||||
@@ -918,7 +1136,8 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
}
|
||||
|
||||
function handleDeployReview() {
|
||||
const selectedObjects = selectedSchemaDiffObjects(diffObjects.value);
|
||||
if (resultTab.value === "routines" && !canDeployRoutines.value) return;
|
||||
const selectedObjects = selectedSchemaDiffObjects(currentTabDiffObjects.value);
|
||||
if (selectedObjects.length === 0) {
|
||||
toast(t("diff.noObjectsSelected"), 3000);
|
||||
return;
|
||||
@@ -946,7 +1165,7 @@ async function onConfirmDeploy() {
|
||||
}
|
||||
|
||||
const deployStats = computed(() => {
|
||||
const counts = summarizeSchemaDiffOperations(diffObjects.value);
|
||||
const counts = summarizeSchemaDiffOperations(currentTabDiffObjects.value);
|
||||
return {
|
||||
create: counts.create,
|
||||
modify: counts.modify,
|
||||
@@ -1010,6 +1229,7 @@ const targetConnectionInfo = computed(() => {
|
||||
:active-config-id="activeConfigId"
|
||||
:options="schemaDiffPanelOptions"
|
||||
:selected-tables="schemaDiffPanelOptions.selectedTables"
|
||||
:selected-routines="schemaDiffPanelOptions.selectedRoutines"
|
||||
:table-list-loader="schemaDiffTableListLoader"
|
||||
:loading="loading"
|
||||
:recent-configs="recentConfigs"
|
||||
@@ -1021,7 +1241,10 @@ const targetConnectionInfo = computed(() => {
|
||||
@delete-history-config="handleDeleteHistoryConfig"
|
||||
@update:field-mappings="handleFieldMappingsUpdate"
|
||||
@update:table-mappings="handleTableMappingsUpdate"
|
||||
@update:routine-mappings="handleRoutineMappingsUpdate"
|
||||
@update:selected-tables="handleSelectedTablesUpdate"
|
||||
@update:selected-routines="handleSelectedRoutinesUpdate"
|
||||
@update:compare-scope="handleCompareScopeUpdate"
|
||||
@open-field-mapping="showFieldMappingDialog = true"
|
||||
/>
|
||||
|
||||
@@ -1056,82 +1279,133 @@ const targetConnectionInfo = computed(() => {
|
||||
|
||||
<!-- Result Step -->
|
||||
<template v-else-if="step === 'result'">
|
||||
<!-- Rename Candidates Panel -->
|
||||
<div v-if="renameCandidates.length > 0" class="border-b bg-amber-50 dark:bg-amber-950/20 shrink-0 overflow-hidden">
|
||||
<button class="flex items-center gap-2 px-3 py-1.5 w-full text-left text-xs font-medium hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors" @click="showRenamePanel = !showRenamePanel">
|
||||
<ChevronDown v-if="showRenamePanel" class="w-3 h-3" />
|
||||
<ChevronRight v-else class="w-3 h-3" />
|
||||
{{ t("diff.renameCandidates") }}
|
||||
<span class="ml-auto text-muted-foreground font-normal">{{ renameCandidates.length }} candidate(s)</span>
|
||||
</button>
|
||||
<div v-if="showRenamePanel" class="px-3 pb-2 text-xs">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-muted-foreground border-b">
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.sourceTable") }}</th>
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.targetTable") }}</th>
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.similarity") }}</th>
|
||||
<th class="text-left py-1">{{ t("common.action") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(rc, i) in renameCandidates" :key="i" class="border-b border-amber-200/50 dark:border-amber-800/30">
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.sourceName }}</td>
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.targetName }}</td>
|
||||
<td class="py-1 pr-4">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="rc.score >= 0.8 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : rc.score >= 0.5 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'"
|
||||
>{{ (rc.score * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors mr-1" @click="applyRename(rc)">
|
||||
{{ t("diff.confirmRename") }}
|
||||
</button>
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 transition-colors" @click="ignoreRename(i)">
|
||||
{{ t("diff.ignore") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs v-model="resultTab" class="flex min-h-0 flex-1 flex-col gap-0 data-horizontal:flex-col">
|
||||
<TabsList v-if="showResultTabList" class="mx-3 mt-2 h-8 w-fit shrink-0">
|
||||
<TabsTrigger value="tables" class="text-xs">
|
||||
{{ t("diff.resultTabTables", { count: tableActionableCount }) }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routines" class="text-xs">
|
||||
{{ t("diff.resultTabRoutines", { count: routineActionableCount }) }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<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="selectedObjectId" @toggle-group="handleToggleGroup" @toggle-group-selection="handleToggleGroupSelection" @toggle-object-selection="handleToggleObjectSelection" @select-object="handleSelectObject" />
|
||||
<TabsContent v-if="showTableResultTab" value="tables" class="mt-0 flex min-h-0 flex-1 flex-col outline-none data-[state=inactive]:hidden">
|
||||
<div v-if="showNoDifferences" class="mx-3 mt-2 shrink-0 rounded-md border border-dashed bg-muted/20 px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="100 - splitpanesSize" min-size="20">
|
||||
<SchemaDiffDdlPanel
|
||||
:selected-object="selectedObject"
|
||||
:focused-object="selectedTreeObject"
|
||||
:deploy-sql="focusedDeploySql"
|
||||
:deploy-sql-all="selectedDeploySql"
|
||||
:rollback-forward-sql="selectedForwardDeploySql"
|
||||
:compatibility-warnings="selectedCompatibilityWarnings"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:dependency-graph="dependencyGraph"
|
||||
:permission-diffs="permissionDiffs"
|
||||
:rollback-completeness="rollbackCompleteness"
|
||||
:missing-rollback-objects="missingRollbackObjects"
|
||||
:can-execute="canExecuteDeploy"
|
||||
@update:deploy-sql-mode="switchDeploySqlMode"
|
||||
@execute-script="handleExecuteScript"
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<div v-if="renameCandidates.length > 0" class="shrink-0 overflow-hidden border-b bg-amber-50 dark:bg-amber-950/20">
|
||||
<button class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium transition-colors hover:bg-amber-100/50 dark:hover:bg-amber-900/30" @click="showRenamePanel = !showRenamePanel">
|
||||
<ChevronDown v-if="showRenamePanel" class="h-3 w-3" />
|
||||
<ChevronRight v-else class="h-3 w-3" />
|
||||
{{ t("diff.renameCandidates") }}
|
||||
<span class="ml-auto font-normal text-muted-foreground">{{ renameCandidates.length }} candidate(s)</span>
|
||||
</button>
|
||||
<div v-if="showRenamePanel" class="px-3 pb-2 text-xs">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="py-1 pr-4 text-left">{{ t("diff.sourceTable") }}</th>
|
||||
<th class="py-1 pr-4 text-left">{{ t("diff.targetTable") }}</th>
|
||||
<th class="py-1 pr-4 text-left">{{ t("diff.similarity") }}</th>
|
||||
<th class="py-1 text-left">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(rc, i) in renameCandidates" :key="i" class="border-b border-amber-200/50 dark:border-amber-800/30">
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.sourceName }}</td>
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.targetName }}</td>
|
||||
<td class="py-1 pr-4">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:class="rc.score >= 0.8 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : rc.score >= 0.5 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'"
|
||||
>{{ (rc.score * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<button class="mr-1 rounded bg-primary/10 px-2 py-0.5 text-xs text-primary transition-colors hover:bg-primary/20" @click="applyRename(rc)">
|
||||
{{ t("diff.confirmRename") }}
|
||||
</button>
|
||||
<button class="rounded bg-muted px-2 py-0.5 text-xs transition-colors hover:bg-muted/80" @click="ignoreRename(i)">
|
||||
{{ t("diff.ignore") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Splitpanes horizontal class="min-h-0 flex-1" @resized="handleSplitpanesResized">
|
||||
<Pane :size="splitpanesSize" min-size="20">
|
||||
<div class="h-full overflow-auto">
|
||||
<SchemaDiffObjectTree :groups="tableDiffGroups" :selected-object-id="selectedTableObjectId" @toggle-group="handleToggleGroup" @toggle-group-selection="handleToggleGroupSelection" @toggle-object-selection="handleToggleObjectSelection" @select-object="handleSelectObject" />
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="100 - splitpanesSize" min-size="20">
|
||||
<SchemaDiffDdlPanel
|
||||
:selected-object="selectedObject"
|
||||
:focused-object="selectedTreeObject"
|
||||
:deploy-sql="focusedDeploySql"
|
||||
:deploy-sql-all="selectedDeploySql"
|
||||
:rollback-forward-sql="selectedForwardDeploySql"
|
||||
:compatibility-warnings="selectedCompatibilityWarnings"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:dependency-graph="dependencyGraph"
|
||||
:permission-diffs="permissionDiffs"
|
||||
:rollback-completeness="rollbackCompleteness"
|
||||
:missing-rollback-objects="missingRollbackObjects"
|
||||
:can-execute="canExecuteDeploy"
|
||||
@update:deploy-sql-mode="switchDeploySqlMode"
|
||||
@execute-script="handleExecuteScript"
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="showRoutineResultTab" value="routines" class="mt-0 flex min-h-0 flex-1 flex-col outline-none data-[state=inactive]:hidden">
|
||||
<div v-if="showRoutineNoDifferences" class="mx-3 mt-2 shrink-0 rounded-md border border-dashed bg-muted/20 px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
<Splitpanes horizontal class="min-h-0 flex-1" @resized="handleSplitpanesResized">
|
||||
<Pane :size="splitpanesSize" min-size="20">
|
||||
<div class="h-full overflow-auto">
|
||||
<SchemaDiffRoutineList :objects="routineDiffObjects" :viewing-object-id="selectedRoutineObjectId" :selectable="canDeployRoutines" :empty-text="t('diff.noDifferences')" @toggle-selection="handleToggleObjectSelection" @view-diff="handleViewRoutineDiff" />
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="100 - splitpanesSize" min-size="20">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div v-if="!selectedObject || selectedObject.objectKind !== 'function'" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.selectRoutineToCompare") }}
|
||||
</div>
|
||||
<div v-else-if="!selectedObject.sourceDdl && !selectedObject.targetDdl" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDdlAvailable") }}
|
||||
</div>
|
||||
<SideBySideTextDiff
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
:before-text="selectedObject.sourceDdl || ''"
|
||||
:after-text="selectedObject.targetDdl || ''"
|
||||
:before-label="t('diff.sourceDdl')"
|
||||
:after-label="t('diff.targetDdl')"
|
||||
:copy-before-title="t('diff.copySourceDdl')"
|
||||
:copy-after-title="t('diff.copyTargetDdl')"
|
||||
:before-available="!!selectedObject.sourceDdl"
|
||||
:after-available="!!selectedObject.targetDdl"
|
||||
@copy="copyRoutineDiffSide"
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
|
||||
<!-- Deploy Review Step -->
|
||||
<template v-else-if="step === 'deploy-review'">
|
||||
<SchemaDiffDeployStep
|
||||
v-model:deploy-sql="selectedDeploySql"
|
||||
:selected-objects="diffObjects"
|
||||
:selected-objects="currentTabDiffObjects"
|
||||
:target-connection-id="targetConnectionId"
|
||||
:target-database="targetDatabase"
|
||||
:target-schema="targetSchema"
|
||||
@@ -1166,10 +1440,13 @@ const targetConnectionInfo = computed(() => {
|
||||
<div v-else></div>
|
||||
|
||||
<div v-if="step === 'result'" class="flex items-center gap-2">
|
||||
<Button size="sm" :disabled="!canDeploy || executing" @click="handleDeployReview">
|
||||
<p v-if="showRoutineDeployDeferredHint" class="max-w-md text-right text-[11px] text-muted-foreground">
|
||||
{{ t("diff.routineSyncDeferredHint") }}
|
||||
</p>
|
||||
<Button v-else 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") }}
|
||||
{{ deployButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { schemaDiffObjectSelectionState, type SchemaDiffObject } from "@/lib/schema/schemaDiff";
|
||||
import { schemaDiffRoutineKey, summarizeSchemaDiffRoutineTextDiff, type SchemaDiffRoutineTextDiffStats } from "@/lib/schema/schemaDiffRoutine";
|
||||
|
||||
const props = defineProps<{
|
||||
objects: SchemaDiffObject[];
|
||||
viewingObjectId?: string | null;
|
||||
emptyText?: string;
|
||||
/** When false, hide deploy checkboxes (compare/copy-only dialects). */
|
||||
selectable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-selection", object: SchemaDiffObject, selected: boolean): void;
|
||||
(e: "view-diff", object: SchemaDiffObject): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showSelection = computed(() => props.selectable !== false);
|
||||
const emptyLabel = computed(() => props.emptyText || t("diff.noDifferences"));
|
||||
const gridClass = computed(() => (showSelection.value ? "grid-cols-[28px_minmax(0,1.2fr)_minmax(0,1.2fr)_minmax(120px,0.9fr)]" : "grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)_minmax(120px,0.9fr)]"));
|
||||
|
||||
const rows = computed(() =>
|
||||
props.objects.map((object) => {
|
||||
const stats = summarizeSchemaDiffRoutineTextDiff(object.sourceDdl, object.targetDdl);
|
||||
return {
|
||||
object,
|
||||
sourceLabel: object.operationType === "delete" ? "" : displayName(object, "source"),
|
||||
targetLabel: object.operationType === "create" ? "" : displayName(object, "target"),
|
||||
stats,
|
||||
selection: schemaDiffObjectSelectionState(object),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
function displayName(object: SchemaDiffObject, side: "source" | "target"): string {
|
||||
const name = side === "source" ? (object.sourceName ?? object.name) : (object.targetName ?? object.name);
|
||||
return schemaDiffRoutineKey(name, object.arguments ?? "");
|
||||
}
|
||||
|
||||
function hasStats(stats: SchemaDiffRoutineTextDiffStats): boolean {
|
||||
return stats.added !== 0 || stats.removed !== 0 || stats.modified !== 0;
|
||||
}
|
||||
|
||||
function onCheckboxChange(object: SchemaDiffObject, event: Event) {
|
||||
emit("toggle-selection", object, (event.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
function onRowActivate(object: SchemaDiffObject) {
|
||||
emit("view-diff", object);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="grid gap-2 border-b px-2 py-1.5 text-xs font-medium text-muted-foreground" :class="gridClass">
|
||||
<div v-if="showSelection" />
|
||||
<div>{{ t("diff.sourceObject") }}</div>
|
||||
<div>{{ t("diff.targetObject") }}</div>
|
||||
<div>{{ t("diff.routineDiffPoints") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
{{ emptyLabel }}
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-border/40">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.object.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="grid cursor-pointer items-center gap-2 px-2 py-1.5 text-xs outline-none focus-visible:bg-accent/40"
|
||||
:class="[gridClass, viewingObjectId === row.object.id ? 'bg-primary/10' : 'hover:bg-accent/30']"
|
||||
@click="onRowActivate(row.object)"
|
||||
@keydown.enter.prevent="onRowActivate(row.object)"
|
||||
@keydown.space.prevent="onRowActivate(row.object)"
|
||||
>
|
||||
<input v-if="showSelection" type="checkbox" class="accent-primary justify-self-center" :checked="row.selection.checked" :indeterminate="row.selection.indeterminate" @click.stop @change="onCheckboxChange(row.object, $event)" />
|
||||
<div class="min-w-0 truncate font-mono" :title="row.sourceLabel || undefined">
|
||||
<span v-if="row.sourceLabel" :class="row.object.operationType === 'create' ? 'text-green-600 dark:text-green-400' : ''">{{ row.sourceLabel }}</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</div>
|
||||
<div class="min-w-0 truncate font-mono" :title="row.targetLabel || undefined">
|
||||
<span v-if="row.targetLabel" :class="row.object.operationType === 'delete' ? 'text-red-500 line-through' : ''">{{ row.targetLabel }}</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</div>
|
||||
<div class="min-w-0 truncate tabular-nums" :title="hasStats(row.stats) ? t('diff.routineDiffStats', { added: row.stats.added, removed: row.stats.removed, modified: row.stats.modified }) : undefined">
|
||||
<template v-if="hasStats(row.stats)">
|
||||
<span class="text-green-600 dark:text-green-400">+{{ row.stats.added }}</span>
|
||||
<span class="text-muted-foreground"> </span>
|
||||
<span class="text-red-500 dark:text-red-400">−{{ row.stats.removed }}</span>
|
||||
<span class="text-muted-foreground"> </span>
|
||||
<span class="text-amber-600 dark:text-amber-400">~{{ row.stats.modified }}</span>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,7 +69,7 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
expect(dialogSource).toContain("schemaDiffNextProgressLabel");
|
||||
expect(dialogSource).toContain('t("diff.progress.next"');
|
||||
expect(dialogSource).toContain('v-if="schemaDiffNextProgressLabel"');
|
||||
expect(dialogSource).toContain("shouldLoadSchemaDiffExtraObjects");
|
||||
expect(dialogSource).toContain("shouldLoadSchemaDiffExtraObjectPhase");
|
||||
});
|
||||
|
||||
it("clears progress before preserving the existing comparison error flow", () => {
|
||||
@@ -93,6 +93,24 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
expect(configStepSource).toContain("restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')");
|
||||
});
|
||||
|
||||
it("resets stale target schema and object selection when the target identity changes", () => {
|
||||
expect(dialogSource).toContain("suppressTargetIdentityReset");
|
||||
expect(dialogSource).toContain("clearCompareObjectSelection");
|
||||
expect(dialogSource).toContain("runWithoutTargetIdentityReset");
|
||||
expect(dialogSource).toContain("watch(targetConnectionId,");
|
||||
expect(dialogSource).toContain("watch(targetDatabase,");
|
||||
expect(dialogSource).toContain("watch(targetDbType,");
|
||||
expect(dialogSource).toContain('targetDatabase.value = ""');
|
||||
expect(dialogSource).toContain('targetSchema.value = ""');
|
||||
expect(dialogSource).toContain("isSchemaAware(dbType as DatabaseType)");
|
||||
expect(dialogSource).toContain("selectedTables: undefined");
|
||||
expect(dialogSource).toContain("selectedRoutines: undefined");
|
||||
});
|
||||
|
||||
it("gates config table-list loading on the tables compare switch", () => {
|
||||
expect(configStepSource).toContain("tablesCompareEnabled.value && shouldLoadSchemaDiffTableList");
|
||||
});
|
||||
|
||||
it("keeps explicit table selection in a shared scope below both sides", () => {
|
||||
const comparisonScope = configStepSource.indexOf("<!-- Comparison Scope -->");
|
||||
const targetInfo = configStepSource.indexOf("<!-- Target Info -->");
|
||||
@@ -103,7 +121,7 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
|
||||
expect(comparisonScope).toBeGreaterThan(targetInfo);
|
||||
expect(comparisonScope).toBeLessThan(options);
|
||||
expect(tableSelectors).toHaveLength(1);
|
||||
expect(tableSelectors).toHaveLength(2);
|
||||
expect(tableSelector).toBeGreaterThan(comparisonScope);
|
||||
expect(tableSelector).toBeLessThan(options);
|
||||
expect(targetMatch).toBeGreaterThan(comparisonScope);
|
||||
@@ -114,10 +132,87 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
expect(configStepSource).toContain('@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"');
|
||||
expect(configStepSource).toContain("tableMatchStatus.${match.kind}");
|
||||
expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"');
|
||||
expect(sessionSource).toContain("tableMappings: options.selectedTables === undefined ? undefined : options.tableMappings");
|
||||
expect(sessionSource).toContain("ignoreTableNameCase: options.ignoreTableNameCase");
|
||||
expect(sessionSource).toContain("ignoreColumnNameCase: options.ignoreColumnNameCase");
|
||||
expect(sessionSource).toContain("tableMappings: sessionOptions.selectedTables === undefined ? undefined : sessionOptions.tableMappings");
|
||||
expect(sessionSource).toContain("ignoreTableNameCase: sessionOptions.ignoreTableNameCase");
|
||||
expect(sessionSource).toContain("ignoreColumnNameCase: sessionOptions.ignoreColumnNameCase");
|
||||
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
|
||||
expect(dialogSource).toContain('value="routines"');
|
||||
expect(configStepSource).toContain("handleUpdateSelectedRoutines");
|
||||
});
|
||||
|
||||
it("exposes table and routine compare switches on the config step", () => {
|
||||
expect(configStepSource).toContain('import { Switch } from "@/components/ui/switch"');
|
||||
expect(configStepSource).toContain("update:compareScope");
|
||||
expect(configStepSource).toContain("handleTablesCompareEnabled");
|
||||
expect(configStepSource).toContain("handleRoutinesCompareEnabled");
|
||||
expect(configStepSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(configStepSource).toContain("const routinesEnabled = effectiveRoutinesEnabled.value");
|
||||
expect(configStepSource).toContain("if (!tablesEnabled && !routinesEnabled) return false");
|
||||
expect(configStepSource).toContain("unrestrictedRoutineLoadTooLarge");
|
||||
expect(configStepSource).toContain("SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT");
|
||||
expect(configStepSource).toContain('t("diff.routineSelectionTooLarge"');
|
||||
expect(configStepSource).toContain('t("diff.tableCompareDisabled")');
|
||||
expect(configStepSource).toContain('t("diff.routineCompareDisabled")');
|
||||
expect(configStepSource).toContain("schemaDiffRoutineObjectTypesIntersection");
|
||||
expect(configStepSource).toContain('t("diff.routineSameNameMatchingOnly")');
|
||||
expect(dialogSource).toContain('@update:compare-scope="handleCompareScopeUpdate"');
|
||||
expect(dialogSource).toContain("function handleCompareScopeUpdate");
|
||||
expect(dialogSource).toContain("const routinesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.functions)");
|
||||
expect(dialogSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(dialogSource).not.toContain("functions: value === undefined ? activeConfig.value.options.functions : true");
|
||||
expect(sessionSource).toContain("shouldLoadSchemaDiffRoutines");
|
||||
expect(sessionSource).toContain("shouldLoadSchemaDiffExtraObjectPhase");
|
||||
expect(sessionSource).toContain("const loadTableMetadata = !!(sessionOptions.tables || sessionOptions.views)");
|
||||
expect(sessionSource).not.toContain("options.selectedRoutines !== undefined");
|
||||
expect(sessionSource).not.toContain("wantRoutines");
|
||||
});
|
||||
|
||||
it("shows result tabs only for enabled compare scopes", () => {
|
||||
expect(dialogSource).toContain("showTableResultTab");
|
||||
expect(dialogSource).toContain("showRoutineResultTab");
|
||||
expect(dialogSource).toContain("showResultTabList");
|
||||
expect(dialogSource).toContain("tablesCompareEnabled");
|
||||
expect(dialogSource).toContain("routinesCompareEnabled");
|
||||
expect(dialogSource).toContain("effectiveRoutinesEnabled");
|
||||
expect(dialogSource).toContain("showNoDifferences");
|
||||
expect(dialogSource).not.toContain("showRoutinesNotCompared");
|
||||
expect(dialogSource).toContain("showRoutineNoDifferences");
|
||||
expect(dialogSource).toContain('t("diff.resultTabTables"');
|
||||
expect(dialogSource).toContain('t("diff.resultTabRoutines"');
|
||||
expect(dialogSource).toContain('t("diff.noDifferences")');
|
||||
expect(dialogSource).toContain("schemaDiffRoutineObjectTypesIntersection");
|
||||
expect(dialogSource).toContain('v-if="showResultTabList"');
|
||||
expect(dialogSource).toContain('v-if="showTableResultTab"');
|
||||
expect(dialogSource).toContain("data-[state=inactive]:hidden");
|
||||
expect(dialogSource).toContain("if (!showTableResultTab.value && showRoutineResultTab.value)");
|
||||
expect(dialogSource).toContain("loadObjectSourceWithRoutineFallback");
|
||||
});
|
||||
|
||||
it("defers routine deploy except for postgres-family targets", () => {
|
||||
expect(dialogSource).toContain("showRoutineDeployDeferredHint");
|
||||
expect(dialogSource).toContain("canDeployRoutines");
|
||||
expect(dialogSource).toContain("isSchemaDiffPostgresLike");
|
||||
expect(dialogSource).toContain('t("diff.routineSyncDeferredHint")');
|
||||
expect(dialogSource).toContain('if (resultTab.value === "routines" && !canDeployRoutines.value) return false');
|
||||
expect(dialogSource).toContain("nextStepDeployRoutines");
|
||||
expect(dialogSource).toContain('t("diff.nextStepDeploy")');
|
||||
expect(dialogSource).toContain(':selectable="canDeployRoutines"');
|
||||
expect(configStepSource).toContain("api.listObjects");
|
||||
expect(configStepSource).not.toContain("api.listFunctions");
|
||||
});
|
||||
|
||||
it("uses the object tree for tables and a dedicated list plus text diff for routines", () => {
|
||||
expect(dialogSource).toContain('import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue"');
|
||||
expect(dialogSource).toContain('import SchemaDiffRoutineList from "@/components/diff/SchemaDiffRoutineList.vue"');
|
||||
expect(dialogSource).toContain('import SideBySideTextDiff, { type TextDiffSide } from "@/components/common/SideBySideTextDiff.vue"');
|
||||
expect(dialogSource).toContain('<SchemaDiffObjectTree :groups="tableDiffGroups"');
|
||||
expect(dialogSource).toContain("<SchemaDiffDdlPanel");
|
||||
expect(dialogSource).toContain("<SchemaDiffRoutineList");
|
||||
expect(dialogSource).toContain(':objects="routineDiffObjects"');
|
||||
expect(dialogSource).toContain("handleViewRoutineDiff");
|
||||
expect(dialogSource).toContain("<SideBySideTextDiff");
|
||||
expect(dialogSource).toContain('t("diff.selectRoutineToCompare")');
|
||||
expect(dialogSource).not.toContain(':groups="routineDiffGroups"');
|
||||
});
|
||||
|
||||
it("keeps focused and selected deployment SQL projections separate", () => {
|
||||
|
||||
Reference in new issue
Block a user