feat(schema-diff): compare routines across same-structure databases

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-12 17:39:14 +08:00
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", () => {