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