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", () => {
@@ -262,6 +262,8 @@ watch(
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
:prefill-schema="dialogs.schemaDiffPrefillSchema.value"
:prefill-selected-routines="dialogs.schemaDiffPrefillSelectedRoutines.value"
:prefill-result-tab="dialogs.schemaDiffPrefillResultTab.value || undefined"
:session-id="dialogs.schemaDiffSessionId.value"
/>
<DataCompareDialog
@@ -398,7 +398,7 @@ const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData,
acceptedSelectionIds: () => acceptedSelectionIds,
});
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSchemaDiffForRoutine, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({
activeNode,
connectionStore,
queryStore,
@@ -6194,6 +6194,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench });
}
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
if (!isPackageMember) {
items.push({ label: t("diff.title"), action: openSchemaDiffForRoutine, icon: ArrowRightLeft });
}
if (!isPackageMember && canViewDatabaseObjectDependencies(currentDatabaseType(), node)) {
items.push({ label: t("contextMenu.viewDependencies"), action: openDatabaseObjectDependencies, icon: Network });
}
@@ -1,8 +1,13 @@
import { strict as assert } from "node:assert";
import { test, vi } from "vitest";
import { DEFAULT_MYSQL_OPTIONS, getDefaultOptionsForDbType } from "@/types/schemaDiff";
const apiMock = vi.hoisted(() => ({
prepareSchemaDiff: vi.fn(),
listFunctions: vi.fn(),
listSequences: vi.fn(),
listRules: vi.fn(),
listOwners: vi.fn(),
}));
const openMock = vi.hoisted(() => vi.fn());
const trackerMock = vi.hoisted(() => ({
@@ -17,6 +22,23 @@ vi.mock("@/lib/schema/schemaDiffMetadataLoad", () => ({ loadSchemaDetails: vi.fn
const { startSchemaDiffSession } = await import("../useSchemaDiffSession.ts");
async function waitForSession(session: { status: string }) {
for (let attempt = 0; attempt < 40 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
test("defaults enable tables and functions compare for common targets", () => {
assert.equal(DEFAULT_MYSQL_OPTIONS.tables, true);
assert.equal(DEFAULT_MYSQL_OPTIONS.functions, true);
assert.equal(getDefaultOptionsForDbType("oracle").tables, true);
assert.equal(getDefaultOptionsForDbType("oracle").functions, true);
assert.equal(getDefaultOptionsForDbType("mysql").tables, true);
assert.equal(getDefaultOptionsForDbType("mysql").functions, true);
assert.equal(getDefaultOptionsForDbType("postgres").tables, true);
assert.equal(getDefaultOptionsForDbType("postgres").functions, true);
});
test("runs a schema diff session after the dialog is closed and retains the prepared result", async () => {
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
@@ -28,6 +50,7 @@ test("runs a schema diff session after the dialog is closed and retains the prep
syncSql: "",
rollbackSyncSql: "",
});
apiMock.listFunctions.mockResolvedValue([]);
const tableListLoader = {
load: vi.fn().mockResolvedValue([]),
@@ -49,9 +72,7 @@ test("runs a schema diff session after the dialog is closed and retains the prep
{ tableListLoader },
);
for (let attempt = 0; attempt < 20 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await waitForSession(session);
assert.equal(session.status, "completed");
assert.deepEqual(session.result?.diffs, []);
@@ -59,8 +80,245 @@ test("runs a schema diff session after the dialog is closed and retains the prep
assert.equal(tableListLoader.load.mock.calls[0]?.[1]?.refresh, true);
assert.equal(trackerMock.addSchemaDiffTask.mock.calls.length, 1);
assert.equal(trackerMock.updateCompareTask.mock.calls.at(-1)?.[1].status, "Done");
// MySQL defaults now enable functions compare for same-dialect pairs.
assert.equal(apiMock.listFunctions.mock.calls.length, 2);
const onOpen = trackerMock.addSchemaDiffTask.mock.calls[0]?.[2] as (() => void) | undefined;
onOpen?.();
assert.equal(openMock.mock.calls.at(-1)?.[0], session.id);
});
test("loads routines for mysql↔mysql when functions is enabled", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [{ diff_type: "added", name: "p1", source: { name: "p1", function_type: "PROCEDURE", data_type: "", definition: "body", arguments: "" }, target: null, changes: [] }],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
apiMock.listFunctions.mockResolvedValue([{ name: "p1", function_type: "PROCEDURE", data_type: "", definition: "body", arguments: "" }]);
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql-src",
sourceDatabase: "gd_ebdata",
sourceSchema: "",
targetConnectionId: "mysql-dst",
targetDatabase: "gd_ebdata_copy",
targetSchema: "",
sourceDbType: "mysql",
targetDbType: "mysql",
options: { functions: true },
ignoreComments: false,
label: "mysql → mysql",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(apiMock.listFunctions.mock.calls.length, 2);
assert.equal(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.sourceFunctions?.length, 1);
assert.equal(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.targetFunctions?.length, 1);
});
test("skips listFunctions for cross-family pairs even when functions is enabled", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql",
sourceDatabase: "gd_ebdata",
sourceSchema: "",
targetConnectionId: "oracle",
targetDatabase: "ARISK",
targetSchema: "SYSTEM",
sourceDbType: "mysql",
targetDbType: "oracle",
options: { functions: true },
ignoreComments: false,
label: "mysql → oracle",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(apiMock.listFunctions.mock.calls.length, 0);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.sourceFunctions, []);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.targetFunctions, []);
});
test("skips listFunctions when functions is disabled and routines are unrestricted", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql-src",
sourceDatabase: "gd_ebdata",
sourceSchema: "",
targetConnectionId: "mysql-dst",
targetDatabase: "gd_ebdata_copy",
targetSchema: "",
sourceDbType: "mysql",
targetDbType: "mysql",
options: { functions: false, selectedRoutines: undefined },
ignoreComments: false,
label: "mysql → mysql",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(apiMock.listFunctions.mock.calls.length, 0);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.sourceFunctions, []);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.targetFunctions, []);
});
test("skips listFunctions when functions is disabled even if selectedRoutines is set", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql-src",
sourceDatabase: "gd_ebdata",
sourceSchema: "",
targetConnectionId: "mysql-dst",
targetDatabase: "gd_ebdata_copy",
targetSchema: "",
sourceDbType: "mysql",
targetDbType: "mysql",
options: { functions: false, selectedRoutines: ["p1"] },
ignoreComments: false,
label: "mysql → mysql routines off",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(apiMock.listFunctions.mock.calls.length, 0);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.sourceFunctions, []);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.targetFunctions, []);
});
test("skips listFunctions when only one side supports routines", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql",
sourceDatabase: "app",
sourceSchema: "",
targetConnectionId: "sqlite",
targetDatabase: "local",
targetSchema: "",
sourceDbType: "mysql",
targetDbType: "sqlite",
options: { functions: true },
ignoreComments: false,
label: "mysql → sqlite",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(apiMock.listFunctions.mock.calls.length, 0);
});
test("skips table list loading for routines-only compares", async () => {
apiMock.prepareSchemaDiff.mockClear();
apiMock.listFunctions.mockClear();
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
apiMock.listFunctions.mockResolvedValue([]);
const tableListLoader = { load: vi.fn().mockResolvedValue([{ name: "should_not_load", table_type: "BASE TABLE" }]) };
const session = startSchemaDiffSession(
{
sourceConnectionId: "mysql-src",
sourceDatabase: "gd_ebdata",
sourceSchema: "",
targetConnectionId: "mysql-dst",
targetDatabase: "gd_ebdata_copy",
targetSchema: "",
sourceDbType: "mysql",
targetDbType: "mysql",
options: { tables: false, views: true, functions: true },
ignoreComments: false,
label: "routines only",
},
{ tableListLoader },
);
await waitForSession(session);
assert.equal(session.status, "completed");
assert.equal(tableListLoader.load.mock.calls.length, 0);
assert.equal(apiMock.listFunctions.mock.calls.length, 2);
assert.deepEqual(apiMock.prepareSchemaDiff.mock.calls[0]?.[0]?.sourceTables, []);
});
@@ -10,6 +10,7 @@ function setup(node: Partial<TreeNode>, options: { treeNodes?: TreeNode[]; selec
docsSource: null as unknown,
diagramSource: null as unknown,
databaseExportSource: null as unknown,
schemaDiffSource: null as unknown,
treeNodes: options.treeNodes ?? [],
selectedTreeNodeIds: options.selectedTreeNodeIds ?? [],
};
@@ -107,3 +108,27 @@ describe("useSidebarTreeToolRuntime diagram and database export", () => {
});
});
});
describe("useSidebarTreeToolRuntime openSchemaDiffForRoutine", () => {
it("prefills schema diff with a signature-aware routine key", () => {
const { connectionStore, runtime } = setup({
type: "function",
label: "add(integer)",
objectName: "add",
signature: "integer",
connectionId: "conn-1",
database: "shop",
schema: "public",
});
runtime.openSchemaDiffForRoutine();
expect(connectionStore.schemaDiffSource).toEqual({
connectionId: "conn-1",
database: "shop",
schema: "public",
selectedRoutines: ["add(integer)"],
preferredResultTab: "routines",
});
});
});
@@ -45,6 +45,8 @@ const transferPrefillTargetSchema = ref("");
const schemaDiffPrefillConnectionId = ref("");
const schemaDiffPrefillDatabase = ref("");
const schemaDiffPrefillSchema = ref("");
const schemaDiffPrefillSelectedRoutines = ref<string[] | undefined>(undefined);
const schemaDiffPrefillResultTab = ref<"tables" | "routines" | "">("");
const schemaDiffSessionId = ref<string | null>(null);
const dataComparePrefillConnectionId = ref("");
const dataComparePrefillDatabase = ref("");
@@ -146,6 +148,8 @@ export function useDialogSources() {
schemaDiffPrefillConnectionId.value = v.connectionId;
schemaDiffPrefillDatabase.value = v.database;
schemaDiffPrefillSchema.value = v.schema ?? "";
schemaDiffPrefillSelectedRoutines.value = v.selectedRoutines;
schemaDiffPrefillResultTab.value = v.preferredResultTab ?? "";
schemaDiffSessionId.value = null;
showSchemaDiffDialog.value = true;
connectionStore.schemaDiffSource = null;
@@ -524,6 +528,8 @@ export function useDialogSources() {
schemaDiffPrefillConnectionId,
schemaDiffPrefillDatabase,
schemaDiffPrefillSchema,
schemaDiffPrefillSelectedRoutines,
schemaDiffPrefillResultTab,
schemaDiffSessionId,
dataComparePrefillConnectionId,
dataComparePrefillDatabase,
@@ -5,12 +5,14 @@ import { openSchemaDiffSession } from "@/composables/useDialogSources";
import { useExportTracker } from "@/composables/useExportTracker";
import { filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
import { compileSchemaDiffTableFilter } from "@/lib/schema/schemaDiffTableFilter";
import { filterSchemaDiffFunctions } from "@/lib/schema/schemaDiffRoutine";
import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad";
import type { SchemaDiffTableIdentity, SchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjectPhase, shouldLoadSchemaDiffRoutines, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { schemaDiffRoutineObjectTypesIntersection } from "@/lib/database/databaseObjectCapabilities";
import { convertToSchemaDiffObjects, databaseTypeToDialectKind, normalizeDialectKind, schemaDiffDeployTargetSchema, type SchemaDiffObject } from "@/lib/schema/schemaDiff";
import { normalizeSchemaDiffCompareOptions, type SchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
import type { DatabaseType, FunctionInfo, TableInfo } from "@/types/database";
import type { SchemaDiffPreparation } from "@/lib/schema/schemaDiff";
export interface SchemaDiffSessionProgress {
@@ -59,6 +61,8 @@ function cloneOptions(options: SchemaDiffCompareOptions): SchemaDiffCompareOptio
...options,
selectedTables: options.selectedTables ? [...options.selectedTables] : options.selectedTables,
tableMappings: options.tableMappings?.map((mapping) => ({ ...mapping })),
selectedRoutines: options.selectedRoutines ? [...options.selectedRoutines] : options.selectedRoutines,
routineMappings: options.routineMappings?.map((mapping) => ({ ...mapping })),
fieldMappings: options.fieldMappings?.map((mapping) => ({ ...mapping })),
};
}
@@ -71,6 +75,19 @@ function sessionError(error: unknown): string {
return error instanceof Error ? error.message : (error as { message?: string } | null)?.message || String(error);
}
function filterFunctionsByRoutineKinds(functions: FunctionInfo[], kinds: Array<"PROCEDURE" | "FUNCTION">): FunctionInfo[] {
if (kinds.length === 0) return [];
if (kinds.length === 2) return functions;
const allowProcedure = kinds.includes("PROCEDURE");
const allowFunction = kinds.includes("FUNCTION");
return functions.filter((fn) => {
const type = (fn.function_type || "").toUpperCase();
if (allowProcedure && type.includes("PROC")) return true;
if (allowFunction && (type.includes("FUNC") || type === "" || type === "FUNCTION")) return true;
return false;
});
}
function publishProgress(session: SchemaDiffSession, progress: SchemaDiffSessionProgress): void {
session.version += 1;
session.progress = progress;
@@ -87,68 +104,79 @@ async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: Sc
const input = session.config;
const tracker = useExportTracker();
const options = normalizeSchemaDiffCompareOptions(input.options, input.targetDbType);
const routineKinds = schemaDiffRoutineObjectTypesIntersection(input.sourceDbType as DatabaseType, input.targetDbType);
const loadRoutines = shouldLoadSchemaDiffRoutines(input.sourceDbType, input.targetDbType, options);
const sessionOptions = options;
const isPostgresLike = isSchemaDiffPostgresLike(input.targetDbType);
const hasExtraObjectPhase = shouldLoadSchemaDiffExtraObjects(input.targetDbType, options);
const tableFilter = compileSchemaDiffTableFilter(options);
const hasExtraObjectPhase = shouldLoadSchemaDiffExtraObjectPhase(input.sourceDbType, input.targetDbType, sessionOptions);
const tableFilter = compileSchemaDiffTableFilter(sessionOptions);
const loadTableMetadata = !!(sessionOptions.tables || sessionOptions.views);
try {
publishProgress(session, { phase: "loading-table-lists" });
const sourceTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.sourceConnectionId,
database: input.sourceDatabase,
schema: input.sourceSchema,
};
const targetTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.targetConnectionId,
database: input.targetDatabase,
schema: input.targetSchema,
};
const [sourceTableList, targetTableList] = await Promise.all([dependencies.tableListLoader.load(sourceTableIdentity, { refresh: true }), dependencies.tableListLoader.load(targetTableIdentity, { refresh: true })]);
const { sourceTables, targetTables } = filterSchemaDiffTables(sourceTableList, targetTableList, tableFilter, options, options.selectedTables);
let sourceTables: TableInfo[] = [];
let targetTables: TableInfo[] = [];
let sourceDetails: Awaited<ReturnType<typeof loadSchemaDetails>> = [];
let targetDetails: Awaited<ReturnType<typeof loadSchemaDetails>> = [];
publishProgress(session, { phase: "loading-source-details", current: 0, total: sourceTables.length });
const sourceDetails = await loadSchemaDetails(
sourceTables,
{
if (loadTableMetadata) {
const sourceTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.sourceConnectionId,
database: input.sourceDatabase,
schema: input.sourceSchema,
dbType: input.sourceDbType,
options,
onProgress: (progress) => publishProgress(session, { phase: "loading-source-details", ...progress }),
},
api,
);
publishProgress(session, { phase: "loading-target-details", current: 0, total: targetTables.length });
const targetDetails = await loadSchemaDetails(
targetTables,
{
};
const targetTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.targetConnectionId,
database: input.targetDatabase,
schema: input.targetSchema,
dbType: input.targetDbType,
options,
onProgress: (progress) => publishProgress(session, { phase: "loading-target-details", ...progress }),
},
api,
);
};
const [sourceTableList, targetTableList] = await Promise.all([dependencies.tableListLoader.load(sourceTableIdentity, { refresh: true }), dependencies.tableListLoader.load(targetTableIdentity, { refresh: true })]);
({ sourceTables, targetTables } = filterSchemaDiffTables(sourceTableList, targetTableList, tableFilter, sessionOptions, sessionOptions.selectedTables));
publishProgress(session, { phase: "loading-source-details", current: 0, total: sourceTables.length });
sourceDetails = await loadSchemaDetails(
sourceTables,
{
connectionId: input.sourceConnectionId,
database: input.sourceDatabase,
schema: input.sourceSchema,
dbType: input.sourceDbType,
options: sessionOptions,
onProgress: (progress) => publishProgress(session, { phase: "loading-source-details", ...progress }),
},
api,
);
publishProgress(session, { phase: "loading-target-details", current: 0, total: targetTables.length });
targetDetails = await loadSchemaDetails(
targetTables,
{
connectionId: input.targetConnectionId,
database: input.targetDatabase,
schema: input.targetSchema,
dbType: input.targetDbType,
options: sessionOptions,
onProgress: (progress) => publishProgress(session, { phase: "loading-target-details", ...progress }),
},
api,
);
}
const promises: Promise<unknown>[] = [];
if (isPostgresLike && options.functions) {
if (loadRoutines) {
promises.push(api.listFunctions(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listFunctions(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
if (isPostgresLike && options.sequences) {
promises.push(api.listSequences(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, !!options.sequenceLastValues));
promises.push(api.listSequences(input.targetConnectionId, input.targetDatabase, input.targetSchema, !!options.sequenceLastValues));
if (isPostgresLike && sessionOptions.sequences) {
promises.push(api.listSequences(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, !!sessionOptions.sequenceLastValues));
promises.push(api.listSequences(input.targetConnectionId, input.targetDatabase, input.targetSchema, !!sessionOptions.sequenceLastValues));
}
if (isPostgresLike && options.rules) {
if (isPostgresLike && sessionOptions.rules) {
promises.push(api.listRules(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listRules(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
if (isPostgresLike && options.owners) {
if (isPostgresLike && sessionOptions.owners) {
promises.push(api.listOwners(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listOwners(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
@@ -156,14 +184,15 @@ async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: Sc
if (hasExtraObjectPhase) publishProgress(session, { phase: "loading-extra-objects" });
const extraObjects = await Promise.all(promises);
let index = 0;
const sourceFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listFunctions>>) : [];
const targetFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listFunctions>>) : [];
const sourceSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const targetSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const sourceRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const targetRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const sourceOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
const targetOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
const listedSourceFunctions = loadRoutines ? filterFunctionsByRoutineKinds(extraObjects[index++] as FunctionInfo[], routineKinds) : [];
const listedTargetFunctions = loadRoutines ? filterFunctionsByRoutineKinds(extraObjects[index++] as FunctionInfo[], routineKinds) : [];
const { sourceFunctions, targetFunctions } = filterSchemaDiffFunctions(listedSourceFunctions, listedTargetFunctions, sessionOptions.selectedRoutines, sessionOptions.routineMappings ?? []);
const sourceSequences = sessionOptions.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const targetSequences = sessionOptions.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const sourceRules = sessionOptions.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const targetRules = sessionOptions.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const sourceOwners = sessionOptions.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
const targetOwners = sessionOptions.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
publishProgress(session, { phase: "comparing" });
const result = await api.prepareSchemaDiff({
@@ -179,29 +208,29 @@ async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: Sc
targetRules,
sourceOwners,
targetOwners,
tableMappings: options.selectedTables === undefined ? undefined : options.tableMappings,
tableMappings: sessionOptions.selectedTables === undefined ? undefined : sessionOptions.tableMappings,
databaseType: input.targetDbType,
targetSchema: schemaDiffDeployTargetSchema(input.targetDbType, input.targetDatabase, input.targetSchema),
ignoreComments: input.ignoreComments,
cascadeDelete: options.cascadeDelete ?? false,
compareColumnOrder: options.compareColumnOrder,
ignoreTableNameCase: options.ignoreTableNameCase,
ignoreColumnNameCase: options.ignoreColumnNameCase,
detectRenames: options.detectRenames ?? false,
detectTableRenames: options.detectTableRenames ?? false,
renameThreshold: options.renameThreshold ?? 0.5,
enableRollback: options.enableRollback ?? false,
batchPatterns: options.batchPatterns
? options.batchPatterns
cascadeDelete: sessionOptions.cascadeDelete ?? false,
compareColumnOrder: sessionOptions.compareColumnOrder,
ignoreTableNameCase: sessionOptions.ignoreTableNameCase,
ignoreColumnNameCase: sessionOptions.ignoreColumnNameCase,
detectRenames: sessionOptions.detectRenames ?? false,
detectTableRenames: sessionOptions.detectTableRenames ?? false,
renameThreshold: sessionOptions.renameThreshold ?? 0.5,
enableRollback: sessionOptions.enableRollback ?? false,
batchPatterns: sessionOptions.batchPatterns
? sessionOptions.batchPatterns
.split(",")
.map((value) => value.trim())
.filter(Boolean)
: undefined,
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : input.sourceDbType ? databaseTypeToDialectKind(input.sourceDbType as DatabaseType) : undefined,
targetDialect: options.targetDialect ? normalizeDialectKind(options.targetDialect) : input.targetDbType ? databaseTypeToDialectKind(input.targetDbType) : undefined,
compatibilityThreshold: options.compatibilityThreshold ?? 0.5,
sourceDialect: sessionOptions.sourceDialect ? normalizeDialectKind(sessionOptions.sourceDialect) : input.sourceDbType ? databaseTypeToDialectKind(input.sourceDbType as DatabaseType) : undefined,
targetDialect: sessionOptions.targetDialect ? normalizeDialectKind(sessionOptions.targetDialect) : input.targetDbType ? databaseTypeToDialectKind(input.targetDbType) : undefined,
compatibilityThreshold: sessionOptions.compatibilityThreshold ?? 0.5,
fieldMappings:
options.fieldMappings?.map((mapping) => ({
sessionOptions.fieldMappings?.map((mapping) => ({
sourceType: mapping.sourceType,
targetType: mapping.targetType,
paramStrategy: mapping.paramStrategy ?? "preserve",
@@ -275,7 +304,7 @@ export function removeSchemaDiffSession(id: string): boolean {
}
export function schemaDiffSessionNextProgressStep(session: SchemaDiffSession): string | null {
return getSchemaDiffNextProgressStep(session.progress?.phase, shouldLoadSchemaDiffExtraObjects(session.config.targetDbType, session.config.options));
return getSchemaDiffNextProgressStep(session.progress?.phase, shouldLoadSchemaDiffExtraObjectPhase(session.config.sourceDbType, session.config.targetDbType, session.config.options));
}
export function schemaDiffSessionObjects(result: SchemaDiffPreparation | null): SchemaDiffObject[] {
@@ -4,6 +4,7 @@ import type { useQueryStore } from "@/stores/queryStore";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { TreeNode } from "@/types/database";
import { allDatabasesExportSourceForNode, databaseExportSourceForNode, sidebarSameSchemaStructureTargets } from "@/lib/sidebar/sidebarExportRuntime";
import { schemaDiffRoutineKey } from "@/lib/schema/schemaDiffRoutine";
interface SidebarTreeToolRuntimeOptions {
activeNode: ShallowRef<TreeNode>;
@@ -26,15 +27,26 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
};
}
function openSchemaDiff() {
function openSchemaDiff(options?: { selectedRoutines?: string[]; preferredResultTab?: "tables" | "routines" }) {
if (!activeNode.value.connectionId) return;
connectionStore.schemaDiffSource = {
connectionId: activeNode.value.connectionId,
database: activeNode.value.database ?? "",
schema: activeNode.value.schema,
selectedRoutines: options?.selectedRoutines,
preferredResultTab: options?.preferredResultTab,
};
}
function openSchemaDiffForRoutine() {
const node = activeNode.value;
if (!node.connectionId || (node.type !== "procedure" && node.type !== "function")) return;
openSchemaDiff({
selectedRoutines: [schemaDiffRoutineKey(node.objectName || node.label, node.signature ?? "")],
preferredResultTab: "routines",
});
}
function openDataCompare() {
if (!activeNode.value.connectionId) return;
connectionStore.dataCompareSource = {
@@ -170,6 +182,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
openFieldLineage,
openScheduledBackups,
openSchemaDiff,
openSchemaDiffForRoutine,
openSqlFileExecution,
openStructureEditor,
openTableImport,
+36
View File
@@ -2448,6 +2448,8 @@ export default {
delete: "Delete",
disabled: "Disabled",
back: "Back",
action: "Action",
actions: "Actions",
},
dateTimePicker: {
open: "Open date and time picker",
@@ -6319,8 +6321,42 @@ export default {
tableSelection: "Tables",
chooseTables: "Choose tables...",
compareAllTables: "Compare all tables",
compareTablesSwitch: "Compare tables",
tableCompareDisabled: "Table compare is off. Turn on the switch to include tables.",
tableSelectionUnrestricted: "Comparing all tables (after regex filters). Choose specific tables to limit the comparison.",
sourceTables: "Source tables",
routineSelection: "Routines",
chooseRoutines: "Choose routines...",
compareAllRoutines: "Compare all routines",
compareRoutinesSwitch: "Compare routines",
routineCompareDisabled: "Routine compare is off. Turn on the switch to include procedures and functions.",
routineSelectionUnrestricted: "Comparing all functions and procedures. Choose specific routines to limit the comparison.",
routineSelectionTooLarge: "This schema has {count} routines (limit {limit} for unrestricted compare). Choose specific routines before comparing.",
routineSelectionNeedSource: "Select a source connection and database first to choose routines.",
routineSelectionUnsupported: "Procedure/function compare requires the same supported dialect on both sides (MySQL, SQL Server, or PostgreSQL-family).",
routineSameNameMatchingOnly: "Routines are matched by the same name (and signature). Manual rename mapping is not available in this release.",
sourceRoutines: "Source routines",
noRoutines: "No routines found",
targetRoutineMatching: "Target routine matching",
matchedRoutines: "{matched}/{total} routines matched",
selectTargetRoutine: "Select target routine",
searchTargetRoutine: "Search target routines...",
noTargetRoutines: "No target routines available",
unmatchedRoutineWarning: "{count} selected source routine(s) have no same-name target",
targetRoutineInUse: "This target routine is already used by another source routine",
targetRoutine: "Target routine",
resultTabTables: "Tables ({count})",
resultTabRoutines: "Routines ({count})",
nextStepDeployRoutines: "Next (Review routine deploy)",
routineSyncDeferredHint: "Routine sync/deploy is not available yet. Compare definitions here and copy DDL to apply manually.",
dependencyGraph: "Dependency graph",
routineDiffPoints: "Differences",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "Select a routine to compare definitions",
routinesNotCompared: "Routine compare is off. Turn on the routines switch and re-compare.",
routineDdlCopied: "DDL copied",
copySourceDdl: "Copy source DDL",
copyTargetDdl: "Copy target DDL",
autoMatchHint: "Target tables are matched by the same name.",
matchedTables: "{matched}/{total} tables matched",
missingTargetTables: "Missing same-name target tables: {tables}",
+36
View File
@@ -2357,6 +2357,8 @@ export default withEnglishFallback({
delete: "Eliminar",
disabled: "Desactivado",
back: "Volver",
action: "Acción",
actions: "Acciones",
},
dateTimePicker: {
open: "Abrir selector de fecha y hora",
@@ -5905,6 +5907,35 @@ export default withEnglishFallback({
compareAllTables: "Comparar todas las tablas",
tableSelectionUnrestricted: "Comparando todas las tablas (después de los filtros regex). Elige tablas específicas para limitar la comparación.",
sourceTables: "Tablas de origen",
routineSelection: "Rutinas",
chooseRoutines: "Elegir rutinas...",
compareAllRoutines: "Comparar todas las rutinas",
compareRoutinesSwitch: "Comparar rutinas",
routineCompareDisabled: "La comparación de rutinas está desactivada. Activa el interruptor para incluir procedimientos y funciones.",
routineSelectionUnrestricted: "Comparando todas las funciones y procedimientos. Elige rutinas específicas para limitar la comparación.",
routineSelectionTooLarge: "Este esquema tiene {count} rutinas (límite {limit} sin selección). Elige rutinas específicas antes de comparar.",
routineSelectionNeedSource: "Selecciona primero una conexión y base de datos de origen para elegir rutinas.",
routineSelectionUnsupported: "La comparación de procedimientos/funciones requiere el mismo dialecto admitido en ambos lados (MySQL, SQL Server o familia PostgreSQL).",
routineSameNameMatchingOnly: "Las rutinas se emparejan por el mismo nombre (y firma). El mapeo manual de renombres no está disponible en esta versión.",
sourceRoutines: "Rutinas de origen",
noRoutines: "No se encontraron rutinas",
targetRoutineMatching: "Emparejamiento de rutinas de destino",
matchedRoutines: "{matched}/{total} rutinas coinciden",
selectTargetRoutine: "Selecciona una rutina de destino",
searchTargetRoutine: "Buscar rutinas de destino...",
noTargetRoutines: "No hay rutinas de destino disponibles",
unmatchedRoutineWarning: "{count} rutina(s) de origen seleccionada(s) no tienen destino con el mismo nombre",
targetRoutineInUse: "Esta rutina de destino ya está usada por otra rutina de origen",
targetRoutine: "Rutina de destino",
resultTabTables: "Tablas ({count})",
resultTabRoutines: "Rutinas ({count})",
nextStepDeployRoutines: "Siguiente (Revisar despliegue de rutinas)",
routineSyncDeferredHint: "La sincronización/despliegue de rutinas aún no está disponible. Compara definiciones aquí y copia el DDL para aplicarlo manualmente.",
routineDiffPoints: "Diferencias",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "Selecciona una rutina para comparar definiciones",
routinesNotCompared: "La comparación de rutinas está desactivada. Activa el interruptor de rutinas y vuelve a comparar.",
routineDdlCopied: "DDL copiado",
autoMatchHint: "Las tablas de destino se emparejan por el mismo nombre.",
matchedTables: "{matched}/{total} tablas coinciden",
missingTargetTables: "Faltan tablas de destino con el mismo nombre: {tables}",
@@ -6046,6 +6077,11 @@ export default withEnglishFallback({
deployMixedTitle: "Parcialmente Desplegado",
deployRolledBackTitle: "Revertido",
deployMixedWarning: "Algunas declaraciones pueden haberse aplicado mientras que otras fallaron. La base de datos puede estar en un estado inconsistente. Revise el registro de transacciones y tome medidas correctivas manuales antes de reintentar.",
compareTablesSwitch: "Comparar tablas",
tableCompareDisabled: "Desactivado; no se comparan tablas. Active el interruptor para incluirlas.",
dependencyGraph: "Grafo de dependencias",
copySourceDdl: "Copiar DDL de origen",
copyTargetDdl: "Copiar DDL de destino",
},
rollbackComparison: {
title: "Comparación de Reversión",
+36
View File
@@ -2355,6 +2355,8 @@ export default withEnglishFallback({
delete: "Elimina",
disabled: "Disabilitato",
back: "Indietro",
action: "Azione",
actions: "Azioni",
},
dateTimePicker: {
open: "Apri selettore data e ora",
@@ -5905,6 +5907,35 @@ export default withEnglishFallback({
compareAllTables: "Confronta tutte le tabelle",
tableSelectionUnrestricted: "Confronto di tutte le tabelle (dopo i filtri regex). Scegli tabelle specifiche per limitare il confronto.",
sourceTables: "Tabelle sorgente",
routineSelection: "Routine",
chooseRoutines: "Scegli routine...",
compareAllRoutines: "Confronta tutte le routine",
compareRoutinesSwitch: "Confronta routine",
routineCompareDisabled: "Il confronto delle routine è disattivato. Attiva l'interruttore per includere procedure e funzioni.",
routineSelectionUnrestricted: "Confronto di tutte le funzioni e procedure. Scegli routine specifiche per limitare il confronto.",
routineSelectionTooLarge: "Questo schema ha {count} routine (limite {limit} senza selezione). Scegli routine specifiche prima di confrontare.",
routineSelectionNeedSource: "Seleziona prima una connessione e un database di origine per scegliere le routine.",
routineSelectionUnsupported: "Il confronto di procedure/funzioni richiede lo stesso dialetto supportato su entrambi i lati (MySQL, SQL Server o famiglia PostgreSQL).",
routineSameNameMatchingOnly: "Le routine vengono abbinate per lo stesso nome (e firma). Il mapping manuale di rinomina non è disponibile in questa versione.",
sourceRoutines: "Routine di origine",
noRoutines: "Nessuna routine trovata",
targetRoutineMatching: "Abbinamento routine di destinazione",
matchedRoutines: "{matched}/{total} routine abbinate",
selectTargetRoutine: "Seleziona routine di destinazione",
searchTargetRoutine: "Cerca routine di destinazione...",
noTargetRoutines: "Nessuna routine di destinazione disponibile",
unmatchedRoutineWarning: "{count} routine di origine selezionate non hanno una destinazione con lo stesso nome",
targetRoutineInUse: "Questa routine di destinazione è già usata da un'altra routine di origine",
targetRoutine: "Routine di destinazione",
resultTabTables: "Tabelle ({count})",
resultTabRoutines: "Routine ({count})",
nextStepDeployRoutines: "Avanti (Revisione distribuzione routine)",
routineSyncDeferredHint: "Sincronizzazione/distribuzione delle routine non ancora disponibile. Confronta le definizioni qui e copia il DDL da applicare manualmente.",
routineDiffPoints: "Differenze",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "Seleziona una routine per confrontare le definizioni",
routinesNotCompared: "Il confronto delle routine è disattivato. Attiva l'interruttore e confronta di nuovo.",
routineDdlCopied: "DDL copiato",
autoMatchHint: "Le tabelle di destinazione vengono abbinate per nome.",
matchedTables: "{matched}/{total} tabelle abbinate",
missingTargetTables: "Tabelle di destinazione omonime mancanti: {tables}",
@@ -6046,6 +6077,11 @@ export default withEnglishFallback({
deployMixedTitle: "Parzialmente Distribuito",
deployRolledBackTitle: "Annullato",
deployMixedWarning: "Alcune istruzioni potrebbero essere già applicate mentre altre sono fallite. Il database potrebbe trovarsi in uno stato inconsistente. Controllare il log delle transazioni e intervenire manualmente prima di riprovare.",
compareTablesSwitch: "Confronta tabelle",
tableCompareDisabled: "Confronto tabelle disattivato. Attiva l'interruttore per includere le tabelle.",
dependencyGraph: "Grafo delle dipendenze",
copySourceDdl: "Copia DDL sorgente",
copyTargetDdl: "Copia DDL destinazione",
},
rollbackComparison: {
title: "Confronto Rollback",
+36
View File
@@ -2382,6 +2382,8 @@ export default withEnglishFallback({
delete: "削除",
disabled: "無効",
back: "戻る",
action: "操作",
actions: "操作",
},
dateTimePicker: {
open: "日時ピッカーを開く",
@@ -5999,6 +6001,35 @@ export default withEnglishFallback({
compareAllTables: "すべてのテーブルを比較",
tableSelectionUnrestricted: "正規表現フィルター後のすべてのテーブルを比較しています。比較対象を限定するにはテーブルを選択してください。",
sourceTables: "ソーステーブル",
routineSelection: "ルーチン",
chooseRoutines: "ルーチンを選択...",
compareAllRoutines: "すべてのルーチンを比較",
compareRoutinesSwitch: "ルーチンを比較",
routineCompareDisabled: "ルーチン比較はオフです。スイッチをオンにするとストアドプロシージャと関数が含まれます。",
routineSelectionUnrestricted: "すべての関数とプロシージャを比較します。特定のルーチンを選ぶと範囲を限定できます。",
routineSelectionTooLarge: "このスキーマには {count} 件のルーチンがあります(未指定時の上限は {limit})。比較前にルーチンを選択してください。",
routineSelectionNeedSource: "ルーチンを選ぶ前に、ソースの接続とデータベースを選択してください。",
routineSelectionUnsupported: "プロシージャ/関数の比較には、ソースとターゲットが同じ対応方言(MySQL、SQL Server、または PostgreSQL 系)である必要があります。",
routineSameNameMatchingOnly: "ルーチンは同名(およびシグネチャ)で照合します。このリリースでは手動のリネーム対応付けは未対応です。",
sourceRoutines: "ソースのルーチン",
noRoutines: "ルーチンがありません",
targetRoutineMatching: "ターゲットのルーチン照合",
matchedRoutines: "{matched}/{total} 件のルーチンが一致",
selectTargetRoutine: "ターゲットのルーチンを選択",
searchTargetRoutine: "ターゲットのルーチンを検索...",
noTargetRoutines: "利用可能なターゲットのルーチンがありません",
unmatchedRoutineWarning: "選択したソースのルーチンのうち {count} 件に同名のターゲットがありません",
targetRoutineInUse: "このターゲットのルーチンは別のソースのルーチンで既に使用されています",
targetRoutine: "ターゲットのルーチン",
resultTabTables: "テーブル ({count})",
resultTabRoutines: "ルーチン ({count})",
nextStepDeployRoutines: "次へ(ルーチンのデプロイを確認)",
routineSyncDeferredHint: "ルーチンの同期/デプロイはまだ利用できません。ここで定義を比較し、DDL をコピーして手動適用してください。",
routineDiffPoints: "差分",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "定義を比較するルーチンを選択",
routinesNotCompared: "ルーチン比較はオフです。スイッチをオンにして再比較してください。",
routineDdlCopied: "DDL をコピーしました",
autoMatchHint: "ターゲットテーブルは同じ名前でマッチングされます。",
matchedTables: "{matched}/{total}テーブルがマッチしました",
missingTargetTables: "同名のターゲットテーブルがありません: {tables}",
@@ -6084,6 +6115,11 @@ export default withEnglishFallback({
deployMixedTitle: "部分的にデプロイ",
deployRolledBackTitle: "ロールバック済み",
deployMixedWarning: "一部のステートメントは既に適用済みで、他のステートメントは失敗した可能性があります。データベースが不整合な状態にある可能性があります。再試行前にトランザクションログを確認し、手動で修正してください。",
compareTablesSwitch: "テーブルを比較",
tableCompareDisabled: "テーブル比較はオフです。スイッチをオンにするとテーブルが比較対象になります。",
dependencyGraph: "依存関係グラフ",
copySourceDdl: "ソース DDL をコピー",
copyTargetDdl: "ターゲット DDL をコピー",
},
rollbackComparison: {
title: "ロールバック比較",
+36
View File
@@ -2256,6 +2256,8 @@ export default withEnglishFallback({
delete: "삭제",
disabled: "비활성화",
back: "뒤로",
action: "작업",
actions: "작업",
},
dateTimePicker: {
open: "날짜 및 시간 선택기 열기",
@@ -5795,6 +5797,40 @@ export default withEnglishFallback({
deployMixedTitle: "부분 배포됨",
deployRolledBackTitle: "롤백됨",
deployMixedWarning: "일부 구문은 이미 적용되었고 다른 일부는 실패했을 수 있습니다. 데이터베이스가 불일치 상태일 수 있습니다. 트랜잭션 로그를 검토하고 다시 시도하기 전에 수동으로 조치를 취하세요.",
compareTablesSwitch: "테이블 비교",
tableCompareDisabled: "테이블 비교가 꺼져 있습니다. 스위치를 켜면 테이블이 포함됩니다.",
routineSelection: "루틴",
chooseRoutines: "루틴 선택...",
compareAllRoutines: "모든 루틴 비교",
compareRoutinesSwitch: "루틴 비교",
routineCompareDisabled: "루틴 비교가 꺼져 있습니다. 스위치를 켜면 프로시저와 함수가 포함됩니다.",
routineSelectionUnrestricted: "모든 함수와 프로시저를 비교합니다. 특정 루틴을 선택하면 비교 범위를 제한할 수 있습니다.",
routineSelectionTooLarge: "이 스키마에 루틴이 {count}개 있습니다(미선택 시 한도 {limit}). 비교 전에 루틴을 선택하세요.",
routineSelectionNeedSource: "루틴을 선택하려면 먼저 소스 연결과 데이터베이스를 선택하세요.",
routineSelectionUnsupported: "프로시저/함수 비교는 양쪽이 같은 지원 방언(MySQL, SQL Server 또는 PostgreSQL 계열)이어야 합니다.",
routineSameNameMatchingOnly: "루틴은 같은 이름(및 시그니처)으로 매칭됩니다. 이 버전에서는 수동 이름 변경 매핑을 사용할 수 없습니다.",
sourceRoutines: "소스 루틴",
noRoutines: "루틴 없음",
targetRoutineMatching: "대상 루틴 매칭",
matchedRoutines: "{matched}/{total}개 루틴 매칭됨",
selectTargetRoutine: "대상 루틴 선택",
searchTargetRoutine: "대상 루틴 검색...",
noTargetRoutines: "사용 가능한 대상 루틴이 없습니다",
unmatchedRoutineWarning: "선택한 소스 루틴 {count}개에 같은 이름의 대상이 없습니다",
targetRoutineInUse: "이 대상 루틴은 이미 다른 소스 루틴에서 사용 중입니다",
targetRoutine: "대상 루틴",
resultTabTables: "테이블 ({count})",
resultTabRoutines: "루틴 ({count})",
nextStepDeployRoutines: "다음 (루틴 배포 검토)",
routineSyncDeferredHint: "루틴 동기화/배포는 아직 사용할 수 없습니다. 여기서 정의를 비교하고 DDL을 복사해 수동으로 적용하세요.",
dependencyGraph: "의존성 그래프",
routineDiffPoints: "차이점",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "정의를 비교할 루틴을 선택하세요",
routinesNotCompared: "루틴 비교가 꺼져 있습니다. 루틴 스위치를 켠 뒤 다시 비교하세요.",
routineDdlCopied: "DDL을 복사했습니다",
copySourceDdl: "소스 DDL 복사",
copyTargetDdl: "대상 DDL 복사",
},
rollbackComparison: {
title: "롤백 비교",
+36
View File
@@ -2357,6 +2357,8 @@ export default withEnglishFallback({
delete: "Excluir",
disabled: "Desativado",
back: "Voltar",
action: "Ação",
actions: "Ações",
},
dateTimePicker: {
open: "Abrir seletor de data e hora",
@@ -5907,6 +5909,35 @@ export default withEnglishFallback({
compareAllTables: "Comparar todas as tabelas",
tableSelectionUnrestricted: "Comparando todas as tabelas (após os filtros regex). Escolha tabelas específicas para limitar a comparação.",
sourceTables: "Tabelas de origem",
routineSelection: "Rotinas",
chooseRoutines: "Escolher rotinas...",
compareAllRoutines: "Comparar todas as rotinas",
compareRoutinesSwitch: "Comparar rotinas",
routineCompareDisabled: "A comparação de rotinas está desativada. Ative o interruptor para incluir procedimentos e funções.",
routineSelectionUnrestricted: "Comparando todas as funções e procedimentos. Escolha rotinas específicas para limitar a comparação.",
routineSelectionTooLarge: "Este schema tem {count} rotinas (limite {limit} sem seleção). Escolha rotinas específicas antes de comparar.",
routineSelectionNeedSource: "Selecione primeiro uma conexão e um banco de origem para escolher rotinas.",
routineSelectionUnsupported: "A comparação de procedimentos/funções exige o mesmo dialeto suportado em ambos os lados (MySQL, SQL Server ou família PostgreSQL).",
routineSameNameMatchingOnly: "As rotinas são correspondidas pelo mesmo nome (e assinatura). O mapeamento manual de renomeação não está disponível nesta versão.",
sourceRoutines: "Rotinas de origem",
noRoutines: "Nenhuma rotina encontrada",
targetRoutineMatching: "Correspondência de rotinas de destino",
matchedRoutines: "{matched}/{total} rotinas correspondidas",
selectTargetRoutine: "Selecionar rotina de destino",
searchTargetRoutine: "Pesquisar rotinas de destino...",
noTargetRoutines: "Nenhuma rotina de destino disponível",
unmatchedRoutineWarning: "{count} rotina(s) de origem selecionada(s) não têm destino com o mesmo nome",
targetRoutineInUse: "Esta rotina de destino já é usada por outra rotina de origem",
targetRoutine: "Rotina de destino",
resultTabTables: "Tabelas ({count})",
resultTabRoutines: "Rotinas ({count})",
nextStepDeployRoutines: "Próximo (Revisar implantação das rotinas)",
routineSyncDeferredHint: "Sincronização/implantação de rotinas ainda não está disponível. Compare as definições aqui e copie o DDL para aplicar manualmente.",
routineDiffPoints: "Diferenças",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "Selecione uma rotina para comparar definições",
routinesNotCompared: "A comparação de rotinas está desativada. Ative o interruptor e compare novamente.",
routineDdlCopied: "DDL copiado",
autoMatchHint: "As tabelas de destino são correspondidas pelo mesmo nome.",
matchedTables: "{matched}/{total} tabelas correspondidas",
missingTargetTables: "Tabelas de destino com o mesmo nome ausentes: {tables}",
@@ -6048,6 +6079,11 @@ export default withEnglishFallback({
deployMixedTitle: "Parcialmente Implantado",
deployRolledBackTitle: "Revertido",
deployMixedWarning: "Algumas instruções podem já ter sido aplicadas enquanto outras falharam. O banco de dados pode estar em um estado inconsistente. Revise o log de transações e tome ação corretiva manual antes de tentar novamente.",
compareTablesSwitch: "Comparar tabelas",
tableCompareDisabled: "A comparação de tabelas está desativada. Ative o interruptor para incluir tabelas.",
dependencyGraph: "Grafo de dependências",
copySourceDdl: "Copiar DDL de origem",
copyTargetDdl: "Copiar DDL de destino",
},
rollbackComparison: {
title: "Comparação de Reversão",
+36
View File
@@ -2371,6 +2371,8 @@ export default withEnglishFallback({
delete: "删除",
disabled: "禁用",
back: "返回",
action: "操作",
actions: "操作",
},
dateTimePicker: {
open: "打开日期时间选择器",
@@ -6386,8 +6388,42 @@ export default withEnglishFallback({
tableSelection: "表",
chooseTables: "指定表...",
compareAllTables: "比较所有表",
compareTablesSwitch: "比较表",
tableCompareDisabled: "已关闭,不比较表。打开开关后才会纳入对比。",
tableSelectionUnrestricted: "当前比较所有表(经过正则过滤后)。可指定表来限定比较范围。",
sourceTables: "源表",
routineSelection: "存储过程 / 函数",
chooseRoutines: "指定对象...",
compareAllRoutines: "比较全部",
compareRoutinesSwitch: "比较存储过程/函数",
routineCompareDisabled: "已关闭,不比较存储过程/函数。打开开关后才会纳入对比。",
routineSelectionUnrestricted: "比较全部函数与存储过程。可指定对象来限定比较范围。",
routineSelectionTooLarge: "当前库有 {count} 个例程(未指定时最多比较 {limit} 个)。请先指定要比较的存储过程/函数。",
routineSelectionNeedSource: "请先选择源连接和数据库,再指定要比较的存储过程或函数。",
routineSelectionUnsupported: "源与目标需为同一受支持方言(MySQL、SQL Server 或 PostgreSQL 系),且能列出存储过程/函数源码,才能开启例程对比。",
routineSameNameMatchingOnly: "例程按同名(及签名)自动匹配。本版本暂不支持手动重命名映射。",
sourceRoutines: "源对象",
noRoutines: "未找到存储过程或函数",
targetRoutineMatching: "目标对象匹配",
matchedRoutines: "已匹配 {matched}/{total} 个对象",
selectTargetRoutine: "请选择目标对象",
searchTargetRoutine: "搜索目标对象...",
noTargetRoutines: "暂无目标对象",
unmatchedRoutineWarning: "还有 {count} 个源对象没有同名目标",
targetRoutineInUse: "该目标对象已被其他源对象使用",
targetRoutine: "目标对象",
resultTabTables: "表对比 ({count})",
resultTabRoutines: "存储过程 ({count})",
nextStepDeployRoutines: "下一步:部署存储过程",
routineSyncDeferredHint: "存储过程一键同步尚未提供。可在此对比定义,并复制 DDL 后手动执行。",
dependencyGraph: "依赖图",
routineDiffPoints: "差异点",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "选择一条存储过程以对比定义",
routinesNotCompared: "未开启存储过程对比。请打开上方开关后重新比较。",
routineDdlCopied: "已复制 DDL",
copySourceDdl: "复制源 DDL",
copyTargetDdl: "复制目标 DDL",
autoMatchHint: "目标表按同名自动匹配。",
matchedTables: "已匹配 {matched}/{total} 张表",
missingTargetTables: "缺少同名的目标表:{tables}",
+36
View File
@@ -2355,6 +2355,8 @@ export default withEnglishFallback({
delete: "刪除",
disabled: "停用",
back: "返回",
action: "操作",
actions: "操作",
},
dateTimePicker: {
open: "開啟日期時間選擇器",
@@ -5241,6 +5243,35 @@ export default withEnglishFallback({
compareAllTables: "比較所有資料表",
tableSelectionUnrestricted: "目前比較所有資料表(套用正規表示式篩選後)。選擇特定資料表以限制比較範圍。",
sourceTables: "來源資料表",
routineSelection: "預存程序 / 函數",
chooseRoutines: "選擇預存程序...",
compareAllRoutines: "比較全部預存程序",
compareRoutinesSwitch: "比較預存程序/函數",
routineCompareDisabled: "已關閉,不比較預存程序/函數。打開開關後才會納入對比。",
routineSelectionUnrestricted: "比較全部函數與預存程序。可指定物件來限定比較範圍。",
routineSelectionTooLarge: "目前庫有 {count} 個例程(未指定時最多比較 {limit} 個)。請先指定要比較的預存程序/函數。",
routineSelectionNeedSource: "請先選擇來源連線與資料庫,再指定要比較的預存程序或函數。",
routineSelectionUnsupported: "來源與目標需為同一受支援方言(MySQL、SQL Server 或 PostgreSQL 系),且能列出預存程序/函數原始碼,才能開啟例程對比。",
routineSameNameMatchingOnly: "例程按同名(及簽章)自動匹配。本版本暫不支援手動重新命名對應。",
sourceRoutines: "來源例程",
noRoutines: "找不到例程",
targetRoutineMatching: "目標例程對應",
matchedRoutines: "已匹配 {matched}/{total} 個例程",
selectTargetRoutine: "選擇目標例程",
searchTargetRoutine: "搜尋目標例程...",
noTargetRoutines: "沒有可用的目標例程",
unmatchedRoutineWarning: "有 {count} 個已選來源例程沒有同名目標",
targetRoutineInUse: "此目標例程已被另一個來源例程使用",
targetRoutine: "目標例程",
resultTabTables: "資料表 ({count})",
resultTabRoutines: "預存程序 ({count})",
nextStepDeployRoutines: "下一步:部署預存程序",
routineSyncDeferredHint: "預存程序一鍵同步尚未提供。可在此對比定義,並複製 DDL 後手動執行。",
routineDiffPoints: "差異點",
routineDiffStats: "+{added} −{removed} ~{modified}",
selectRoutineToCompare: "選擇一條預存程序以對比定義",
routinesNotCompared: "未開啟預存程序對比。請打開上方開關後重新比較。",
routineDdlCopied: "已複製 DDL",
autoMatchHint: "目標資料表會依相同名稱進行比對。",
matchedTables: "已比對 {matched}/{total} 張資料表",
missingTargetTables: "缺少同名的目標資料表:{tables}",
@@ -5366,6 +5397,11 @@ export default withEnglishFallback({
deployMixedTitle: "部分已部署",
deployRolledBackTitle: "已回溯",
deployMixedWarning: "部分語句可能已生效,其餘語句失敗。資料庫可能處於不一致狀態,請在重試前檢查交易日誌並手動處理。",
compareTablesSwitch: "比較表",
tableCompareDisabled: "已關閉,不比較表。打開開關後才會納入對比。",
dependencyGraph: "依賴圖",
copySourceDdl: "複製來源 DDL",
copyTargetDdl: "複製目標 DDL",
},
rollbackComparison: {
title: "回溯對比",
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { customTypeCapabilities, databaseObjectCapabilities, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import {
customTypeCapabilities,
databaseObjectCapabilities,
normalizeSidebarObjectKind,
schemaDiffRoutineObjectTypes,
schemaDiffRoutineObjectTypesIntersection,
sidebarObjectKindsForDatabase,
supportsPackageMemberExpansion,
supportsSchemaDiffRoutines,
supportsTypeObjectSource,
} from "@/lib/database/databaseObjectCapabilities";
import { buildObjectGroupPlaceholderNodes } from "@/lib/table/tableTree";
describe("databaseObjectCapabilities", () => {
@@ -128,4 +138,24 @@ describe("databaseObjectCapabilities", () => {
expect(customTypeCapabilities(dbType), String(dbType)).toEqual({ details: false, members: false, ddl: false });
}
});
it("opens schema diff routines only for same-dialect allowlisted databases", () => {
for (const dbType of ["postgres", "opengauss", "mysql", "sqlserver", "gaussdb", "kingbase"] as const) {
expect(supportsSchemaDiffRoutines(dbType), dbType).toBe(true);
}
for (const dbType of ["oracle", "dameng", "hive", "sqlite", "doris", "databend", "manticoresearch"] as const) {
expect(supportsSchemaDiffRoutines(dbType), dbType).toBe(false);
}
// Fallback ROUTINE_OBJECTS must not open schema-diff routine compare.
expect(supportsSchemaDiffRoutines("jdbc")).toBe(false);
expect(supportsSchemaDiffRoutines(undefined)).toBe(false);
expect(schemaDiffRoutineObjectTypes("mysql")).toEqual(["PROCEDURE", "FUNCTION"]);
expect(schemaDiffRoutineObjectTypes("sqlserver")).toEqual(["PROCEDURE", "FUNCTION"]);
expect(schemaDiffRoutineObjectTypes("oracle")).toEqual([]);
expect(schemaDiffRoutineObjectTypesIntersection("mysql", "mysql")).toEqual(["PROCEDURE", "FUNCTION"]);
expect(schemaDiffRoutineObjectTypesIntersection("mysql", "oracle")).toEqual([]);
expect(schemaDiffRoutineObjectTypesIntersection("mysql", "sqlserver")).toEqual([]);
expect(schemaDiffRoutineObjectTypesIntersection("postgres", "opengauss")).toEqual(["PROCEDURE", "FUNCTION"]);
expect(sidebarObjectKindsForDatabase("sqlserver")).toEqual(["TABLE", "VIEW", "PROCEDURE", "FUNCTION"]);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createConcurrencyLimiter, loadSchemaDetails, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan, shouldFetchSchemaDiffDdl, type SchemaDiffMetadataApi, type SchemaDiffMetadataProgress } from "../../schema/schemaDiffMetadataLoad";
import { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjects } from "../../schema/schemaDiffProgress";
import { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjectPhase, shouldLoadSchemaDiffExtraObjects, shouldLoadSchemaDiffRoutines } from "../../schema/schemaDiffProgress";
import { DEFAULT_MYSQL_OPTIONS, DEFAULT_POSTGRES_OPTIONS } from "../../../types/schemaDiff";
import type { TableInfo } from "../../../types/database";
@@ -187,6 +187,16 @@ describe("schemaDiffMetadataLoad", () => {
).toBe(false);
});
it("treats supported same-dialect routine compare as an extra-object progress phase", () => {
expect(shouldLoadSchemaDiffRoutines("mysql", "mysql", { functions: true })).toBe(true);
expect(shouldLoadSchemaDiffRoutines("mysql", "oracle", { functions: true })).toBe(false);
expect(shouldLoadSchemaDiffRoutines("mysql", "sqlite", { functions: true })).toBe(false);
expect(shouldLoadSchemaDiffRoutines("mysql", "mysql", { functions: false })).toBe(false);
expect(shouldLoadSchemaDiffExtraObjectPhase("mysql", "mysql", DEFAULT_MYSQL_OPTIONS)).toBe(true);
expect(shouldLoadSchemaDiffExtraObjectPhase("mysql", "mysql", { ...DEFAULT_MYSQL_OPTIONS, functions: false })).toBe(false);
expect(shouldLoadSchemaDiffExtraObjectPhase("postgres", "postgres", DEFAULT_POSTGRES_OPTIONS)).toBe(true);
});
it("maps phases to the next step on the actual comparison path", () => {
expect(getSchemaDiffNextProgressStep("loading-table-lists", false)).toBe("nextSourceDetails");
expect(getSchemaDiffNextProgressStep("loading-source-details", false)).toBe("nextTargetDetails");
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
countSchemaDiffActionableObjects,
filterSchemaDiffFunctions,
isSchemaDiffUnrestrictedRoutineLoadTooLarge,
partitionSchemaDiffObjectsByResultTab,
SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT,
schemaDiffRoutineKey,
schemaDiffRoutineKeyFromFunction,
summarizeSchemaDiffRoutineTextDiff,
} from "@/lib/schema/schemaDiffRoutine";
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
import type { FunctionInfo } from "@/types/database";
function fn(name: string, args = "", definition = "body"): FunctionInfo {
return { name, function_type: "FUNCTION", data_type: "void", definition, arguments: args };
}
function obj(partial: Partial<SchemaDiffObject> & Pick<SchemaDiffObject, "id" | "objectKind" | "operationType" | "name">): SchemaDiffObject {
return {
selected: true,
...partial,
};
}
describe("schemaDiffRoutine", () => {
it("builds stable routine keys for overloaded functions", () => {
expect(schemaDiffRoutineKey("add")).toBe("add");
expect(schemaDiffRoutineKey("add", "integer")).toBe("add(integer)");
expect(schemaDiffRoutineKeyFromFunction(fn("add", "integer, text"))).toBe("add(integer, text)");
});
it("blocks unrestricted compare when routine count exceeds the soft limit", () => {
expect(SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT).toBe(200);
expect(isSchemaDiffUnrestrictedRoutineLoadTooLarge(200, undefined)).toBe(false);
expect(isSchemaDiffUnrestrictedRoutineLoadTooLarge(201, undefined)).toBe(true);
expect(isSchemaDiffUnrestrictedRoutineLoadTooLarge(500, ["a"])).toBe(false);
});
it("filters selected source routines and same-name mapped targets", () => {
const source = [fn("a"), fn("b", "int"), fn("c")];
const target = [fn("a"), fn("b", "int"), fn("c"), fn("orphan")];
const filtered = filterSchemaDiffFunctions(source, target, ["a", "b(int)"], [{ sourceRoutine: "b(int)", targetRoutine: "b(int)" }]);
expect(filtered.sourceFunctions.map((item) => schemaDiffRoutineKeyFromFunction(item))).toEqual(["a", "b(int)"]);
expect(filtered.targetFunctions.map((item) => schemaDiffRoutineKeyFromFunction(item))).toEqual(["a", "b(int)"]);
});
it("ignores non-identity rename mappings until backend rematch exists", () => {
const source = [fn("a"), fn("b", "int"), fn("c")];
const target = [fn("a"), fn("b_new", "int"), fn("c"), fn("orphan")];
const filtered = filterSchemaDiffFunctions(source, target, ["a", "b(int)"], [{ sourceRoutine: "b(int)", targetRoutine: "b_new(int)" }]);
expect(filtered.sourceFunctions.map((item) => schemaDiffRoutineKeyFromFunction(item))).toEqual(["a", "b(int)"]);
// Without same-name target for b(int), only auto-matched "a" is kept on the target side.
expect(filtered.targetFunctions.map((item) => schemaDiffRoutineKeyFromFunction(item))).toEqual(["a"]);
});
it("keeps all functions when selection is unrestricted", () => {
const source = [fn("a")];
const target = [fn("a"), fn("b")];
expect(filterSchemaDiffFunctions(source, target, undefined)).toEqual({ sourceFunctions: source, targetFunctions: target });
});
it("partitions result objects into table and routine tabs", () => {
const objects = [obj({ id: "t1", objectKind: "table", operationType: "modify", name: "t1" }), obj({ id: "f1", objectKind: "function", operationType: "create", name: "f1" }), obj({ id: "v1", objectKind: "view", operationType: "none", name: "v1" })];
const { tableObjects, routineObjects } = partitionSchemaDiffObjectsByResultTab(objects);
expect(tableObjects.map((item) => item.id)).toEqual(["t1", "v1"]);
expect(routineObjects.map((item) => item.id)).toEqual(["f1"]);
expect(countSchemaDiffActionableObjects(tableObjects)).toBe(1);
expect(countSchemaDiffActionableObjects(routineObjects)).toBe(1);
});
it("summarizes routine DDL line diffs for create, delete, and modify", () => {
expect(summarizeSchemaDiffRoutineTextDiff(undefined, undefined)).toEqual({ added: 0, removed: 0, modified: 0 });
expect(summarizeSchemaDiffRoutineTextDiff("a\nb\nc", "")).toEqual({ added: 3, removed: 0, modified: 0 });
expect(summarizeSchemaDiffRoutineTextDiff("", "x\ny")).toEqual({ added: 0, removed: 2, modified: 0 });
const modified = summarizeSchemaDiffRoutineTextDiff("line1\nold\nline3", "line1\nnew\nline3\nextra");
expect(modified.modified).toBeGreaterThanOrEqual(1);
expect(modified.added).toBeGreaterThanOrEqual(1);
expect(modified.removed).toBe(0);
});
});
@@ -92,6 +92,8 @@ describe("schema diff table mappings", () => {
expect(normalizeSchemaDiffCompareOptions({}).tableMappings).toEqual([]);
expect(normalizeSchemaDiffCompareOptions({}).ignoreTableNameCase).toBe(false);
expect(normalizeSchemaDiffCompareOptions({}).ignoreColumnNameCase).toBe(false);
expect(normalizeSchemaDiffCompareOptions({ tables: false, views: true }, "mysql").views).toBe(false);
expect(normalizeSchemaDiffCompareOptions({ tables: true, views: true }, "mysql").views).toBe(true);
});
it("swaps each mapping direction", () => {
@@ -55,6 +55,10 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["oceanbase-oracle", OCEANBASE_ORACLE_OBJECTS],
["xugu", XUGU_OBJECTS],
["mysql", MYSQL_OBJECTS],
// Explicit entry so schema-diff routine gating can opt in without relying on the
// unknown-type ROUTINE_OBJECTS fallback. Keep the same object set the fallback
// already used (no TRIGGER/SEQUENCE expansion in this change).
["sqlserver", ROUTINE_OBJECTS],
// table and view
["sqlite", TABLE_VIEW_OBJECTS],
["rqlite", TABLE_VIEW_OBJECTS],
@@ -119,6 +123,49 @@ export function databaseObjectCapabilities(dbType?: DatabaseType): DatabaseObjec
};
}
const SCHEMA_DIFF_ROUTINE_KINDS = ["PROCEDURE", "FUNCTION"] as const;
/** Same-dialect families with verified list_objects + get_object_source (or PG catalog) paths. */
const SCHEMA_DIFF_ROUTINE_FAMILY = new Map<DatabaseType, "postgres" | "mysql" | "sqlserver">([
["postgres", "postgres"],
["opengauss", "postgres"],
["gaussdb", "postgres"],
["kwdb", "postgres"],
["kingbase", "postgres"],
["highgo", "postgres"],
["uxdb", "postgres"],
["vastbase", "postgres"],
["redshift", "postgres"],
["mysql", "mysql"],
["sqlserver", "sqlserver"],
]);
/**
* Schema Diff routine compare is limited to an allowlist of same-dialect families.
* Sidebar may list routines for more engines (oracle/hive/…); those stay out of
* schema-diff until a verified compare path lands. Cross-family pairs never match.
*/
export function supportsSchemaDiffRoutines(dbType?: DatabaseType): boolean {
if (!dbType || !SCHEMA_DIFF_ROUTINE_FAMILY.has(dbType)) return false;
const { sidebarObjects, sourceReadable } = databaseObjectCapabilities(dbType);
return SCHEMA_DIFF_ROUTINE_KINDS.some((kind) => sidebarObjects.includes(kind) && sourceReadable.includes(kind));
}
export function schemaDiffRoutineObjectTypes(dbType?: DatabaseType): Array<"PROCEDURE" | "FUNCTION"> {
if (!supportsSchemaDiffRoutines(dbType)) return [];
const { sidebarObjects, sourceReadable } = databaseObjectCapabilities(dbType);
return SCHEMA_DIFF_ROUTINE_KINDS.filter((kind) => sidebarObjects.includes(kind) && sourceReadable.includes(kind));
}
export function schemaDiffRoutineObjectTypesIntersection(sourceDbType?: DatabaseType, targetDbType?: DatabaseType): Array<"PROCEDURE" | "FUNCTION"> {
if (!sourceDbType || !targetDbType) return [];
const sourceFamily = SCHEMA_DIFF_ROUTINE_FAMILY.get(sourceDbType);
const targetFamily = SCHEMA_DIFF_ROUTINE_FAMILY.get(targetDbType);
if (!sourceFamily || sourceFamily !== targetFamily) return [];
const sourceTypes = new Set(schemaDiffRoutineObjectTypes(sourceDbType));
return schemaDiffRoutineObjectTypes(targetDbType).filter((kind) => sourceTypes.has(kind));
}
export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObjectKind[] {
if (!dbType) return [...TABLE_VIEW_OBJECTS];
return DATABASE_TYPE_OBJECTS.get(dbType) ?? [...ROUTINE_OBJECTS];
@@ -384,6 +384,8 @@ export interface SchemaDiffObject {
parentName?: string;
/** Function arguments signature (for PostgreSQL overloaded functions) */
arguments?: string;
/** PROCEDURE vs FUNCTION when objectKind is function (for getObjectSource). */
routineType?: "PROCEDURE" | "FUNCTION";
renameMetadata?: {
confirmed: boolean;
sourceName?: string;
@@ -718,12 +720,15 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
for (const diff of functionDiffs) {
const args = diff.source?.arguments || diff.target?.arguments || "";
const functionType = (diff.source?.function_type || diff.target?.function_type || "").toUpperCase();
const routineType: "PROCEDURE" | "FUNCTION" = functionType.includes("PROC") ? "PROCEDURE" : "FUNCTION";
objects.push({
id: `func-${diff.name}-${args}`,
operationType: getOperationType(diff.type),
objectKind: "function",
name: diff.name,
arguments: args,
routineType,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
selected: true,
@@ -1,4 +1,6 @@
import type { SchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
import { schemaDiffRoutineObjectTypesIntersection } from "@/lib/database/databaseObjectCapabilities";
export type SchemaDiffProgressPhase = "loading-table-lists" | "loading-source-details" | "loading-target-details" | "loading-extra-objects" | "comparing" | "generating" | "complete";
@@ -9,10 +11,22 @@ export function isSchemaDiffPostgresLike(dbType: string | null | undefined): boo
return normalizedDbType === "postgres" || normalizedDbType === "opengauss";
}
/** Extra objects that remain Postgres-catalog specific (sequences/rules/owners + PG function fast path). Routine compare for other DBs is gated separately via supportsSchemaDiffRoutines. */
export function shouldLoadSchemaDiffExtraObjects(dbType: string | null | undefined, options: Pick<SchemaDiffCompareOptions, "functions" | "sequences" | "rules" | "owners">): boolean {
return isSchemaDiffPostgresLike(dbType) && (options.functions || options.sequences || options.rules || options.owners);
}
/** True when session will load routines via listFunctions for a supported source/target pair. */
export function shouldLoadSchemaDiffRoutines(sourceDbType: string | null | undefined, targetDbType: string | null | undefined, options: Pick<SchemaDiffCompareOptions, "functions">): boolean {
if (!options.functions) return false;
return schemaDiffRoutineObjectTypesIntersection(sourceDbType as DatabaseType | undefined, targetDbType as DatabaseType | undefined).length > 0;
}
/** Unified extra-object progress phase used by session + dialog next-step hints. */
export function shouldLoadSchemaDiffExtraObjectPhase(sourceDbType: string | null | undefined, targetDbType: string | null | undefined, options: Pick<SchemaDiffCompareOptions, "functions" | "sequences" | "rules" | "owners">): boolean {
return shouldLoadSchemaDiffExtraObjects(targetDbType, options) || shouldLoadSchemaDiffRoutines(sourceDbType, targetDbType, options);
}
export function getSchemaDiffNextProgressStep(phase: SchemaDiffProgressPhase | undefined, hasExtraObjectPhase: boolean): SchemaDiffNextProgressStep | null {
switch (phase) {
case "loading-table-lists":
@@ -0,0 +1,190 @@
import type { FunctionInfo } from "@/types/database";
import type { SchemaDiffRoutineMapping } from "@/types/schemaDiff";
import type { DiffObjectKind, SchemaDiffObject } from "@/lib/schema/schemaDiff";
import { buildTextDiff } from "@/lib/common/textDiff";
import {
areSchemaDiffTableMappingsEqual,
availableSchemaDiffTargetTables,
buildSchemaDiffTableMatches,
pruneSchemaDiffTableMappings,
reconcileSchemaDiffTableMappings,
swapSchemaDiffTableMappings,
updateSchemaDiffTableMapping,
type SchemaDiffTableMatch,
type SchemaDiffTableMappingUpdate,
} from "@/lib/schema/schemaDiffTableMapping";
export interface SchemaDiffRoutineTextDiffStats {
added: number;
removed: number;
modified: number;
}
/** Unrestricted routine compare loads every definition; require explicit selection above this count. */
export const SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT = 200;
export function isSchemaDiffUnrestrictedRoutineLoadTooLarge(routineCount: number, selectedRoutines: string[] | undefined): boolean {
return selectedRoutines === undefined && routineCount > SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT;
}
function countNonEmptyLines(text: string): number {
if (!text) return 0;
const normalized = text.replace(/\r\n?/g, "\n");
if (!normalized) return 0;
const lines = normalized.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines.length;
}
/** Line-level add/remove/modify counts for routine DDL side-by-side comparison. */
export function summarizeSchemaDiffRoutineTextDiff(sourceDdl?: string | null, targetDdl?: string | null): SchemaDiffRoutineTextDiffStats {
const source = sourceDdl ?? "";
const target = targetDdl ?? "";
if (!source && !target) return { added: 0, removed: 0, modified: 0 };
// Source-only (create on target): count as additions. Target-only (drop on target): removals.
if (source && !target) return { added: countNonEmptyLines(source), removed: 0, modified: 0 };
if (!source && target) return { added: 0, removed: countNonEmptyLines(target), modified: 0 };
let added = 0;
let removed = 0;
let modified = 0;
for (const row of buildTextDiff(source, target)) {
if (row.kind === "added") added += 1;
else if (row.kind === "removed") removed += 1;
else if (row.kind === "modified") modified += 1;
}
return { added, removed, modified };
}
export type SchemaDiffRoutineMatch = SchemaDiffTableMatch & {
sourceRoutine: string;
targetRoutine?: string;
};
const ROUTINE_OBJECT_KINDS = new Set<DiffObjectKind>(["function"]);
/** Stable routine identity for overloaded functions: `name` or `name(args)`. */
export function schemaDiffRoutineKey(name: string, args = ""): string {
const trimmedArgs = args.trim();
return trimmedArgs ? `${name}(${trimmedArgs})` : name;
}
export function schemaDiffRoutineKeyFromFunction(fn: Pick<FunctionInfo, "name" | "arguments">): string {
return schemaDiffRoutineKey(fn.name, fn.arguments);
}
export function isSchemaDiffRoutineObjectKind(kind: DiffObjectKind): boolean {
return ROUTINE_OBJECT_KINDS.has(kind);
}
export function isSchemaDiffRoutineObject(object: Pick<SchemaDiffObject, "objectKind">): boolean {
return isSchemaDiffRoutineObjectKind(object.objectKind);
}
export function partitionSchemaDiffObjectsByResultTab(objects: SchemaDiffObject[]): { tableObjects: SchemaDiffObject[]; routineObjects: SchemaDiffObject[] } {
const tableObjects: SchemaDiffObject[] = [];
const routineObjects: SchemaDiffObject[] = [];
for (const object of objects) {
if (isSchemaDiffRoutineObject(object)) routineObjects.push(object);
else tableObjects.push(object);
}
return { tableObjects, routineObjects };
}
/** Count objects that are not "none" (used for tab badges). */
export function countSchemaDiffActionableObjects(objects: SchemaDiffObject[]): number {
return objects.filter((object) => object.operationType !== "none").length;
}
function toTableMappings(mappings: readonly SchemaDiffRoutineMapping[]) {
return mappings.map((mapping) => ({ sourceTable: mapping.sourceRoutine, targetTable: mapping.targetRoutine }));
}
function fromTableMappings(mappings: { sourceTable: string; targetTable: string }[]): SchemaDiffRoutineMapping[] {
return mappings.map((mapping) => ({ sourceRoutine: mapping.sourceTable, targetRoutine: mapping.targetTable }));
}
export function pruneSchemaDiffRoutineMappings(selectedRoutines: readonly string[], mappings: readonly SchemaDiffRoutineMapping[]): SchemaDiffRoutineMapping[] {
return fromTableMappings(pruneSchemaDiffTableMappings(selectedRoutines, toTableMappings(mappings)));
}
export function reconcileSchemaDiffRoutineMappings(selectedRoutines: readonly string[], targetRoutines: readonly string[], mappings: readonly SchemaDiffRoutineMapping[], ignoreNameCase = false): SchemaDiffRoutineMapping[] {
return fromTableMappings(reconcileSchemaDiffTableMappings(selectedRoutines, targetRoutines, toTableMappings(mappings), ignoreNameCase));
}
export function buildSchemaDiffRoutineMatches(selectedRoutines: readonly string[], targetRoutines: readonly string[], mappings: readonly SchemaDiffRoutineMapping[], ignoreNameCase = false): SchemaDiffRoutineMatch[] {
return buildSchemaDiffTableMatches(selectedRoutines, targetRoutines, toTableMappings(mappings), ignoreNameCase).map((match) => ({
...match,
sourceRoutine: match.sourceTable,
targetRoutine: match.targetTable,
}));
}
export function updateSchemaDiffRoutineMapping(mappings: readonly SchemaDiffRoutineMapping[], sourceRoutine: string, targetRoutine: string, ignoreNameCase = false): SchemaDiffTableMappingUpdate & { routineMappings: SchemaDiffRoutineMapping[] } {
const update = updateSchemaDiffTableMapping(toTableMappings(mappings), sourceRoutine, targetRoutine, ignoreNameCase);
return {
...update,
routineMappings: fromTableMappings(update.mappings),
};
}
export function availableSchemaDiffTargetRoutines(sourceRoutine: string, targetRoutines: readonly string[], mappings: readonly SchemaDiffRoutineMapping[], ignoreNameCase = false): string[] {
return availableSchemaDiffTargetTables(sourceRoutine, targetRoutines, toTableMappings(mappings), ignoreNameCase);
}
export function swapSchemaDiffRoutineMappings(mappings: readonly SchemaDiffRoutineMapping[]): SchemaDiffRoutineMapping[] {
return fromTableMappings(swapSchemaDiffTableMappings(toTableMappings(mappings)));
}
export function areSchemaDiffRoutineMappingsEqual(left: readonly SchemaDiffRoutineMapping[], right: readonly SchemaDiffRoutineMapping[]): boolean {
return areSchemaDiffTableMappingsEqual(toTableMappings(left), toTableMappings(right));
}
export function reconcileSchemaDiffSelectedRoutines(selectedRoutines: string[], availableRoutines: string[]): string[] {
const available = new Set(availableRoutines);
return selectedRoutines.filter((routine) => available.has(routine));
}
/** Same-name (identity) mappings only — rename rematch is not wired through prepareSchemaDiff yet. */
export function isSchemaDiffRoutineIdentityMapping(mapping: SchemaDiffRoutineMapping, ignoreNameCase = false): boolean {
if (ignoreNameCase) {
return mapping.sourceRoutine.toLocaleLowerCase() === mapping.targetRoutine.toLocaleLowerCase();
}
return mapping.sourceRoutine === mapping.targetRoutine;
}
export function identitySchemaDiffRoutineMappings(mappings: readonly SchemaDiffRoutineMapping[], ignoreNameCase = false): SchemaDiffRoutineMapping[] {
return mappings.filter((mapping) => isSchemaDiffRoutineIdentityMapping(mapping, ignoreNameCase));
}
export interface FilteredSchemaDiffFunctions {
sourceFunctions: FunctionInfo[];
targetFunctions: FunctionInfo[];
}
/**
* Apply visual routine selection + same-name mappings before prepareSchemaDiff.
* When `selectedRoutines` is undefined, both sides keep all listed functions.
* Non-identity (rename) mappings are ignored until backend rematch lands.
*/
export function filterSchemaDiffFunctions(sourceFunctions: FunctionInfo[], targetFunctions: FunctionInfo[], selectedRoutines: string[] | undefined, routineMappings: SchemaDiffRoutineMapping[] = []): FilteredSchemaDiffFunctions {
if (selectedRoutines === undefined) {
return { sourceFunctions, targetFunctions };
}
const selectedSet = new Set(selectedRoutines);
const filteredSource = sourceFunctions.filter((fn) => selectedSet.has(schemaDiffRoutineKeyFromFunction(fn)));
const targetByKey = new Map(targetFunctions.map((fn) => [schemaDiffRoutineKeyFromFunction(fn), fn]));
const targetKeys = [...targetByKey.keys()];
const mappings = reconcileSchemaDiffRoutineMappings(
filteredSource.map((fn) => schemaDiffRoutineKeyFromFunction(fn)),
targetKeys,
identitySchemaDiffRoutineMappings(routineMappings),
);
const selectedTargetKeys = new Set(mappings.map((mapping) => mapping.targetRoutine));
return {
sourceFunctions: filteredSource,
targetFunctions: targetFunctions.filter((fn) => selectedTargetKeys.has(schemaDiffRoutineKeyFromFunction(fn))),
};
}
+2 -1
View File
@@ -524,7 +524,8 @@ export const useConnectionStore = defineStore("connection", () => {
targetDatabase?: string;
targetSchema?: string;
} | null>(null);
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string; selectedRoutines?: string[]; preferredResultTab?: "tables" | "routines" } | null>(null);
const dataCompareSource = ref<{
connectionId: string;
database: string;
+23 -2
View File
@@ -5,6 +5,11 @@ export interface SchemaDiffTableMapping {
targetTable: string;
}
export interface SchemaDiffRoutineMapping {
sourceRoutine: string;
targetRoutine: string;
}
export interface SchemaDiffCompareOptions {
tables: boolean;
primaryKeys: boolean;
@@ -38,6 +43,13 @@ export interface SchemaDiffCompareOptions {
*/
selectedTables: string[] | undefined;
tableMappings?: SchemaDiffTableMapping[];
/**
* Explicitly selected routine keys (`name` or `name(args)`) to compare.
* `undefined` means no restriction (all functions when `functions` is on).
* `[]` means restriction enabled with nothing selected.
*/
selectedRoutines: string[] | undefined;
routineMappings?: SchemaDiffRoutineMapping[];
detectRenames: boolean;
renameThreshold: number;
detectTableRenames: boolean;
@@ -109,6 +121,8 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
tableFilterPriority: "exclude",
selectedTables: undefined,
tableMappings: [],
selectedRoutines: undefined,
routineMappings: [],
detectRenames: false,
renameThreshold: 0.5,
detectTableRenames: false,
@@ -128,7 +142,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
checks: true,
exclusions: false,
views: true,
functions: false,
functions: true,
indexes: true,
sequences: false,
triggers: true,
@@ -144,6 +158,8 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
tableFilterPriority: "exclude",
selectedTables: undefined,
tableMappings: [],
selectedRoutines: undefined,
routineMappings: [],
detectRenames: false,
renameThreshold: 0.5,
detectTableRenames: false,
@@ -164,11 +180,16 @@ export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOpt
export function normalizeSchemaDiffCompareOptions(options: Partial<SchemaDiffCompareOptions> | null | undefined, dbType = "postgres"): SchemaDiffCompareOptions {
const defaults = getDefaultOptionsForDbType(dbType);
return {
const normalized: SchemaDiffCompareOptions = {
...defaults,
...options,
tableMappings: Array.isArray(options?.tableMappings) ? options.tableMappings.map((mapping) => ({ ...mapping })) : defaults.tableMappings,
routineMappings: Array.isArray(options?.routineMappings) ? options.routineMappings.map((mapping) => ({ ...mapping })) : defaults.routineMappings,
selectedRoutines: options?.selectedRoutines === undefined ? defaults.selectedRoutines : options.selectedRoutines ? [...options.selectedRoutines] : options.selectedRoutines,
};
// Table compare owns views; turning tables off must not leave views loading metadata alone.
if (!normalized.tables) normalized.views = false;
return normalized;
}
export function createEmptyConfig(id: string, name: string): SchemaDiffConfig {