feat(schema-diff): add table filters
This commit is contained in:
parent
37f7c48c3b
commit
21ec91a3d5
|
|
@ -15,10 +15,11 @@ import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
|
|||
import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue";
|
||||
|
||||
import { getSchemaDiffOptionsForDbType } from "@/lib/schemaDiffOptions";
|
||||
import { getDefaultOptionsForDbType } from "@/types/schemaDiff";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig } from "@/types/schemaDiff";
|
||||
import type { ObjectSourceKind } from "@/types/database";
|
||||
import { buildDeploySqlForObjects, convertToSchemaDiffObjects, groupDiffObjects, type OperationGroup, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type SchemaDiffPreparation } from "@/lib/schemaDiff";
|
||||
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schemaDiffTableFilter";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
|
|
@ -180,6 +181,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
// Config management
|
||||
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
|
||||
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
|
||||
|
||||
const selectedObject = computed(() => {
|
||||
if (!selectedObjectId.value) return null;
|
||||
|
|
@ -259,7 +261,7 @@ function handleSwap() {
|
|||
|
||||
function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(options);
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(options, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,14 +284,20 @@ async function handleCompare() {
|
|||
step.value = "compare";
|
||||
|
||||
try {
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const dbType = targetConfig?.db_type || "mysql";
|
||||
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
|
||||
const tableFilter = compileSchemaDiffTableFilter(opts);
|
||||
|
||||
await store.ensureConnected(sourceConnectionId.value);
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
|
||||
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
|
||||
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter);
|
||||
|
||||
// Load schema details in parallel
|
||||
const sourceDetails = await Promise.all(
|
||||
srcTables.map(async (table) => {
|
||||
sourceTables.map(async (table) => {
|
||||
const objectType = isViewOrMaterializedView(table.table_type);
|
||||
const [columns, indexes, foreignKeys, triggers, ddl] = await Promise.all([
|
||||
api.getColumns(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, table.name),
|
||||
|
|
@ -303,7 +311,7 @@ async function handleCompare() {
|
|||
);
|
||||
|
||||
const targetDetails = await Promise.all(
|
||||
tgtTables.map(async (table) => {
|
||||
targetTables.map(async (table) => {
|
||||
const objectType = isViewOrMaterializedView(table.table_type);
|
||||
const [columns, indexes, foreignKeys, triggers, ddl] = await Promise.all([
|
||||
api.getColumns(targetConnectionId.value, targetDatabase.value, targetSchema.value, table.name),
|
||||
|
|
@ -316,10 +324,7 @@ async function handleCompare() {
|
|||
}),
|
||||
);
|
||||
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const dbType = targetConfig?.db_type || "mysql";
|
||||
const isPostgresLike = dbType === "postgres" || dbType === "opengauss";
|
||||
const opts = activeConfig.value?.options;
|
||||
|
||||
// Fetch new object types for PostgreSQL-like databases
|
||||
const promises: Promise<any>[] = [];
|
||||
|
|
@ -352,8 +357,8 @@ async function handleCompare() {
|
|||
const tgtOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
|
||||
|
||||
const result = await api.prepareSchemaDiff({
|
||||
sourceTables: srcTables,
|
||||
targetTables: tgtTables,
|
||||
sourceTables,
|
||||
targetTables,
|
||||
sourceDetails,
|
||||
targetDetails,
|
||||
sourceFunctions: srcFunctions,
|
||||
|
|
@ -368,6 +373,7 @@ async function handleCompare() {
|
|||
targetSchema: targetSchema.value,
|
||||
ignoreComments: ignoreComments.value,
|
||||
cascadeDelete: opts?.cascadeDelete ?? false,
|
||||
compareColumnOrder: opts.compareColumnOrder,
|
||||
});
|
||||
|
||||
// Convert to unified objects
|
||||
|
|
@ -822,12 +828,12 @@ const targetConnectionInfo = computed(() => {
|
|||
|
||||
<!-- Options Panel Overlay -->
|
||||
<div v-if="showOptionsPanel" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="showOptionsPanel = false">
|
||||
<div class="bg-card border rounded-lg shadow-lg w-[500px] max-h-[80vh] overflow-auto p-4">
|
||||
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
|
||||
<Button variant="ghost" size="sm" @click="showOptionsPanel = false">✕</Button>
|
||||
</div>
|
||||
<SchemaDiffOptionsPanel :options="activeConfig?.options ?? getDefaultOptionsForDbType(getDbType())" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
||||
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffOptionItem } from "@/types/schemaDiff";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { HelpTooltip } from "@/components/ui/tooltip";
|
||||
import type { BooleanSchemaDiffCompareOptionKey, SchemaDiffCompareOptions, SchemaDiffOptionItem } from "@/types/schemaDiff";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
|
||||
const props = defineProps<{
|
||||
options: SchemaDiffCompareOptions;
|
||||
|
|
@ -17,22 +20,22 @@ const emit = defineEmits<{
|
|||
const { t } = useI18n();
|
||||
|
||||
// Local copy of options
|
||||
const localOptions = ref<SchemaDiffCompareOptions>({ ...props.options });
|
||||
const localOptions = ref<SchemaDiffCompareOptions>(normalizeSchemaDiffCompareOptions(props.options));
|
||||
|
||||
// Watch for external changes
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
localOptions.value = { ...newOptions };
|
||||
localOptions.value = normalizeSchemaDiffCompareOptions(newOptions);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function isChecked(id: keyof SchemaDiffCompareOptions): boolean {
|
||||
function isChecked(id: BooleanSchemaDiffCompareOptionKey): boolean {
|
||||
return !!localOptions.value[id];
|
||||
}
|
||||
|
||||
function setOption(id: keyof SchemaDiffCompareOptions, checked: boolean) {
|
||||
function setOption(id: BooleanSchemaDiffCompareOptionKey, checked: boolean) {
|
||||
localOptions.value = { ...localOptions.value, [id]: checked };
|
||||
}
|
||||
|
||||
|
|
@ -84,32 +87,67 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
|
|||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-auto space-y-1">
|
||||
<template v-for="item in optionTree" :key="item.id">
|
||||
<div class="grid flex-1 grid-cols-[minmax(0,1fr)_minmax(300px,0.95fr)] gap-4 overflow-auto pr-1">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="px-2 pb-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.optionsTitle") }}</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
|
||||
<div :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
|
||||
</div>
|
||||
<div v-if="item.children" class="ml-6 space-y-1">
|
||||
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
|
||||
<div :class="getItemClasses(getChildState(child))">
|
||||
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<template v-for="item in optionTree" :key="item.id">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
|
||||
<div :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
|
||||
</div>
|
||||
<div v-if="item.children" class="ml-6 space-y-1">
|
||||
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
|
||||
<div :class="getItemClasses(getChildState(child))">
|
||||
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 grid-cols-[112px_minmax(0,1fr)] content-start gap-x-3 gap-y-2 border-l pl-4">
|
||||
<div class="pt-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.tableFilter") }}</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate text-xs text-muted-foreground">{{ t("schemaDiff.tableFilterHintShort") }}</span>
|
||||
<HelpTooltip :label="t('schemaDiff.tableFilterHelp')" side="left">
|
||||
<div class="space-y-1">
|
||||
<div>{{ t("schemaDiff.tableFilterHelpRule") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpBlank") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpExample") }}</div>
|
||||
</div>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-include">{{ t("schemaDiff.tableIncludePattern") }}</label>
|
||||
<input id="schema-diff-table-include" v-model="localOptions.tableIncludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableIncludePatternPlaceholder')" />
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-exclude">{{ t("schemaDiff.tableExcludePattern") }}</label>
|
||||
<input id="schema-diff-table-exclude" v-model="localOptions.tableExcludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableExcludePatternPlaceholder')" />
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-priority">{{ t("schemaDiff.tableFilterPriority") }}</label>
|
||||
<div>
|
||||
<Select v-model="localOptions.tableFilterPriority">
|
||||
<SelectTrigger id="schema-diff-table-priority" class="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">{{ t("schemaDiff.tableFilterPriorityExclude") }}</SelectItem>
|
||||
<SelectItem value="include">{{ t("schemaDiff.tableFilterPriorityInclude") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-4 border-t mt-2">
|
||||
|
|
|
|||
|
|
@ -2118,6 +2118,20 @@ export default {
|
|||
importPlaceholder: "Paste JSON config...",
|
||||
importInvalidJson: "Invalid JSON format",
|
||||
configName: "Config Name",
|
||||
tableFilter: "Table filter",
|
||||
tableFilterHint: "Matches full table names with regex. Do not type /.../. ^ means starts with, $ means ends with, | means or.",
|
||||
tableFilterHintShort: "Limit compare by table name regex",
|
||||
tableFilterHelp: "Table filter help",
|
||||
tableFilterHelpRule: "Matches full table names. Do not type /.../.",
|
||||
tableFilterHelpBlank: "Blank include means all tables. Blank skip means no exclusions.",
|
||||
tableFilterHelpExample: "^user_ means starts with user_; _bak$ means ends with _bak; a|b means a or b.",
|
||||
tableIncludePattern: "Only compare matched tables",
|
||||
tableIncludePatternPlaceholder: "e.g. ^user_",
|
||||
tableExcludePattern: "Skip matched tables",
|
||||
tableExcludePatternPlaceholder: "e.g. _bak$",
|
||||
tableFilterPriority: "When both match",
|
||||
tableFilterPriorityExclude: "Exclude wins",
|
||||
tableFilterPriorityInclude: "Include wins",
|
||||
options: {
|
||||
tables: "Compare tables",
|
||||
primaryKeys: "Compare primary keys",
|
||||
|
|
@ -2134,6 +2148,7 @@ export default {
|
|||
owners: "Compare owners",
|
||||
cascadeDelete: "Use CASCADE delete",
|
||||
sequenceLastValues: "Compare sequence last values",
|
||||
compareColumnOrder: "Compare column order",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
|
|
|||
|
|
@ -1781,6 +1781,20 @@ export default {
|
|||
importPlaceholder: "Paste JSON config...",
|
||||
importInvalidJson: "Invalid JSON format",
|
||||
configName: "Config Name",
|
||||
tableFilter: "Table filter",
|
||||
tableFilterHint: "Matches table names with JavaScript regex. Empty include means all tables; empty exclude means no exclusions.",
|
||||
tableFilterHintShort: "Limit compare by table name regex",
|
||||
tableFilterHelp: "Table filter help",
|
||||
tableFilterHelpRule: "Matches full table names. Do not type /.../.",
|
||||
tableFilterHelpBlank: "Blank include means all tables. Blank skip means no exclusions.",
|
||||
tableFilterHelpExample: "^user_ means starts with user_; _bak$ means ends with _bak; a|b means a or b.",
|
||||
tableIncludePattern: "Include table regex",
|
||||
tableIncludePatternPlaceholder: "e.g. ^user_",
|
||||
tableExcludePattern: "Exclude table regex",
|
||||
tableExcludePatternPlaceholder: "e.g. _bak$",
|
||||
tableFilterPriority: "When both match",
|
||||
tableFilterPriorityExclude: "Exclude wins",
|
||||
tableFilterPriorityInclude: "Include wins",
|
||||
options: {
|
||||
tables: "Compare tables",
|
||||
primaryKeys: "Compare primary keys",
|
||||
|
|
@ -1797,6 +1811,7 @@ export default {
|
|||
owners: "Compare owners",
|
||||
cascadeDelete: "Use CASCADE delete",
|
||||
sequenceLastValues: "Compare sequence last values",
|
||||
compareColumnOrder: "Compare column order",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
|
|
|||
|
|
@ -2076,6 +2076,20 @@ export default {
|
|||
importPlaceholder: "JSON設定を貼り付け...",
|
||||
importInvalidJson: "不正なJSON形式です",
|
||||
configName: "設定名",
|
||||
tableFilter: "テーブルフィルター",
|
||||
tableFilterHint: "テーブル名をJavaScript正規表現で照合します。包含が空なら全テーブル、除外が空なら除外なしです。",
|
||||
tableFilterHintShort: "テーブル名の正規表現で比較範囲を絞り込み",
|
||||
tableFilterHelp: "テーブルフィルターのヘルプ",
|
||||
tableFilterHelpRule: "完全なテーブル名に一致します。/.../ は入力しません。",
|
||||
tableFilterHelpBlank: "包含が空なら全テーブル、スキップが空なら除外なしです。",
|
||||
tableFilterHelpExample: "^user_ は user_ で始まる名前、_bak$ は _bak で終わる名前、a|b は a または b を表します。",
|
||||
tableIncludePattern: "含めるテーブル名の正規表現",
|
||||
tableIncludePatternPlaceholder: "例: ^user_",
|
||||
tableExcludePattern: "除外するテーブル名の正規表現",
|
||||
tableExcludePatternPlaceholder: "例: _bak$",
|
||||
tableFilterPriority: "両方に一致した場合",
|
||||
tableFilterPriorityExclude: "除外を優先",
|
||||
tableFilterPriorityInclude: "包含を優先",
|
||||
options: {
|
||||
tables: "テーブルを比較",
|
||||
primaryKeys: "主キーを比較",
|
||||
|
|
@ -2092,6 +2106,7 @@ export default {
|
|||
owners: "所有者を比較",
|
||||
cascadeDelete: "CASCADE削除を使用",
|
||||
sequenceLastValues: "シーケンス最終値を比較",
|
||||
compareColumnOrder: "列順序を比較",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
|
|
|||
|
|
@ -2142,6 +2142,20 @@ export default {
|
|||
importPlaceholder: "粘贴 JSON 配置...",
|
||||
importInvalidJson: "无效的 JSON 格式",
|
||||
configName: "配置名称",
|
||||
tableFilter: "表名过滤",
|
||||
tableFilterHint: "按完整表名匹配正则。不用写 /.../;^ 表示开头,$ 表示结尾,| 表示或。",
|
||||
tableFilterHintShort: "按表名正则缩小比较范围",
|
||||
tableFilterHelp: "表名过滤说明",
|
||||
tableFilterHelpRule: "匹配完整表名,不用写 /.../。",
|
||||
tableFilterHelpBlank: "只比较为空表示全部表;跳过为空表示不排除。",
|
||||
tableFilterHelpExample: "^user_ 表示 user_ 开头;_bak$ 表示 _bak 结尾;a|b 表示 a 或 b。",
|
||||
tableIncludePattern: "只比较匹配的表",
|
||||
tableIncludePatternPlaceholder: "如 ^user_",
|
||||
tableExcludePattern: "跳过匹配的表",
|
||||
tableExcludePatternPlaceholder: "如 _bak$",
|
||||
tableFilterPriority: "同时匹配时",
|
||||
tableFilterPriorityExclude: "排除优先",
|
||||
tableFilterPriorityInclude: "包含优先",
|
||||
options: {
|
||||
tables: "比较表",
|
||||
primaryKeys: "比较主键",
|
||||
|
|
@ -2158,6 +2172,7 @@ export default {
|
|||
owners: "比较所有者",
|
||||
cascadeDelete: "使用级联删除",
|
||||
sequenceLastValues: "比较序列最后值",
|
||||
compareColumnOrder: "比较字段顺序",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
|
|
|||
|
|
@ -1902,6 +1902,20 @@ export default {
|
|||
importPlaceholder: "貼上 JSON 配置...",
|
||||
importInvalidJson: "無效的 JSON 格式",
|
||||
configName: "配置名稱",
|
||||
tableFilter: "資料表名稱篩選",
|
||||
tableFilterHint: "依資料表名稱比對 JavaScript 正規表示式。包含為空表示全部資料表;排除為空表示不排除。",
|
||||
tableFilterHintShort: "依資料表名稱正規表示式縮小比較範圍",
|
||||
tableFilterHelp: "資料表名稱篩選說明",
|
||||
tableFilterHelpRule: "比對完整資料表名稱,不用寫 /.../。",
|
||||
tableFilterHelpBlank: "只比較為空表示全部資料表;跳過為空表示不排除。",
|
||||
tableFilterHelpExample: "^user_ 表示 user_ 開頭;_bak$ 表示 _bak 結尾;a|b 表示 a 或 b。",
|
||||
tableIncludePattern: "包含資料表名稱正規表示式",
|
||||
tableIncludePatternPlaceholder: "例如 ^user_",
|
||||
tableExcludePattern: "排除資料表名稱正規表示式",
|
||||
tableExcludePatternPlaceholder: "例如 _bak$",
|
||||
tableFilterPriority: "同時符合時",
|
||||
tableFilterPriorityExclude: "排除優先",
|
||||
tableFilterPriorityInclude: "包含優先",
|
||||
options: {
|
||||
tables: "比較資料表",
|
||||
primaryKeys: "比較主鍵",
|
||||
|
|
@ -1918,6 +1932,7 @@ export default {
|
|||
owners: "比較擁有者",
|
||||
cascadeDelete: "使用級聯刪除",
|
||||
sequenceLastValues: "比較序列最後值",
|
||||
compareColumnOrder: "比較欄位順序",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export interface SchemaDiffPreparationOptions {
|
|||
targetSchema?: string;
|
||||
ignoreComments?: boolean;
|
||||
cascadeDelete?: boolean;
|
||||
compareColumnOrder?: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaDiffPreparation {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { SchemaDiffOptionItem, SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { BooleanSchemaDiffCompareOptionKey, SchemaDiffCompareOptions, SchemaDiffOptionItem } from "@/types/schemaDiff";
|
||||
|
||||
export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
|
||||
{
|
||||
|
|
@ -22,6 +22,7 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
|
|||
{ id: "owners", labelKey: "schemaDiff.options.owners", defaultChecked: true },
|
||||
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
|
||||
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
|
||||
{ id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false },
|
||||
];
|
||||
|
||||
export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record<string, SchemaDiffOptionItem[]> = {
|
||||
|
|
@ -35,8 +36,8 @@ export function getSchemaDiffOptionsForDbType(dbType: string): SchemaDiffOptionI
|
|||
return SCHEMA_DIFF_OPTIONS_BY_DB_TYPE[dbType] ?? POSTGRES_SCHEMA_DIFF_OPTIONS;
|
||||
}
|
||||
|
||||
export function getOptionIdsFromTree(items: SchemaDiffOptionItem[]): (keyof SchemaDiffCompareOptions)[] {
|
||||
const ids: (keyof SchemaDiffCompareOptions)[] = [];
|
||||
export function getOptionIdsFromTree(items: SchemaDiffOptionItem[]): BooleanSchemaDiffCompareOptionKey[] {
|
||||
const ids: BooleanSchemaDiffCompareOptionKey[] = [];
|
||||
for (const item of items) {
|
||||
ids.push(item.id);
|
||||
if (item.children) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import type { TableInfo } from "@/types/database";
|
||||
import type { SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
|
||||
export interface CompiledSchemaDiffTableFilter {
|
||||
include?: RegExp;
|
||||
exclude?: RegExp;
|
||||
priority: SchemaDiffCompareOptions["tableFilterPriority"];
|
||||
}
|
||||
|
||||
export interface FilteredSchemaDiffTables {
|
||||
sourceTables: TableInfo[];
|
||||
targetTables: TableInfo[];
|
||||
}
|
||||
|
||||
function compilePattern(pattern: string, label: "include" | "exclude"): RegExp | undefined {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
return new RegExp(trimmed);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid ${label} table name regex: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function compileSchemaDiffTableFilter(options: SchemaDiffCompareOptions): CompiledSchemaDiffTableFilter {
|
||||
return {
|
||||
include: compilePattern(options.tableIncludePattern, "include"),
|
||||
exclude: compilePattern(options.tableExcludePattern, "exclude"),
|
||||
priority: options.tableFilterPriority,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSchemaDiffTableFilter(tableName: string, filter: CompiledSchemaDiffTableFilter): boolean {
|
||||
const includeMatches = filter.include ? filter.include.test(tableName) : true;
|
||||
const excludeMatches = filter.exclude ? filter.exclude.test(tableName) : false;
|
||||
|
||||
if (filter.include && filter.exclude && includeMatches && excludeMatches) {
|
||||
return filter.priority === "include";
|
||||
}
|
||||
return includeMatches && !excludeMatches;
|
||||
}
|
||||
|
||||
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter): FilteredSchemaDiffTables {
|
||||
return {
|
||||
sourceTables: sourceTables.filter((table) => matchesSchemaDiffTableFilter(table.name, filter)),
|
||||
targetTables: targetTables.filter((table) => matchesSchemaDiffTableFilter(table.name, filter)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export type SchemaDiffTableFilterPriority = "include" | "exclude";
|
||||
|
||||
export interface SchemaDiffCompareOptions {
|
||||
tables: boolean;
|
||||
primaryKeys: boolean;
|
||||
|
|
@ -14,6 +16,10 @@ export interface SchemaDiffCompareOptions {
|
|||
owners: boolean;
|
||||
cascadeDelete: boolean;
|
||||
sequenceLastValues: boolean;
|
||||
compareColumnOrder: boolean;
|
||||
tableIncludePattern: string;
|
||||
tableExcludePattern: string;
|
||||
tableFilterPriority: SchemaDiffTableFilterPriority;
|
||||
}
|
||||
|
||||
export interface SchemaDiffConfig {
|
||||
|
|
@ -31,12 +37,16 @@ export interface SchemaDiffConfig {
|
|||
}
|
||||
|
||||
export interface SchemaDiffOptionItem {
|
||||
id: keyof SchemaDiffCompareOptions;
|
||||
id: BooleanSchemaDiffCompareOptionKey;
|
||||
labelKey: string;
|
||||
defaultChecked: boolean;
|
||||
children?: SchemaDiffOptionItem[];
|
||||
}
|
||||
|
||||
export type BooleanSchemaDiffCompareOptionKey = {
|
||||
[K in keyof SchemaDiffCompareOptions]: SchemaDiffCompareOptions[K] extends boolean ? K : never;
|
||||
}[keyof SchemaDiffCompareOptions];
|
||||
|
||||
export type SchemaDiffOptionsMap = Partial<Record<string, SchemaDiffOptionItem[]>>;
|
||||
|
||||
export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
|
||||
|
|
@ -55,6 +65,10 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
|
|||
owners: true,
|
||||
cascadeDelete: false,
|
||||
sequenceLastValues: true,
|
||||
compareColumnOrder: false,
|
||||
tableIncludePattern: "",
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
};
|
||||
|
||||
export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
|
||||
|
|
@ -73,6 +87,10 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
|
|||
owners: false,
|
||||
cascadeDelete: false,
|
||||
sequenceLastValues: false,
|
||||
compareColumnOrder: false,
|
||||
tableIncludePattern: "",
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
};
|
||||
|
||||
export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOptions {
|
||||
|
|
@ -82,6 +100,13 @@ export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOpt
|
|||
return { ...DEFAULT_MYSQL_OPTIONS };
|
||||
}
|
||||
|
||||
export function normalizeSchemaDiffCompareOptions(options: Partial<SchemaDiffCompareOptions> | null | undefined, dbType = "postgres"): SchemaDiffCompareOptions {
|
||||
return {
|
||||
...getDefaultOptionsForDbType(dbType),
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyConfig(id: string, name: string): SchemaDiffConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,8 @@ pub struct SchemaDiffPreparationOptions {
|
|||
pub ignore_comments: bool,
|
||||
#[serde(default)]
|
||||
pub cascade_delete: bool,
|
||||
#[serde(default)]
|
||||
pub compare_column_order: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -361,7 +363,12 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
|
|||
for name in common {
|
||||
let Some(source) = source_details.get(name.as_str()) else { continue };
|
||||
let Some(target) = target_details.get(name.as_str()) else { continue };
|
||||
let column_diffs = diff_columns_with_options(&source.columns, &target.columns, options.ignore_comments);
|
||||
let column_diffs = diff_columns_with_options(
|
||||
&source.columns,
|
||||
&target.columns,
|
||||
options.ignore_comments,
|
||||
options.compare_column_order,
|
||||
);
|
||||
let index_diffs = diff_indexes(&source.indexes, &target.indexes);
|
||||
let foreign_key_diffs = diff_foreign_keys(&source.foreign_keys, &target.foreign_keys);
|
||||
let trigger_diffs = diff_triggers(&source.triggers, &target.triggers);
|
||||
|
|
@ -408,15 +415,25 @@ fn diff_names(source: &[String], target: &[String]) -> (Vec<String>, Vec<String>
|
|||
}
|
||||
|
||||
pub fn diff_columns(source: &[ColumnInfo], target: &[ColumnInfo]) -> Vec<ColumnDiff> {
|
||||
diff_columns_with_options(source, target, false)
|
||||
diff_columns_with_options(source, target, false, false)
|
||||
}
|
||||
|
||||
fn diff_columns_with_options(source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool) -> Vec<ColumnDiff> {
|
||||
fn diff_columns_with_options(
|
||||
source: &[ColumnInfo],
|
||||
target: &[ColumnInfo],
|
||||
ignore_comments: bool,
|
||||
compare_column_order: bool,
|
||||
) -> Vec<ColumnDiff> {
|
||||
let mut diffs = Vec::new();
|
||||
let target_map: HashMap<&str, &ColumnInfo> = target.iter().map(|column| (column.name.as_str(), column)).collect();
|
||||
let source_map: HashMap<&str, &ColumnInfo> = source.iter().map(|column| (column.name.as_str(), column)).collect();
|
||||
let target_position_map: HashMap<&str, usize> =
|
||||
target.iter().enumerate().map(|(index, column)| (column.name.as_str(), index)).collect();
|
||||
let can_compare_order = compare_column_order
|
||||
&& source.len() == target.len()
|
||||
&& source.iter().all(|column| target_map.contains_key(column.name.as_str()));
|
||||
|
||||
for source_column in source {
|
||||
for (source_index, source_column) in source.iter().enumerate() {
|
||||
if let Some(target_column) = target_map.get(source_column.name.as_str()) {
|
||||
let mut changes = Vec::new();
|
||||
if source_column.data_type.to_lowercase() != target_column.data_type.to_lowercase() {
|
||||
|
|
@ -448,6 +465,13 @@ fn diff_columns_with_options(source: &[ColumnInfo], target: &[ColumnInfo], ignor
|
|||
source_column.comment.as_deref().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if can_compare_order {
|
||||
if let Some(target_index) = target_position_map.get(source_column.name.as_str()) {
|
||||
if source_index != *target_index {
|
||||
changes.push(format!("order: {} → {}", *target_index + 1, source_index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
diffs.push(ColumnDiff {
|
||||
diff_type: "modified".to_string(),
|
||||
|
|
@ -1117,7 +1141,9 @@ pub fn generate_schema_sync_sql(
|
|||
"modified" => {
|
||||
if let Some(source) = &column.source {
|
||||
if is_mysql {
|
||||
parts.push(format!(" MODIFY COLUMN {}", column_def(source, db_type)));
|
||||
if column.changes.iter().any(|change| !change.starts_with("order:")) {
|
||||
parts.push(format!(" MODIFY COLUMN {}", column_def(source, db_type)));
|
||||
}
|
||||
} else {
|
||||
let name = quote_id(&column.name, db_type);
|
||||
if column.changes.iter().any(|change| change.starts_with("type:")) {
|
||||
|
|
@ -1429,6 +1455,31 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_column_order_when_option_is_disabled() {
|
||||
let diffs = diff_columns_with_options(
|
||||
&[column("id", "int", None), column("name", "varchar(64)", None), column("status", "varchar(16)", None)],
|
||||
&[column("status", "varchar(16)", None), column("id", "int", None), column("name", "varchar(64)", None)],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(diffs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_column_order_when_option_is_enabled() {
|
||||
let diffs = diff_columns_with_options(
|
||||
&[column("id", "int", None), column("name", "varchar(64)", None), column("status", "varchar(16)", None)],
|
||||
&[column("status", "varchar(16)", None), column("id", "int", None), column("name", "varchar(64)", None)],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(diffs.len(), 3);
|
||||
assert_eq!(diffs[0].changes, vec!["order: 2 → 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_modified_indexes_not_only_added_or_removed_indexes() {
|
||||
let diffs = diff_indexes(
|
||||
|
|
@ -1654,6 +1705,7 @@ mod tests {
|
|||
target_schema: None,
|
||||
ignore_comments: true,
|
||||
cascade_delete: false,
|
||||
compare_column_order: false,
|
||||
};
|
||||
|
||||
let result = prepare_schema_diff(options);
|
||||
|
|
@ -1706,6 +1758,7 @@ mod tests {
|
|||
target_schema: None,
|
||||
ignore_comments: false,
|
||||
cascade_delete: false,
|
||||
compare_column_order: false,
|
||||
};
|
||||
|
||||
let result = prepare_schema_diff(options);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "../../apps/desktop/src/lib/schemaDiffTableFilter.ts";
|
||||
import type { TableInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
import { normalizeSchemaDiffCompareOptions } from "../../apps/desktop/src/types/schemaDiff.ts";
|
||||
|
||||
function table(name: string): TableInfo {
|
||||
return {
|
||||
name,
|
||||
table_type: "BASE TABLE",
|
||||
comment: null,
|
||||
parent_schema: null,
|
||||
parent_name: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("filters schema diff tables before detail loading", () => {
|
||||
const filter = compileSchemaDiffTableFilter(
|
||||
normalizeSchemaDiffCompareOptions({
|
||||
tableIncludePattern: "^user_|^orders$",
|
||||
tableExcludePattern: "_bak$",
|
||||
tableFilterPriority: "exclude",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = filterSchemaDiffTables([table("user_profile"), table("user_profile_bak"), table("orders"), table("audit_log")], [table("user_profile"), table("orders_bak"), table("orders")], filter);
|
||||
|
||||
assert.deepEqual(
|
||||
result.sourceTables.map((item) => item.name),
|
||||
["user_profile", "orders"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.targetTables.map((item) => item.name),
|
||||
["user_profile", "orders"],
|
||||
);
|
||||
});
|
||||
|
||||
test("lets include priority keep tables that also match exclude", () => {
|
||||
const filter = compileSchemaDiffTableFilter(
|
||||
normalizeSchemaDiffCompareOptions({
|
||||
tableIncludePattern: "^user_",
|
||||
tableExcludePattern: "_bak$",
|
||||
tableFilterPriority: "include",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = filterSchemaDiffTables([table("user_profile_bak")], [], filter);
|
||||
|
||||
assert.deepEqual(
|
||||
result.sourceTables.map((item) => item.name),
|
||||
["user_profile_bak"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid schema diff table regex", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
compileSchemaDiffTableFilter(
|
||||
normalizeSchemaDiffCompareOptions({
|
||||
tableIncludePattern: "[",
|
||||
}),
|
||||
),
|
||||
/Invalid include table name regex/,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue