diff --git a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue index 3de42fa536..e027de45fd 100644 --- a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue @@ -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([]); @@ -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([]); +const targetRoutineList = ref([]); +const targetRoutineListLoaded = ref(false); +const restrictRoutines = ref(false); +const localSelectedRoutines = ref([]); +const loadingRoutines = ref(false); + +const canConfigureRoutineSelection = computed(() => isTableIdentityReady("source") && supportsSchemaDiffRoutines(sourceDbType.value as DatabaseType)); +const activeRoutineMappings = computed(() => identitySchemaDiffRoutineMappings(props.options?.routineMappings ?? [])); +const routineMatches = computed(() => 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
- -
-
- {{ t("diff.tableSelectionUnrestricted") }} +
+ {{ t("diff.tableCompareDisabled") }}
- +
-
+
{{ t("diff.targetTableMatching") }}
@@ -668,6 +895,77 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st {{ t("diff.targetTableInUse") }}
+ +
+
+
+ + +
+ +
+
+ {{ t("diff.routineSelectionUnsupported") }} +
+
+ {{ t("diff.routineCompareDisabled") }} +
+ +
+ +
+
+
{{ t("diff.targetRoutineMatching") }}
+
+ {{ t("diff.matchedRoutines", { matched: matchedRoutineCount, total: localSelectedRoutines.length }) }} +
+
+

{{ t("diff.routineSameNameMatchingOnly") }}

+
+ + + + + + + + + + + + + + + +
{{ t("diff.sourceRoutines") }}{{ t("diff.targetRoutine") }}{{ t("diff.status") }}
{{ match.sourceRoutine }} + {{ match.targetRoutine }} + — + + {{ t(`diff.tableMatchStatus.${match.kind}`) }} +
+
+
+ {{ t("diff.unmatchedRoutineWarning", { count: missingTargetRoutines.length }) }} +
+
diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 00459776e4..38605675e5 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts index 83b5ee835a..d6910ac072 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts @@ -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(""); const targetInfo = configStepSource.indexOf(""); @@ -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(' { diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 010cc15e2a..a7b6107f99 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -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" /> 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 }); } diff --git a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts index ba488d0978..b2aadae38d 100644 --- a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts @@ -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, []); +}); diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts index 73295c1d79..a19eda6176 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts @@ -10,6 +10,7 @@ function setup(node: Partial, 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", + }); + }); +}); diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index a56a2a1266..22d8ad30fe 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -45,6 +45,8 @@ const transferPrefillTargetSchema = ref(""); const schemaDiffPrefillConnectionId = ref(""); const schemaDiffPrefillDatabase = ref(""); const schemaDiffPrefillSchema = ref(""); +const schemaDiffPrefillSelectedRoutines = ref(undefined); +const schemaDiffPrefillResultTab = ref<"tables" | "routines" | "">(""); const schemaDiffSessionId = ref(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, diff --git a/apps/desktop/src/composables/useSchemaDiffSession.ts b/apps/desktop/src/composables/useSchemaDiffSession.ts index c6a1988ba6..02a91df436 100644 --- a/apps/desktop/src/composables/useSchemaDiffSession.ts +++ b/apps/desktop/src/composables/useSchemaDiffSession.ts @@ -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> = []; + let targetDetails: Awaited> = []; - 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[] = []; - 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>) : []; - const targetFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const sourceSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const targetSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const sourceRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const targetRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const sourceOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; - const targetOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; + 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>) : []; + const targetSequences = sessionOptions.sequences && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; + const sourceRules = sessionOptions.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; + const targetRules = sessionOptions.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; + const sourceOwners = sessionOptions.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; + const targetOwners = sessionOptions.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : []; 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[] { diff --git a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts index 9328447484..eb0d4a1ff9 100644 --- a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts @@ -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; @@ -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, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cf27a3a441..05423cffbf 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b732bc2571..1a6f819126 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4567daa020..ec33d83d3e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d5b2bc96ba..a6fff68633 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "ロールバック比較", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6507c3be2d..fb1a57e7ec 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "롤백 비교", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1c1347f788..50d782e034 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d4dd6d8e1d..a4420040ef 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e6aa88a067..ef15bd67e8 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "回溯對比", diff --git a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts index 97b2b7094a..ffb0dcf087 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts @@ -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"]); + }); }); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffMetadataLoad.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffMetadataLoad.spec.ts index 93d2402240..84e18abf59 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffMetadataLoad.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffMetadataLoad.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffRoutine.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffRoutine.spec.ts new file mode 100644 index 0000000000..231d3cdf26 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffRoutine.spec.ts @@ -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 & Pick): 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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts index 015486aa2a..e41063b48c 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index 850e38c4ed..9f78765abb 100644 --- a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts @@ -55,6 +55,10 @@ const DATABASE_TYPE_OBJECTS = new Map([ ["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([ + ["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]; diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index 1f13f12eed..97f9fd4739 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -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, diff --git a/apps/desktop/src/lib/schema/schemaDiffProgress.ts b/apps/desktop/src/lib/schema/schemaDiffProgress.ts index 40efc74acd..b93c022d18 100644 --- a/apps/desktop/src/lib/schema/schemaDiffProgress.ts +++ b/apps/desktop/src/lib/schema/schemaDiffProgress.ts @@ -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): 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): 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): 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": diff --git a/apps/desktop/src/lib/schema/schemaDiffRoutine.ts b/apps/desktop/src/lib/schema/schemaDiffRoutine.ts new file mode 100644 index 0000000000..a1977ae7cd --- /dev/null +++ b/apps/desktop/src/lib/schema/schemaDiffRoutine.ts @@ -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(["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): string { + return schemaDiffRoutineKey(fn.name, fn.arguments); +} + +export function isSchemaDiffRoutineObjectKind(kind: DiffObjectKind): boolean { + return ROUTINE_OBJECT_KINDS.has(kind); +} + +export function isSchemaDiffRoutineObject(object: Pick): 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))), + }; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a0cf5181fd..2c62e50883 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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; diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index f6c9be60af..4fe9b9bb1a 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -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 | 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 { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index add77487cb..7309a9dfc7 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -6,10 +6,11 @@ use crate::connection::{ use crate::db; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query::{agent_execute_query_params, should_discard_pool_after_error, QueryExecutionOptions}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::future::Future; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; mod kingbase; @@ -7549,16 +7550,193 @@ pub async fn list_functions_core( database: &str, schema: &str, ) -> Result, String> { - retry_metadata_connection(state, connection_id, Some(database), || async { + let postgres_functions = retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; match &pool { - PoolKind::Postgres(p) => db::postgres::list_functions(p, schema).await, - _ => Ok(vec![]), + PoolKind::Postgres(p) => Ok(Some(db::postgres::list_functions(p, schema).await?)), + _ => Ok(None), } }) + .await?; + + if let Some(functions) = postgres_functions { + return Ok(functions); + } + + // Non-Postgres: reuse sidebar list_objects + get_object_source paths. + list_functions_via_objects(state, connection_id, database, schema).await +} + +/// Build FunctionInfo for non-Postgres pools by reusing list_objects + get_object_source +/// (same paths the sidebar uses for PROCEDURE/FUNCTION). +async fn list_functions_via_objects( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, +) -> Result, String> { + let object_types = ["PROCEDURE".to_string(), "FUNCTION".to_string()]; + let objects = + list_objects_core(state, connection_id, database, schema, None, None, None, Some(&object_types), None).await?; + + // Bound concurrent get_object_source calls (N+1) without requiring AppState: Clone. + const CONCURRENCY: usize = 8; + let mut functions = Vec::with_capacity(objects.len()); + for chunk in objects.chunks(CONCURRENCY) { + let chunk_results = futures::future::join_all( + chunk + .iter() + .map(|object| load_function_info_via_object(state, connection_id, database, schema, object.clone())), + ) + .await; + functions.extend(chunk_results.into_iter().flatten()); + } + + Ok(functions) +} + +fn schema_diff_routine_kind(object_type: &str) -> Option<(&'static str, db::ObjectSourceKind)> { + let object_type_upper = object_type.to_ascii_uppercase(); + if object_type_upper.contains("PROC") { + Some(("PROCEDURE", db::ObjectSourceKind::Procedure)) + } else if object_type_upper.contains("FUNC") { + Some(("FUNCTION", db::ObjectSourceKind::Function)) + } else { + None + } +} + +async fn load_function_info_via_object( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + object: db::ObjectInfo, +) -> Option { + let (function_type, source_kind) = schema_diff_routine_kind(&object.object_type)?; + + let definition = match get_object_source_core( + state, + connection_id, + database, + schema, + &object.name, + source_kind.clone(), + object.signature.as_deref(), + None, + ) .await + { + Ok(source) if !source.source.trim().is_empty() => source.source, + Ok(_) | Err(_) => { + // Retry the alternate routine kind when the primary getter is empty/fails. + let alternate = match source_kind { + db::ObjectSourceKind::Procedure => db::ObjectSourceKind::Function, + db::ObjectSourceKind::Function => db::ObjectSourceKind::Procedure, + other => other, + }; + match get_object_source_core( + state, + connection_id, + database, + schema, + &object.name, + alternate, + object.signature.as_deref(), + None, + ) + .await + { + Ok(source) if !source.source.trim().is_empty() => source.source, + // Skip objects with no readable source so empty definitions are not treated as loaded. + _ => return None, + } + } + }; + + Some(db::FunctionInfo { + name: object.name, + function_type: function_type.to_string(), + data_type: String::new(), + definition: strip_routine_definer_clause(&definition), + arguments: object.signature.unwrap_or_default(), + }) +} + +/// MySQL's SHOW CREATE PROCEDURE/FUNCTION prefixes `CREATE DEFINER=`user`@`host``. +/// The definer account typically differs across same-structure databases on +/// different servers while the routine body is identical, so drop the clause +/// before schema-diff comparison (same spirit as DBeaver's removeDefiner +/// option). Definitions without the clause pass through unchanged; the +/// `^CREATE DEFINER` anchor keeps definer mentions inside a routine body alone. +fn strip_routine_definer_clause(definition: &str) -> String { + static DEFINER_PREFIX: OnceLock = OnceLock::new(); + let definer_prefix = DEFINER_PREFIX.get_or_init(|| { + Regex::new(r#"(?is)^\s*CREATE\s+DEFINER\s*=\s*(`(?:[^`]|``)*`|"(?:[^"]|"")*"|[A-Za-z0-9_$]+)@(`(?:[^`]|``)*`|"(?:[^"]|"")*"|[A-Za-z0-9_$.%*-]+)"#).unwrap() + }); + match definer_prefix.find(definition) { + Some(found) => format!("CREATE {}", definition[found.end()..].trim_start()), + None => definition.to_string(), + } +} + +#[cfg(test)] +mod schema_diff_routine_kind_tests { + use super::schema_diff_routine_kind; + use crate::db::ObjectSourceKind; + + #[test] + fn classifies_procedure_and_function_object_types() { + assert_eq!(schema_diff_routine_kind("PROCEDURE"), Some(("PROCEDURE", ObjectSourceKind::Procedure))); + assert_eq!(schema_diff_routine_kind("StoredProc"), Some(("PROCEDURE", ObjectSourceKind::Procedure))); + assert_eq!(schema_diff_routine_kind("FUNCTION"), Some(("FUNCTION", ObjectSourceKind::Function))); + assert_eq!(schema_diff_routine_kind("user_function"), Some(("FUNCTION", ObjectSourceKind::Function))); + assert!(schema_diff_routine_kind("TABLE").is_none()); + assert!(schema_diff_routine_kind("VIEW").is_none()); + } +} + +#[cfg(test)] +mod strip_routine_definer_clause_tests { + use super::strip_routine_definer_clause; + + #[test] + fn strips_backquoted_definer_prefix() { + assert_eq!( + strip_routine_definer_clause("CREATE DEFINER=`root`@`localhost` PROCEDURE `p`() BEGIN SELECT 1; END"), + "CREATE PROCEDURE `p`() BEGIN SELECT 1; END" + ); + } + + #[test] + fn strips_bare_definer_prefix() { + assert_eq!( + strip_routine_definer_clause("CREATE DEFINER=app_user@10.0.0.% FUNCTION `f`() RETURNS int RETURN 1"), + "CREATE FUNCTION `f`() RETURNS int RETURN 1" + ); + } + + #[test] + fn keeps_definitions_without_definer() { + let def = "CREATE PROCEDURE `p`() BEGIN SELECT 1; END"; + assert_eq!(strip_routine_definer_clause(def), def); + } + + #[test] + fn keeps_definer_mentions_inside_the_body() { + let def = "CREATE PROCEDURE `p`() BEGIN -- CREATE DEFINER=`x`@`y` stays\nSELECT 1; END"; + assert_eq!(strip_routine_definer_clause(def), def); + } + + #[test] + fn strips_definer_after_leading_whitespace() { + assert_eq!( + strip_routine_definer_clause(" CREATE DEFINER=`root`@`%` PROCEDURE `p`() BEGIN END"), + "CREATE PROCEDURE `p`() BEGIN END" + ); + } } pub async fn list_sequences_core( diff --git a/crates/dbx-core/src/sql_parser/ast_filter.rs b/crates/dbx-core/src/sql_parser/ast_filter.rs index 715b556896..97dc40a7a8 100644 --- a/crates/dbx-core/src/sql_parser/ast_filter.rs +++ b/crates/dbx-core/src/sql_parser/ast_filter.rs @@ -4,7 +4,6 @@ use sqlparser::dialect::GenericDialect; use sqlparser::parser::Parser; use crate::schema_diff::SchemaDiffPreparationOptions; -use crate::types::FunctionInfo; #[derive(Debug, Clone)] pub enum FilterAction { @@ -168,22 +167,16 @@ impl AstTransmitFilter { ) } + /// Pass-through for schema-diff preparation options. + /// + /// Routine definitions are compared as metadata and must be preserved here. + /// Dangerous statement bodies are still blocked by `filter_sql` / + /// `is_dangerous_body_node` when SQL is whitelisted for transmit/sync. pub fn filter_diff_preparation_options( options: SchemaDiffPreparationOptions, _dialect: &str, ) -> SchemaDiffPreparationOptions { - let mut filtered = options; - - let is_dangerous_fn = |f: &FunctionInfo| -> bool { - let upper = f.definition.to_ascii_uppercase(); - let keywords = ["FUNCTION", "PROCEDURE", "TRIGGER", "BEGIN", "DECLARE", "LANGUAGE"]; - keywords.iter().any(|kw| upper.contains(kw)) - }; - - filtered.source_functions.retain(|f| !is_dangerous_fn(f)); - filtered.target_functions.retain(|f| !is_dangerous_fn(f)); - - filtered + options } } @@ -225,6 +218,7 @@ impl AstFilter for AstTransmitFilter { #[cfg(test)] mod tests { use super::*; + use crate::types::FunctionInfo; #[test] fn allows_create_table() { @@ -334,20 +328,20 @@ mod tests { } #[test] - fn filter_options_removes_dangerous_functions() { + fn filter_options_preserves_routine_definitions() { let opts = SchemaDiffPreparationOptions { source_functions: vec![ FunctionInfo { name: "f1".into(), - definition: "CREATE FUNCTION f1() RETURNS INT ...".into(), + definition: "CREATE FUNCTION f1() RETURNS INT BEGIN RETURN 1; END".into(), function_type: "FUNCTION".into(), data_type: "int".into(), arguments: "".into(), }, FunctionInfo { - name: "t1".into(), - definition: "TABLE t1".into(), - function_type: "TABLE".into(), + name: "p1".into(), + definition: "CREATE PROCEDURE p1() BEGIN SELECT 1; END".into(), + function_type: "PROCEDURE".into(), data_type: "".into(), arguments: "".into(), }, @@ -372,8 +366,11 @@ mod tests { }; let filtered = AstTransmitFilter::filter_diff_preparation_options(opts, "mysql"); - assert_eq!(filtered.source_functions.len(), 1); - assert_eq!(filtered.source_functions[0].name, "t1"); + assert_eq!(filtered.source_functions.len(), 2); + assert_eq!(filtered.source_functions[0].name, "f1"); + assert_eq!(filtered.source_functions[1].name, "p1"); + assert!(filtered.source_functions[0].definition.contains("BEGIN")); + assert!(filtered.source_functions[1].definition.contains("PROCEDURE")); } #[test]