feat(transfer): batch-select transfer objects from a pasted name list
This commit is contained in:
16 files changed
+605
-7
No files matched your search
@@ -142,6 +142,10 @@ const showCrossFamilyViewHint = computed(() => {
|
||||
if (!allowed.includes("VIEW")) return false;
|
||||
return !isSameTransferFamily(transferDatabaseTypeForConnection(sourceConfig), transferDatabaseTypeForConnection(targetConfig)) && (selectedObjects.value.VIEW?.size ?? 0) > 0;
|
||||
});
|
||||
// 「批量录入」允许的 schema.table / db.table 前缀:取当前源已选的 catalog / 库 / schema,
|
||||
// 便于用户从其他工具粘贴带前缀的对象名;全为空时前端退化为只按对象名匹配。
|
||||
const objectQualifiers = computed(() => [sourceCatalog.value, sourceDatabase.value, sourceSchema.value].filter((value) => value.trim().length > 0));
|
||||
|
||||
const pendingSourceSchemaPrefill = ref("");
|
||||
const pendingSelectedTablesPrefill = ref<string[] | null>(null);
|
||||
// Pending object selection for saved-task loading (covers all object kinds,
|
||||
@@ -1320,7 +1324,7 @@ async function saveConfigTask() {
|
||||
<div v-if="(!loadingObjects && !sourceConnectionId) || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
|
||||
{{ t("transfer.selectSourceFirst") }}
|
||||
</div>
|
||||
<ObjectSelectionTree v-model="treeSelection" :groups="treeGroups" :disabled-groups="treeDisabledGroups" :disabled-hints="treeDisabledHints" v-model:search="objectSearch" :loading="loadingObjects" class="min-h-0 flex-1" />
|
||||
<ObjectSelectionTree v-model="treeSelection" :groups="treeGroups" :disabled-groups="treeDisabledGroups" :disabled-hints="treeDisabledHints" :qualifiers="objectQualifiers" v-model:search="objectSearch" :loading="loadingObjects" class="min-h-0 flex-1" />
|
||||
<div v-if="showCrossFamilyViewHint" class="mt-1.5 rounded-md border border-amber-300/40 bg-amber-50 px-2 py-1.5 text-xs text-amber-700">
|
||||
{{ t("transfer.crossFamilyViewHint") }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useI18n } from "vue-i18n";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import type { TransferObjectKind } from "@/lib/backend/api";
|
||||
import { Square, CheckSquare, MinusSquare, Search, ChevronRight } from "@lucide/vue";
|
||||
import { Square, CheckSquare, MinusSquare, Search, ChevronRight, X } from "@lucide/vue";
|
||||
import { matchBulkObjectNames } from "./transferSelections";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -29,6 +30,11 @@ const props = defineProps<{
|
||||
modelValue: Record<string, string[]>;
|
||||
search?: string;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 「批量录入」可接受的名称前缀(当前源的 catalog / 库 / schema)。
|
||||
* 用于识别粘贴内容里的 schema.table 前缀,留空时放宽为只按对象名匹配。
|
||||
*/
|
||||
qualifiers?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -215,6 +221,54 @@ function deselectAll() {
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
// 「批量录入」面板状态:showBulkInput 控制面板展开,bulkText 存放粘贴内容,
|
||||
// bulkFeedback 保存最近一次匹配结果,用于反馈「已勾选多少项 / 哪些名称未匹配」。
|
||||
const showBulkInput = ref(false);
|
||||
const bulkText = ref("");
|
||||
const bulkFeedback = ref<{ matchedCount: number; unmatchedNames: string[] } | null>(null);
|
||||
|
||||
function toggleBulkInput() {
|
||||
showBulkInput.value = !showBulkInput.value;
|
||||
if (showBulkInput.value) {
|
||||
// 每次重新打开都清空上一次的输入与结果,避免误用旧文本
|
||||
bulkText.value = "";
|
||||
bulkFeedback.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行批量勾选。匹配始终基于完整对象清单(不受当前搜索过滤影响),
|
||||
* 结果与已有选择合并(只追加勾选,不清除手工勾选或被搜索隐藏的选项)。
|
||||
*/
|
||||
function applyBulkSelection() {
|
||||
const result = matchBulkObjectNames(bulkText.value, props.groups, props.disabledGroups, props.qualifiers ?? []);
|
||||
if (result.matchedCount > 0) {
|
||||
const next: Record<string, string[]> = { ...props.modelValue };
|
||||
for (const [kind, names] of Object.entries(result.matched)) {
|
||||
if (names.length === 0) continue;
|
||||
next[kind] = [...new Set([...(next[kind] ?? []), ...names])];
|
||||
}
|
||||
emit("update:modelValue", next);
|
||||
revealMatchedGroups(result.matched);
|
||||
}
|
||||
bulkFeedback.value = { matchedCount: result.matchedCount, unmatchedNames: result.unmatchedNames };
|
||||
}
|
||||
|
||||
// 批量勾选后展开命中的分组:大清单分组默认折叠,展开后用户才能看到勾选结果
|
||||
function revealMatchedGroups(matched: Record<string, string[]>) {
|
||||
const next = new Set(expanded.value);
|
||||
let changed = false;
|
||||
for (const [kind, names] of Object.entries(matched)) {
|
||||
if (names.length === 0 || next.has(kind)) continue;
|
||||
next.add(kind);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) expanded.value = next;
|
||||
}
|
||||
|
||||
// 未匹配名称拼成一行展示,并用 title 提供完整内容(列表可能被截断)
|
||||
const bulkUnmatchedNames = computed(() => bulkFeedback.value?.unmatchedNames.join(", ") ?? "");
|
||||
|
||||
// The bulk button flips between “select all” and “deselect all”. It shows
|
||||
// “deselect all” only when every visible, enabled item is already selected
|
||||
// (selecting more is then a no-op); otherwise “select all” is shown so the
|
||||
@@ -261,6 +315,9 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
|
||||
class="pl-8 pr-2.5 h-8 w-full rounded-md border border-input bg-transparent text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring transition-colors placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
</div>
|
||||
<Button data-test="bulk-open" variant="outline" size="sm" @click="toggleBulkInput">
|
||||
{{ t("transfer.bulkSelectObjects") }}
|
||||
</Button>
|
||||
<Button v-if="allVisibleEnabledSelected" variant="outline" size="sm" @click="deselectAll">
|
||||
{{ t("transfer.deselectAll") }}
|
||||
</Button>
|
||||
@@ -269,6 +326,42 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 批量录入面板:粘贴多个对象名,一键勾选清单里匹配到的项。
|
||||
放在列表上方(而不是浮层)以避免与传输对话框的滚动/层级互相干扰。 -->
|
||||
<div v-if="showBulkInput" data-test="bulk-panel" class="shrink-0 rounded-md border border-border/80 bg-muted/10 p-2">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="flex-1 text-xs font-semibold text-foreground/90">{{ t("transfer.bulkSelectTitle") }}</span>
|
||||
<button data-test="bulk-close" type="button" class="shrink-0 p-0.5 text-muted-foreground hover:text-foreground" @click="toggleBulkInput">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mb-1.5 text-[11px] leading-snug text-muted-foreground">{{ t("transfer.bulkSelectHint") }}</p>
|
||||
<textarea
|
||||
data-test="bulk-input"
|
||||
v-model="bulkText"
|
||||
rows="4"
|
||||
:placeholder="t('transfer.bulkSelectPlaceholder')"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="mb-1.5 min-h-16 w-full resize-y rounded-md border border-input bg-transparent px-2 py-1.5 font-mono text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
<div class="flex items-end gap-2">
|
||||
<div data-test="bulk-feedback" class="min-w-0 flex-1 text-[11px] leading-snug">
|
||||
<p v-if="bulkFeedback && bulkFeedback.matchedCount > 0" class="text-foreground/80">
|
||||
{{ t("transfer.bulkSelectMatched", { count: bulkFeedback.matchedCount }) }}
|
||||
</p>
|
||||
<p v-if="bulkFeedback && bulkFeedback.unmatchedNames.length > 0" class="truncate text-amber-700" :title="bulkUnmatchedNames">
|
||||
{{ t("transfer.bulkSelectUnmatched", { count: bulkFeedback.unmatchedNames.length, names: bulkUnmatchedNames }) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button data-test="bulk-confirm" variant="outline" size="sm" class="shrink-0" @click="applyBulkSelection">
|
||||
{{ t("transfer.bulkSelectConfirm") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-4 text-center text-sm text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
@@ -306,7 +399,7 @@ function groupSelectionState(kind: TransferObjectKind, items: string[]): "none"
|
||||
<input type="checkbox" class="h-3.5 w-3.5" :checked="selectedSet(group.kind).has(item)" :disabled="isGroupDisabled(group.kind)" @change="toggleItem(group.kind, item)" />
|
||||
<span class="truncate text-foreground/80">{{ item }}</span>
|
||||
</label>
|
||||
<div v-if="group.items.length === 0" class="px-1 py-1 text-xs text-muted-foreground">无匹配</div>
|
||||
<div v-if="group.items.length === 0" class="px-1 py-1 text-xs text-muted-foreground">{{ t("transfer.noMatchingObjects") }}</div>
|
||||
</div>
|
||||
<div v-else class="pl-6 pr-1 mt-1" :style="{ height: `${OBJECT_LIST_HEIGHT}px` }">
|
||||
<RecycleScroller v-slot="{ item }" class="h-full" :items="group.items" :item-size="OBJECT_ITEM_HEIGHT" :buffer="OBJECT_LIST_BUFFER">
|
||||
|
||||
+145
-3
@@ -22,6 +22,7 @@ vi.mock("@lucide/vue", () => {
|
||||
MinusSquare: Icon,
|
||||
Search: Icon,
|
||||
ChevronRight: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
vi.mock("vue-virtual-scroller", () => ({
|
||||
@@ -67,8 +68,8 @@ const LARGE_TABLES: TreeGroup = {
|
||||
items: Array.from({ length: 40_000 }, (_, index) => (index === 20_000 ? "customer_order_special" : `table_${String(index).padStart(5, "0")}`)),
|
||||
};
|
||||
|
||||
function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabledHints?: Record<string, string>; selection?: Record<string, string[]>; search?: string }) {
|
||||
const { groups = [VIEWS, FUNCTIONS], disabledGroups = [], disabledHints = {}, selection = {}, search = "" } = init;
|
||||
function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabledHints?: Record<string, string>; selection?: Record<string, string[]>; search?: string; qualifiers?: string[] }) {
|
||||
const { groups = [VIEWS, FUNCTIONS], disabledGroups = [], disabledHints = {}, selection = {}, search = "", qualifiers } = init;
|
||||
const state = reactive({ selection, search, groups });
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
@@ -85,6 +86,7 @@ function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabl
|
||||
"onUpdate:search": (v: string) => {
|
||||
state.search = v;
|
||||
},
|
||||
qualifiers,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -216,7 +218,7 @@ describe("ObjectSelectionTree interaction", () => {
|
||||
|
||||
expect(container.querySelector('label[data-test="item-FUNCTION-box"]')).toBeNull();
|
||||
expect(container.querySelector('label[data-test="item-SEQUENCE-biz_banner_id_seq"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-test="group-FUNCTION"]')?.textContent).toContain("无匹配");
|
||||
expect(container.querySelector('[data-test="group-FUNCTION"]')?.textContent).toContain("transfer.noMatchingObjects");
|
||||
});
|
||||
|
||||
it("search ranks exact-prefix matches before mid-name matches", async () => {
|
||||
@@ -374,3 +376,143 @@ describe("ObjectSelectionTree interaction", () => {
|
||||
expect(groupToggle(container).dataset.state).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
async function openBulkPanel(container: HTMLElement) {
|
||||
container.querySelector<HTMLButtonElement>('button[data-test="bulk-open"]')!.click();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function fillBulkInput(container: HTMLElement, value: string) {
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('textarea[data-test="bulk-input"]')!;
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function confirmBulkSelection(container: HTMLElement) {
|
||||
container.querySelector<HTMLButtonElement>('button[data-test="bulk-confirm"]')!.click();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function bulkFeedbackText(container: HTMLElement): string {
|
||||
return container.querySelector('[data-test="bulk-feedback"]')?.textContent ?? "";
|
||||
}
|
||||
|
||||
// i18n 在测试里被 mock 成直接返回 key,因此未匹配的名称通过 title 属性断言
|
||||
function bulkUnmatchedTitle(container: HTMLElement): string {
|
||||
return container.querySelector('[data-test="bulk-feedback"] p[title]')?.getAttribute("title") ?? "";
|
||||
}
|
||||
|
||||
describe("ObjectSelectionTree bulk input", () => {
|
||||
it("stays collapsed until the toolbar button is clicked", async () => {
|
||||
const { container, app } = mountTree({});
|
||||
cleanup = () => app.unmount();
|
||||
expect(container.querySelector('[data-test="bulk-panel"]')).toBeNull();
|
||||
|
||||
await openBulkPanel(container);
|
||||
await nextTick();
|
||||
expect(container.querySelector('[data-test="bulk-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('textarea[data-test="bulk-input"]')).not.toBeNull();
|
||||
|
||||
container.querySelector<HTMLButtonElement>('button[data-test="bulk-close"]')!.click();
|
||||
await nextTick();
|
||||
expect(container.querySelector('[data-test="bulk-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the pasted names across groups and reports the match count", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "T1\nv3, v1");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["t1"]);
|
||||
expect(state.selection.VIEW).toEqual(["v1", "v3"]);
|
||||
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectMatched");
|
||||
});
|
||||
|
||||
it("merges the matches with the existing selection", async () => {
|
||||
const { state, container, app } = mountTree({ selection: { VIEW: ["v1"] } });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "v2");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.VIEW).toEqual(["v1", "v2"]);
|
||||
});
|
||||
|
||||
it("reports names that are missing from the catalog", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "t1\nmissing_table");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["t1"]);
|
||||
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectUnmatched");
|
||||
expect(bulkUnmatchedTitle(container)).toContain("missing_table");
|
||||
});
|
||||
|
||||
it("matches the full list even while a search filter is active", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS] });
|
||||
cleanup = () => app.unmount();
|
||||
await typeSearch(container, "v1");
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "t2");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["t2"]);
|
||||
});
|
||||
|
||||
it("accepts schema-qualified names only for the current schema", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS], qualifiers: ["public"] });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "public.t1\nother.t2");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["t1"]);
|
||||
expect(bulkUnmatchedTitle(container)).toContain("other.t2");
|
||||
});
|
||||
|
||||
it("skips disabled groups", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [TABLES, VIEWS], disabledGroups: ["VIEW"] });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "t1\nv1");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["t1"]);
|
||||
expect(state.selection.VIEW ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands the group that received matches", async () => {
|
||||
const { state, container, app } = mountTree({ groups: [LARGE_TABLES] });
|
||||
cleanup = () => app.unmount();
|
||||
// 大清单默认折叠,批量勾选后应自动展开,否则用户看不到勾选结果
|
||||
expect(container.querySelectorAll('label[data-test^="item-TABLE-"]')).toHaveLength(0);
|
||||
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "customer_order_special");
|
||||
await confirmBulkSelection(container);
|
||||
|
||||
expect(state.selection.TABLE).toEqual(["customer_order_special"]);
|
||||
expect(container.querySelectorAll('label[data-test^="item-TABLE-"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clears the previous input and feedback when reopened", async () => {
|
||||
const { container, app } = mountTree({ groups: [TABLES, VIEWS] });
|
||||
cleanup = () => app.unmount();
|
||||
await openBulkPanel(container);
|
||||
await fillBulkInput(container, "t1");
|
||||
await confirmBulkSelection(container);
|
||||
expect(bulkFeedbackText(container)).toContain("transfer.bulkSelectMatched");
|
||||
|
||||
await openBulkPanel(container);
|
||||
await nextTick();
|
||||
await openBulkPanel(container);
|
||||
await nextTick();
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea[data-test="bulk-input"]')!.value).toBe("");
|
||||
expect(bulkFeedbackText(container)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTransferObjectSelections } from "../transferSelections";
|
||||
import { buildTransferObjectSelections, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
|
||||
|
||||
function setOf(names: string[]): Set<string> {
|
||||
return new Set(names);
|
||||
@@ -42,3 +42,77 @@ describe("buildTransferObjectSelections", () => {
|
||||
expect(buildTransferObjectSelections({}, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBulkObjectNames", () => {
|
||||
it("splits on newlines, commas, semicolons and tabs, dropping blanks", () => {
|
||||
expect(parseBulkObjectNames("orders, users;\npayments\t\tlogs\n\n ")).toEqual(["orders", "users", "payments", "logs"]);
|
||||
});
|
||||
|
||||
it("returns an empty list for blank input", () => {
|
||||
expect(parseBulkObjectNames(" \n\t ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchBulkObjectNames", () => {
|
||||
const groups = [
|
||||
{ kind: "TABLE", items: ["orders", "users", "Order_Items"] },
|
||||
{ kind: "VIEW", items: ["v_orders"] },
|
||||
];
|
||||
|
||||
it("matches case-insensitively and ignores surrounding quotes", () => {
|
||||
const result = matchBulkObjectNames('"ORDERS"\n`users`', groups);
|
||||
expect(result.matched).toEqual({ TABLE: ["orders", "users"] });
|
||||
expect(result.matchedCount).toBe(2);
|
||||
expect(result.unmatchedNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the catalog spelling of matched items", () => {
|
||||
expect(matchBulkObjectNames("order_items", groups).matched).toEqual({ TABLE: ["Order_Items"] });
|
||||
});
|
||||
|
||||
it("collects names missing from the catalog", () => {
|
||||
const result = matchBulkObjectNames("orders, missing_table", groups);
|
||||
expect(result.matchedCount).toBe(1);
|
||||
expect(result.unmatchedNames).toEqual(["missing_table"]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated names across separators and case", () => {
|
||||
const result = matchBulkObjectNames("orders\nORDERS, orders", groups);
|
||||
expect(result.matched).toEqual({ TABLE: ["orders"] });
|
||||
expect(result.matchedCount).toBe(1);
|
||||
expect(result.unmatchedNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts schema-qualified names for the current schema only", () => {
|
||||
const result = matchBulkObjectNames("public.orders\nother.orders\ncatalog.public.users", groups, [], ["public"]);
|
||||
expect(result.matched).toEqual({ TABLE: ["orders", "users"] });
|
||||
expect(result.unmatchedNames).toEqual(["other.orders"]);
|
||||
});
|
||||
|
||||
it("falls back to plain name matching when no qualifier is available", () => {
|
||||
expect(matchBulkObjectNames("public.orders", groups).matched).toEqual({ TABLE: ["orders"] });
|
||||
});
|
||||
|
||||
it("treats a fully quoted dotted name as a literal name", () => {
|
||||
const result = matchBulkObjectNames('"my.table"', [{ kind: "TABLE", items: ["my.table"] }]);
|
||||
expect(result.matched).toEqual({ TABLE: ["my.table"] });
|
||||
});
|
||||
|
||||
it("skips disabled object kinds and reports their names as unmatched", () => {
|
||||
const result = matchBulkObjectNames("orders\nv_orders", groups, ["VIEW"]);
|
||||
expect(result.matched).toEqual({ TABLE: ["orders"] });
|
||||
expect(result.unmatchedNames).toEqual(["v_orders"]);
|
||||
});
|
||||
|
||||
it("selects a duplicated name in the first matching group only", () => {
|
||||
const duplicated = [
|
||||
{ kind: "TABLE", items: ["orders"] },
|
||||
{ kind: "VIEW", items: ["orders"] },
|
||||
];
|
||||
expect(matchBulkObjectNames("orders", duplicated).matched).toEqual({ TABLE: ["orders"] });
|
||||
});
|
||||
|
||||
it("returns no matches for blank input", () => {
|
||||
expect(matchBulkObjectNames(" \n", groups)).toEqual({ matched: {}, matchedCount: 0, unmatchedNames: [] });
|
||||
});
|
||||
});
|
||||
@@ -20,3 +20,161 @@ export function buildTransferObjectSelections(selectedObjects: Partial<Record<Tr
|
||||
names: [...(selectedObjects[kind] ?? [])],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「批量录入」可匹配的对象分组:kind 为对象类型,items 是该类型下的全部对象名。
|
||||
* 与 ObjectSelectionTree 的 ObjectTreeGroup 结构保持一致,只取匹配需要的字段。
|
||||
*/
|
||||
export interface BulkObjectNameGroup {
|
||||
kind: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/** 「批量录入」的匹配结果 */
|
||||
export interface BulkObjectNameMatchResult {
|
||||
/** 命中的对象:kind -> 名称列表(使用清单中的原始拼写,顺序与清单一致) */
|
||||
matched: Record<string, string[]>;
|
||||
/** 实际勾选的对象数量,即 matched 中所有名称之和 */
|
||||
matchedCount: number;
|
||||
/** 未匹配上的名称,保留用户输入的原始拼写,按输入顺序去重 */
|
||||
unmatchedNames: string[];
|
||||
}
|
||||
|
||||
// 批量录入支持的分隔符:换行、逗号、分号、制表符
|
||||
const BULK_NAME_SEPARATOR = /[\r\n,;\t]+/;
|
||||
|
||||
// 成对包裹标识符的引号,兼容 "orders" / 'orders' / `orders` / [orders]
|
||||
const IDENTIFIER_WRAPPERS: Array<[string, string]> = [
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["`", "`"],
|
||||
["[", "]"],
|
||||
];
|
||||
|
||||
function identifierWrapper(value: string): [string, string] | undefined {
|
||||
return IDENTIFIER_WRAPPERS.find(([open, close]) => value.length >= 2 && value.startsWith(open) && value.endsWith(close));
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉标识符两侧成对的引号。只有首尾引号成对匹配时才剥离,
|
||||
* 避免把 a."b 这类不完整输入误处理成别的名称。
|
||||
*/
|
||||
function stripIdentifierQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const wrapper = identifierWrapper(trimmed);
|
||||
return wrapper ? trimmed.slice(1, -1).trim() : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按引号与大小写无关的方式归一化名称,用于批量匹配的比较。
|
||||
*/
|
||||
function normalizeBulkName(value: string): string {
|
||||
return stripIdentifierQuotes(value).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「批量录入」文本切分为名称列表:支持换行、逗号、分号、制表符分隔,
|
||||
* 自动忽略空行与首尾空白。
|
||||
*/
|
||||
export function parseBulkObjectNames(text: string): string[] {
|
||||
return text
|
||||
.split(BULK_NAME_SEPARATOR)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
interface BulkNameToken {
|
||||
/** 用户输入的原始拼写(trim 后),用于未匹配提示 */
|
||||
raw: string;
|
||||
/** 归一化后的对象名;前缀 schema 不被接受时为 null(视为未匹配) */
|
||||
key: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个输入项。支持 schema.table / catalog.schema.table 前缀:
|
||||
* 取最后一段作为对象名,前缀需与当前源库/schema 匹配(忽略大小写与引号);
|
||||
* 若调用方没有提供任何可用的前缀(例如源 schema 为空),则放宽为按对象名匹配。
|
||||
* 整体被引号包裹时(如 "my.table")视为字面名称,不再拆分前缀。
|
||||
*/
|
||||
function resolveBulkToken(token: string, qualifiers: readonly string[]): BulkNameToken {
|
||||
const raw = token.trim();
|
||||
if (!raw) return { raw, key: null };
|
||||
|
||||
if (identifierWrapper(raw)) {
|
||||
const name = normalizeBulkName(raw);
|
||||
return { raw, key: name.length > 0 ? name : null };
|
||||
}
|
||||
|
||||
if (raw.includes(".")) {
|
||||
const segments = raw.split(".");
|
||||
const namePart = segments.pop() ?? "";
|
||||
const accepted = qualifiers.length === 0 || segments.some((segment) => qualifierMatches(segment, qualifiers));
|
||||
if (!accepted) return { raw, key: null };
|
||||
const name = normalizeBulkName(namePart);
|
||||
return { raw, key: name.length > 0 ? name : null };
|
||||
}
|
||||
|
||||
const name = normalizeBulkName(raw);
|
||||
return { raw, key: name.length > 0 ? name : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断前缀段(db / schema / catalog)是否与当前源库信息一致。
|
||||
*/
|
||||
function qualifierMatches(segment: string, qualifiers: readonly string[]): boolean {
|
||||
const normalized = normalizeBulkName(segment);
|
||||
if (!normalized) return false;
|
||||
return qualifiers.some((qualifier) => normalizeBulkName(qualifier) === normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「批量录入」的文本匹配到对象清单上,得到需要勾选的对象。
|
||||
*
|
||||
* 匹配规则:
|
||||
* - 忽略大小写与成对引号;
|
||||
* - 支持 schema.table / catalog.schema.table 前缀,前缀与当前源库/schema 不一致的条目按未匹配处理;
|
||||
* - 同名对象只在清单中首个命中的分组里勾选(避免表与视图重名时被重复勾选);
|
||||
* - 禁用(当前传输不支持)的对象类型不参与匹配。
|
||||
*
|
||||
* @param text 用户粘贴的原始文本
|
||||
* @param groups 完整的对象清单(不受当前搜索过滤影响)
|
||||
* @param disabledKinds 当前传输不支持、需要跳过的对象类型
|
||||
* @param qualifiers 可接受的对象名前缀(当前源的 catalog / 库 / schema)
|
||||
*/
|
||||
export function matchBulkObjectNames(text: string, groups: BulkObjectNameGroup[], disabledKinds: readonly string[] = [], qualifiers: readonly string[] = []): BulkObjectNameMatchResult {
|
||||
const tokens = parseBulkObjectNames(text).map((token) => resolveBulkToken(token, qualifiers));
|
||||
|
||||
// 归一化名称 -> 输入中的首个原始拼写;匹配到即从 pending 中移除,用于去重与判定未匹配
|
||||
const pending = new Map<string, string>();
|
||||
for (const token of tokens) {
|
||||
if (token.key !== null && !pending.has(token.key)) pending.set(token.key, token.raw);
|
||||
}
|
||||
|
||||
const matched: Record<string, string[]> = {};
|
||||
let matchedCount = 0;
|
||||
for (const group of groups) {
|
||||
if (disabledKinds.includes(group.kind)) continue;
|
||||
for (const item of group.items) {
|
||||
const key = normalizeBulkName(item);
|
||||
if (!pending.has(key)) continue;
|
||||
pending.delete(key);
|
||||
if (!matched[group.kind]) matched[group.kind] = [];
|
||||
matched[group.kind].push(item);
|
||||
matchedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 未匹配 = 前缀被拒绝的条目 + 清单中找不到的条目,按输入顺序去重
|
||||
const unmatchedNames: string[] = [];
|
||||
const seenUnmatched = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
const isUnmatched = token.key === null || pending.has(token.key);
|
||||
if (!isUnmatched) continue;
|
||||
const dedupeKey = token.key ?? `#${token.raw.toLowerCase()}`;
|
||||
if (seenUnmatched.has(dedupeKey)) continue;
|
||||
seenUnmatched.add(dedupeKey);
|
||||
unmatchedNames.push(token.raw);
|
||||
}
|
||||
|
||||
return { matched, matchedCount, unmatchedNames };
|
||||
}
|
||||
Reference in new issue
Block a user