fix(export): allow selecting and exporting non-table objects for Kingbase

This commit is contained in:
lxk955
2026-10-18 11:03:53 +08:00
committed by GitHub
parent a7fb7aeff4
commit de0fbc681a
7 changed files with 314 additions and 80 deletions
@@ -326,8 +326,16 @@ async function loadTables(preferredTable = "", preferredTables: string[] = []) {
const tableInfos = await api.listTables(connectionId.value, database.value, schema.value);
const names = sortDatabaseTableNames(tableInfos.map((table) => table.name));
tables.value = names;
const preferredSet = new Set(preferredTables.filter((name) => names.includes(name)));
selectedTables.value = preferredSet.size > 0 ? names.filter((name) => preferredSet.has(name)) : preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
// Kingbase 系的 listTables 同时返回视图(relkind v/m);预填里命中视图的项
// 必须走 include_objects 通道,否则后端对象门会静默丢弃它们。
const viewTypeNames = new Set(tableInfos.filter((table) => table.table_type.toUpperCase().includes("VIEW")).map((table) => table.name));
const preferredSet = new Set(preferredTables.filter((name) => names.includes(name) && !viewTypeNames.has(name)));
const nonTablePrefills = preferredTables.filter((name) => !names.includes(name) || viewTypeNames.has(name));
if (nonTablePrefills.length > 0) {
includeObjects.value = true;
}
const matchedTableNames = names.filter((name) => preferredSet.has(name));
selectedTables.value = preferredSet.size > 0 || nonTablePrefills.length > 0 ? [...matchedTableNames, ...nonTablePrefills] : preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
} catch (e: any) {
tableError.value = e?.message || String(e);
} finally {
@@ -768,7 +768,7 @@ const filteredRows = computed(() => groupedRows.value.rows);
function partitionRowDepth(row: ObjectBrowserRow): number {
return groupedRows.value.depths.get(row.id) ?? 0;
}
const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE"));
const selectableRows = computed(() => rows.value);
// ---- Grid (tile) view virtualization ----
// The grid view chunks filteredRows into fixed-height rows and hands them to
@@ -860,12 +860,15 @@ const gridRows = computed(() => {
}
return rows;
});
const visibleSelectableRows = computed(() => filteredRows.value.filter((row) => row.type === "TABLE"));
const visibleSelectableRows = computed(() => filteredRows.value);
const selectedTableRows = computed(() => {
const ids = selectedTableIds.value;
return selectableRows.value.filter((row) => ids.has(row.id));
});
const selectedTableCount = computed(() => selectedTableRows.value.length);
const hasSelectedTablesOnly = computed(() => selectedTableRows.value.length > 0 && selectedTableRows.value.every((row) => row.type === "TABLE"));
const batchDropConfirmTitle = computed(() => (hasSelectedTablesOnly.value ? t("objects.confirmBatchDropTitle") : t("contextMenu.confirmBatchDropTitle")));
const batchDropConfirmMessage = computed(() => (hasSelectedTablesOnly.value ? t("objects.confirmBatchDropMessage", { count: selectedTableCount.value }) : t("contextMenu.confirmBatchDropMessage", { count: selectedTableCount.value })));
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
@@ -1166,15 +1169,15 @@ function selectTableRangeFromAnchor(row: ObjectBrowserRow) {
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
selectedRowId.value = row.id;
if (row.type === "TABLE" && event.shiftKey) {
if (event.shiftKey) {
selectTableRangeFromAnchor(row);
return;
}
if (row.type === "TABLE" && (event.metaKey || event.ctrlKey)) {
if (event.metaKey || event.ctrlKey) {
toggleTableSelectionWithAnchor(row);
return;
}
if (row.type === "TABLE") tableSelectionAnchorId.value = row.id;
tableSelectionAnchorId.value = row.id;
const activation = settingsStore.editorSettings.sidebarActivation;
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value);
// Double click: cancel any pending single-click and fire immediately. When
@@ -2649,7 +2652,6 @@ function setSelectedTableIds(ids: Set<string>) {
}
function toggleTableSelection(row: ObjectBrowserRow) {
if (row.type !== "TABLE") return;
const next = new Set(selectedTableIds.value);
if (next.has(row.id)) {
next.delete(row.id);
@@ -2688,7 +2690,7 @@ async function fetchSortedTableRowsForDrop(): Promise<ObjectBrowserRow[]> {
const rows = [...selectedTableRows.value];
if (rows.length <= 1) return rows;
const fkResults = await Promise.all(rows.map((row) => api.listForeignKeys(props.connection.id, props.database, row.schema || selectedSchema.value || "", row.name, props.catalog).catch(() => [] as ForeignKeyInfo[])));
const fkResults = await Promise.all(rows.map((row) => (row.type === "TABLE" ? api.listForeignKeys(props.connection.id, props.database, row.schema || selectedSchema.value || "", row.name, props.catalog).catch(() => [] as ForeignKeyInfo[]) : Promise.resolve([] as ForeignKeyInfo[]))));
const tablesWithFk: TableWithFk[] = rows.map((row, i) => ({
name: row.name,
@@ -2706,7 +2708,7 @@ async function refreshBatchDropPreviewSql() {
const sortedRows = await fetchSortedTableRowsForDrop();
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
for (const row of sortedRows) {
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })).catch(() => "");
const sql = await buildDropSqlForRow(row, { cascade: useCascade }).catch(() => "");
if (sql) statements.push(sql);
}
batchDropPreviewSql.value = statements.join("\n");
@@ -2736,7 +2738,7 @@ async function confirmBatchDropTables() {
const plan = await Promise.all(
targets.map(async (target) => ({
target,
sql: await buildDropTableSql(tableAdminSqlOptions(target, { cascade: useCascade })),
sql: await buildDropSqlForRow(target, { cascade: useCascade }),
})),
);
batchSql = plan.map(({ sql }) => sql).join(";\n");
@@ -2773,7 +2775,7 @@ async function confirmBatchDropTables() {
}
if (result.failed) throw result.failed;
toast(t("objects.batchDropSuccess", { count: result.succeeded.length }));
toast(hasSelectedTablesOnly.value ? t("objects.batchDropSuccess", { count: result.succeeded.length }) : t("contextMenu.batchDropSuccess", { count: result.succeeded.length }));
} catch (e: any) {
if (!batchDropRecorded && batchSql) {
await recordTableMutationHistory(getTableMutationHistoryStoreOrNull(), {
@@ -4379,7 +4381,7 @@ function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
}
function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
return selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
}
function addToAiMenuItem(item: ObjectBrowserRow): ContextMenuItem {
@@ -4425,10 +4427,10 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
}
const useBatchActions = isSelectedBatchTableContext(item);
const moreActions: ContextMenuItem[] = [];
if (supportsVacuumTable.value) {
if (supportsVacuumTable.value && (!useBatchActions || hasSelectedTablesOnly.value)) {
moreActions.push({ sidebarActionId: "contextMenu.vacuumTable", label: t("contextMenu.vacuumTable"), action: () => requestVacuumTable(item), icon: Activity, variant: "destructive" as const });
}
if (supportsTruncateTable.value) {
if (supportsTruncateTable.value && (!useBatchActions || hasSelectedTablesOnly.value)) {
moreActions.push({
sidebarActionId: "contextMenu.truncateTable",
label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"),
@@ -4437,22 +4439,22 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
variant: "destructive" as const,
});
}
moreActions.push(
{
if (!useBatchActions || hasSelectedTablesOnly.value) {
moreActions.push({
sidebarActionId: "contextMenu.emptyTable",
label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"),
action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item),
icon: Eraser,
variant: "destructive" as const,
},
{
sidebarActionId: "contextMenu.dropTable",
label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"),
action: useBatchActions ? requestBatchDropTables : () => requestDrop(item),
icon: Trash2,
variant: "destructive" as const,
},
);
});
}
moreActions.push({
sidebarActionId: "contextMenu.dropTable",
label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"),
action: useBatchActions ? requestBatchDropTables : () => requestDrop(item),
icon: Trash2,
variant: "destructive" as const,
});
return [
{ sidebarActionId: "contextMenu.viewData", label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{
@@ -4784,11 +4786,11 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.copyTableSelected") }}
</Button>
<Button v-if="supportsBatchTableActions && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Button v-if="supportsBatchTableActions && supportsTruncateTable && hasSelectedTablesOnly" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Scissors class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.truncateSelected") }}
</Button>
<Button v-if="supportsBatchTableActions" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Button v-if="supportsBatchTableActions && hasSelectedTablesOnly" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Eraser class="mr-1.5 h-3.5 w-3.5" />
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
</Button>
@@ -4964,7 +4966,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
@click="onRowClick(item, $event)"
@contextmenu="onContextMenu"
>
<button v-if="showCheckboxColumn" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
<button v-if="showCheckboxColumn" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" @click.stop="toggleTableSelection(item)">
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
@@ -5032,7 +5034,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
@click="onRowClick(item, $event)"
@contextmenu="onContextMenu"
>
<button v-if="showCheckboxColumn" class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
<button v-if="showCheckboxColumn" class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" @click.stop="toggleTableSelection(item)">
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
@@ -5075,7 +5077,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
@click="onRowClick(item, $event)"
@contextmenu="onContextMenu"
>
<button v-if="showCheckboxColumn" class="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
<button v-if="showCheckboxColumn" class="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" @click.stop="toggleTableSelection(item)">
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
@@ -5399,16 +5401,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-model:open="showBatchDropConfirm"
:title="t('objects.confirmBatchDropTitle')"
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
:sql="batchDropPreviewSql"
:confirm-label="t('objects.dropSelected')"
:loading="batchDropExecuting"
:close-on-confirm="false"
@confirm="confirmBatchDropTables"
>
<DangerConfirmDialog v-model:open="showBatchDropConfirm" :title="batchDropConfirmTitle" :message="batchDropConfirmMessage" :sql="batchDropPreviewSql" :confirm-label="t('objects.dropSelected')" :loading="batchDropExecuting" :close-on-confirm="false" @confirm="confirmBatchDropTables">
<template #options>
<div v-if="batchDropExecuting" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
@@ -17,34 +17,34 @@ describe("objectBrowserTableSelectionRange", () => {
expect(objectBrowserTableSelectionRange(rows, "TABLE-d", "TABLE-b")).toEqual(["TABLE-b", "TABLE-c", "TABLE-d"]);
});
it("drops non-table rows inside the span, since only tables are selectable", () => {
it("includes non-table rows inside the span (views, procedures, etc.)", () => {
const rows = [row("TABLE", "a"), row("VIEW", "v1"), row("PROCEDURE", "p1"), row("TABLE", "b")];
expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-b")).toEqual(["TABLE-a", "TABLE-b"]);
expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-b")).toEqual(["TABLE-a", "VIEW-v1", "PROCEDURE-p1", "TABLE-b"]);
});
it("returns just the current row when the anchor is not selectable and current is a table", () => {
it("returns just the current row when the anchor is not found in rows", () => {
const rows = [row("TABLE", "a"), row("TABLE", "b")];
expect(objectBrowserTableSelectionRange(rows, "missing-anchor", "TABLE-b")).toEqual(["TABLE-b"]);
});
it("returns an empty range when the anchor is missing and current row is not a table", () => {
it("returns the current non-table row when the anchor is missing", () => {
const rows = [row("VIEW", "v1"), row("TABLE", "b")];
expect(objectBrowserTableSelectionRange(rows, "missing-anchor", "VIEW-v1")).toEqual([]);
expect(objectBrowserTableSelectionRange(rows, "missing-anchor", "VIEW-v1")).toEqual(["VIEW-v1"]);
});
it("collapses to a single row when anchor and current are the same table", () => {
it("collapses to a single row when anchor and current are the same row", () => {
const rows = [row("TABLE", "a"), row("TABLE", "b")];
expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-a")).toEqual(["TABLE-a"]);
});
});
describe("objectBrowserTableSelectionAnchor", () => {
it("keeps a visible table anchor", () => {
const rows = [row("TABLE", "a"), row("TABLE", "b")];
expect(objectBrowserTableSelectionAnchor(rows, "TABLE-a", "TABLE-b")).toBe("TABLE-a");
it("keeps a visible anchor for any object type", () => {
const rows = [row("VIEW", "v1"), row("TABLE", "b")];
expect(objectBrowserTableSelectionAnchor(rows, "VIEW-v1", "TABLE-b")).toBe("VIEW-v1");
});
it("uses the current table when the anchor is missing or no longer visible", () => {
it("uses the current row when the anchor is missing or no longer visible", () => {
const rows = [row("TABLE", "a"), row("TABLE", "b")];
expect(objectBrowserTableSelectionAnchor(rows, null, "TABLE-b")).toBe("TABLE-b");
expect(objectBrowserTableSelectionAnchor(rows, "TABLE-filtered-out", "TABLE-b")).toBe("TABLE-b");
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { buildSelectedTablesPayload, isDatabaseExportTableSelectionValid } from "@/lib/export/databaseExportSelection";
describe("buildSelectedTablesPayload", () => {
it("returns undefined when all tables are selected without extra objects", () => {
const allTables = ["users", "orders", "products"];
const selectedTables = ["users", "orders", "products"];
expect(buildSelectedTablesPayload(allTables, selectedTables)).toBeUndefined();
});
it("returns filtered tables when a subset of tables is selected", () => {
const allTables = ["users", "orders", "products"];
const selectedTables = ["orders"];
expect(buildSelectedTablesPayload(allTables, selectedTables)).toEqual(["orders"]);
});
it("preserves non-table object names (views, sequences, routines) in the selection payload", () => {
const allTables = ["users", "orders"];
const selectedTables = ["orders", "v_active_users", "user_id_seq"];
expect(buildSelectedTablesPayload(allTables, selectedTables)).toEqual(["orders", "v_active_users", "user_id_seq"]);
});
it("returns only non-table objects when no tables are selected", () => {
const allTables = ["users", "orders"];
const selectedTables = ["v_active_users", "user_id_seq"];
expect(buildSelectedTablesPayload(allTables, selectedTables)).toEqual(["v_active_users", "user_id_seq"]);
});
it("returns empty array when selectedTables is empty", () => {
const allTables = ["users", "orders"];
const selectedTables: string[] = [];
expect(buildSelectedTablesPayload(allTables, selectedTables)).toEqual([]);
});
});
describe("isDatabaseExportTableSelectionValid", () => {
it("is valid when neither structure nor data is selected", () => {
expect(
isDatabaseExportTableSelectionValid({
allTableCount: 10,
selectedTableCount: 0,
includeStructure: false,
includeData: false,
}),
).toBe(true);
});
it("is valid when tables are present and at least one is selected", () => {
expect(
isDatabaseExportTableSelectionValid({
allTableCount: 10,
selectedTableCount: 1,
includeStructure: true,
includeData: true,
}),
).toBe(true);
});
it("is invalid when tables exist but none are selected with structure or data enabled", () => {
expect(
isDatabaseExportTableSelectionValid({
allTableCount: 10,
selectedTableCount: 0,
includeStructure: true,
includeData: false,
}),
).toBe(false);
});
});
@@ -1,10 +1,12 @@
export function buildSelectedTablesPayload(allTables: string[], selectedTables: string[]): string[] | undefined {
if (allTables.length > 0 && selectedTables.length === allTables.length) {
if (allTables.length > 0 && selectedTables.length === allTables.length && allTables.every((t, i) => selectedTables[i] === t)) {
return undefined;
}
const selected = new Set(selectedTables);
return allTables.filter((table) => selected.has(table));
const filtered = allTables.filter((table) => selected.has(table));
const nonTables = selectedTables.filter((name) => !allTables.includes(name));
return [...filtered, ...nonTables];
}
export function isDatabaseExportTableSelectionValid(options: { allTableCount: number; selectedTableCount: number; includeStructure: boolean; includeData: boolean }): boolean {
@@ -1,25 +1,21 @@
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
export function objectBrowserTableSelectionAnchor(rows: readonly ObjectBrowserRow[], anchorId: string | null, currentId: string): string {
if (anchorId && rows.some((row) => row.id === anchorId && row.type === "TABLE")) return anchorId;
if (anchorId && rows.some((row) => row.id === anchorId)) return anchorId;
return currentId;
}
/**
* Compute the contiguous table-id range for a shift-click in the object
* browser, mirroring the sidebar tree's range-select behavior
* (sidebarTreeSelection.ts): the range spans every row between the anchor
* and the clicked row in visible order, but only TABLE rows are selectable
* so non-table rows inside the span are dropped from the result.
* Compute the contiguous object-id range for a shift-click in the object
* browser, mirroring the sidebar tree's range-select behavior:
* the range spans every row between the anchor and the clicked row
* in visible order.
*/
export function objectBrowserTableSelectionRange(rows: readonly ObjectBrowserRow[], anchorId: string, currentId: string): string[] {
const anchorIndex = rows.findIndex((row) => row.id === anchorId);
const currentIndex = rows.findIndex((row) => row.id === currentId);
if (anchorIndex < 0 || currentIndex < 0) return rows.some((row) => row.id === currentId && row.type === "TABLE") ? [currentId] : [];
if (anchorIndex < 0 || currentIndex < 0) return rows.some((row) => row.id === currentId) ? [currentId] : [];
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
return rows
.slice(start, end + 1)
.filter((row) => row.type === "TABLE")
.map((row) => row.id);
return rows.slice(start, end + 1).map((row) => row.id);
}
+177 -16
View File
@@ -265,10 +265,18 @@ fn exports_mysql_trigger_objects(db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::Mysql)
}
/// Oracle writes each table's triggers into its table DDL (see `loadTableTriggerDDLs` in
/// `agents/drivers/oracle-go`), so a trigger that reads `seq.NEXTVAL` reaches the script
/// whenever structure is exported. The standalone sequences it depends on are written
/// before the tables of a whole-schema structure export so the script replays (#11673).
/// Standalone sequences (Oracle, Kingbase, Vastbase) are exported before tables so the DDL
/// and default expressions replay properly.
fn exports_standalone_sequence_objects(request: &DatabaseExportRequest, db_type: DatabaseType) -> bool {
if !matches!(db_type, DatabaseType::Oracle | DatabaseType::Kingbase | DatabaseType::Vastbase) {
return false;
}
if request.include_objects {
return true;
}
exports_oracle_sequence_objects(request, db_type)
}
fn exports_oracle_sequence_objects(request: &DatabaseExportRequest, db_type: DatabaseType) -> bool {
matches!(db_type, DatabaseType::Oracle) && request.include_structure && request.selected_tables.is_empty()
}
@@ -4131,12 +4139,25 @@ async fn export_database_sql_core_inner(
} else {
Vec::new()
};
let all_tables = filter_export_table_infos(all_tables, &request.selected_tables, &request.excluded_tables)
let all_tables = filter_export_table_infos(all_tables, &[], &request.excluded_tables)
.into_iter()
.filter(|table| !postgres_extension_members.relation_names.contains(&table.name))
.collect::<Vec<_>>();
let mut tables: Vec<_> = all_tables.iter().filter(|t| !t.table_type.contains("VIEW")).collect();
let mut views: Vec<_> = all_tables.iter().filter(|t| t.table_type.contains("VIEW")).collect();
let mut tables: Vec<_> = all_tables
.iter()
.filter(|t| !t.table_type.contains("VIEW"))
.filter(|t| request.selected_tables.is_empty() || request.selected_tables.contains(&t.name))
.collect();
let mut views: Vec<_> = all_tables
.iter()
.filter(|t| t.table_type.contains("VIEW"))
.filter(|t| {
if !request.include_objects {
return false;
}
request.selected_tables.is_empty() || request.selected_tables.contains(&t.name)
})
.collect();
if request.include_objects && db_type == DatabaseType::Mysql && views.len() > 1 {
match list_mysql_export_view_dependencies(state, &request.connection_id, &request.database, &client_session_id)
.await
@@ -4214,11 +4235,11 @@ async fn export_database_sql_core_inner(
let mut functions: Vec<crate::types::ObjectInfo> = Vec::new();
let mut triggers: Vec<crate::types::ObjectInfo> = Vec::new();
let mut events: Vec<crate::types::ObjectInfo> = Vec::new();
let mut oracle_sequences: Vec<crate::types::ObjectInfo> = Vec::new();
let mut standalone_sequences: Vec<crate::types::ObjectInfo> = Vec::new();
let collects_schema_wide_objects = exports_schema_wide_objects(request);
let collects_oracle_sequences = exports_oracle_sequence_objects(request, db_type);
let collects_standalone_sequences = exports_standalone_sequence_objects(request, db_type);
if collects_schema_wide_objects || collects_oracle_sequences {
if collects_schema_wide_objects || collects_standalone_sequences {
match crate::schema::list_objects_core(
state,
&request.connection_id,
@@ -4239,8 +4260,8 @@ async fn export_database_sql_core_inner(
continue;
}
if ot == "SEQUENCE" {
if collects_oracle_sequences && !is_oracle_identity_sequence(&obj.name) {
oracle_sequences.push(obj.clone());
if collects_standalone_sequences && !is_oracle_identity_sequence(&obj.name) {
standalone_sequences.push(obj.clone());
}
continue;
}
@@ -4263,6 +4284,12 @@ async fn export_database_sql_core_inner(
}
}
if !request.selected_tables.is_empty() {
standalone_sequences.retain(|s| request.selected_tables.contains(&s.name));
procedures.retain(|p| request.selected_tables.contains(&p.name));
functions.retain(|f| request.selected_tables.contains(&f.name));
}
// A determinate total must describe the same object categories guarded by
// the execution branches below, not every object discovered in the schema.
let total_objects = database_export_total_objects(
@@ -4271,7 +4298,7 @@ async fn export_database_sql_core_inner(
tables: tables.len(),
views: views.len(),
sequences: postgres_sequences.iter().filter(|sequence| !sequence.created_by_table).count()
+ oracle_sequences.len(),
+ standalone_sequences.len(),
extensions: postgres_extensions.len(),
enums: postgres_enums.len(),
procedures: procedures.len(),
@@ -4477,7 +4504,7 @@ async fn export_database_sql_core_inner(
object_index += 1;
}
for sequence in &oracle_sequences {
for sequence in &standalone_sequences {
if is_export_cancelled(&request.export_id).await {
return Err("Export cancelled".to_string());
}
@@ -4510,6 +4537,7 @@ async fn export_database_sql_core_inner(
&ObjectSourceKind::Sequence,
&sequence.name,
&obj_source.source,
&request.schema,
request.drop_table_if_exists,
);
if !source.is_empty() {
@@ -4953,6 +4981,7 @@ async fn export_database_sql_core_inner(
&ObjectSourceKind::View,
view_name,
&obj_source.source,
&request.schema,
request.drop_table_if_exists,
);
if !source.is_empty() {
@@ -5012,6 +5041,7 @@ async fn export_database_sql_core_inner(
&ObjectSourceKind::Procedure,
proc_name,
&obj_source.source,
&request.schema,
request.drop_table_if_exists,
);
if !source.is_empty() {
@@ -5071,6 +5101,7 @@ async fn export_database_sql_core_inner(
&ObjectSourceKind::Function,
func_name,
&obj_source.source,
&request.schema,
request.drop_table_if_exists,
);
if !source.is_empty() {
@@ -5136,6 +5167,7 @@ async fn export_database_sql_core_inner(
&object_type,
object_name,
&obj_source.source,
&request.schema,
request.drop_table_if_exists,
);
if !source.is_empty() {
@@ -5231,8 +5263,35 @@ fn build_database_export_object_source_sql(
object_type: &ObjectSourceKind,
object_name: &str,
source: &str,
schema: &str,
drop_if_exists: bool,
) -> String {
let wrapped_source;
let source = if matches!(object_type, ObjectSourceKind::View | ObjectSourceKind::MaterializedView)
&& !source.trim().to_ascii_uppercase().starts_with("CREATE")
{
wrapped_source = crate::object_source_sql::build_view_ddl_sql(crate::object_source_sql::BuildViewDdlInput {
database_type: Some(database_type),
schema: Some(schema.to_string()),
name: object_name.to_string(),
source: source.to_string(),
identifier_quote: None,
});
&wrapped_source
} else {
source
};
// 独立序列在表之前导出,而 agent 序列源内嵌的 `alter sequence ... owned by`
// 引用所属表,重放时表还不存在;导出侧直接去掉该语句(DBeaver 同样不导出 OWNED BY)。
let stripped_source;
let source = if matches!(object_type, ObjectSourceKind::Sequence)
&& matches!(database_type, DatabaseType::Kingbase | DatabaseType::Vastbase)
{
stripped_source = strip_agent_sequence_owned_by(source);
&stripped_source
} else {
source
};
let source = build_export_object_source_sql(database_type, object_type.clone(), source);
// 非 MySQL 系没有这种 definer 语法;且 strip_mysql_definer 对不含该片段的 DDL 是空操作,
// 所以放宽到整个 MySQL 系是安全的,能顺带覆盖 Doris/StarRocks 等同族方言。
@@ -5257,6 +5316,20 @@ fn build_database_export_object_source_sql(
format!("DROP {object_type} IF EXISTS {object_name};\n{source}")
}
/// agent 序列源以空行分块(create sequence / alter ... owner to / alter ... owned by),
/// 导出时整块剔除 `owned by` 语句,其余内容原样保留。
fn strip_agent_sequence_owned_by(source: &str) -> String {
let mut kept: Vec<&str> = Vec::new();
for block in source.split("\n\n") {
let lower = block.trim().to_ascii_lowercase();
if lower.starts_with("alter sequence ") && lower.contains(" owned by ") {
continue;
}
kept.push(block);
}
kept.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::{
@@ -5672,6 +5745,71 @@ mod tests {
assert!(!is_oracle_identity_sequence("iseq$$_custom"));
}
#[test]
fn standalone_sequences_are_exported_for_oracle_kingbase_vastbase() {
use super::exports_standalone_sequence_objects;
let cases = [
("kingbase include objects", DatabaseType::Kingbase, export_request(false, false, true, Vec::new()), true),
("kingbase structure only", DatabaseType::Kingbase, export_request(true, false, false, Vec::new()), false),
("vastbase include objects", DatabaseType::Vastbase, export_request(false, false, true, Vec::new()), true),
("oracle include objects", DatabaseType::Oracle, export_request(false, false, true, Vec::new()), true),
("oracle structure only", DatabaseType::Oracle, export_request(true, false, false, Vec::new()), true),
("postgres include objects", DatabaseType::Postgres, export_request(false, false, true, Vec::new()), false),
("mysql include objects", DatabaseType::Mysql, export_request(false, false, true, Vec::new()), false),
];
for (name, db_type, request, expected) in cases {
assert_eq!(exports_standalone_sequence_objects(&request, db_type), expected, "{name}");
}
}
#[test]
fn kingbase_view_definition_without_create_is_wrapped() {
let view_def = "SELECT id, name FROM public.users WHERE active = true";
let ddl = build_database_export_object_source_sql(
DatabaseType::Kingbase,
&ObjectSourceKind::View,
"v_active_users",
view_def,
"public",
false,
);
assert!(ddl.starts_with(
"CREATE OR REPLACE VIEW \"public\".\"v_active_users\" AS\nSELECT id, name FROM public.users WHERE active = true;"
));
}
#[test]
fn agent_sequence_owned_by_is_stripped_from_kingbase_export() {
let source = "-- auto-generated definition\ncreate sequence permissions_id_seq\n increment by 1\n minvalue 1\n maxvalue 9223372036854775807\n start with 1\n no cycle;\n\nalter sequence permissions_id_seq owner to app_user;\n\nalter sequence permissions_id_seq owned by permissions.id;";
let ddl = build_database_export_object_source_sql(
DatabaseType::Kingbase,
&ObjectSourceKind::Sequence,
"permissions_id_seq",
source,
"public",
false,
);
// 独立序列在表之前写入导出文件,owned by 引用的表此时还不存在。
assert!(!ddl.to_ascii_lowercase().contains("owned by"));
assert!(ddl.contains("alter sequence permissions_id_seq owner to app_user;"));
assert!(ddl.contains("create sequence permissions_id_seq"));
}
#[test]
fn kingbase_view_definition_with_create_is_not_double_wrapped() {
let view_def = "CREATE VIEW \"v_users\" AS SELECT id FROM public.users;";
let ddl = build_database_export_object_source_sql(
DatabaseType::Kingbase,
&ObjectSourceKind::View,
"v_users",
view_def,
"public",
false,
);
assert_eq!(ddl, "CREATE VIEW \"v_users\" AS SELECT id FROM public.users;");
}
#[test]
fn mysql_database_export_drops_triggers_and_events_before_create() {
let trigger = "CREATE DEFINER=`root`@`%` TRIGGER `trg_orders_ai` AFTER INSERT ON `orders` FOR EACH ROW INSERT INTO audit_log(msg) VALUES ('x')";
@@ -5687,6 +5825,7 @@ mod tests {
&ObjectSourceKind::Trigger,
"trg_orders_ai",
trigger,
"public",
true
),
format!(
@@ -5700,6 +5839,7 @@ mod tests {
&ObjectSourceKind::Event,
"ev_purge",
event,
"public",
true
),
format!(
@@ -5714,6 +5854,7 @@ mod tests {
&ObjectSourceKind::Trigger,
"trg_orders_ai",
trigger,
"public",
false
),
build_export_object_source_sql(DatabaseType::Mysql, ObjectSourceKind::Trigger, &stripped_trigger)
@@ -5732,6 +5873,7 @@ mod tests {
&ObjectSourceKind::View,
"mysql_table",
view,
"public",
true
),
"DROP VIEW IF EXISTS `mysql_table`;\nCREATE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `mysql_table` AS select 1;"
@@ -5744,7 +5886,14 @@ mod tests {
let view = "CREATE DEFINER=`root`@`%` VIEW `v` AS select 1";
assert_eq!(
build_database_export_object_source_sql(DatabaseType::Doris, &ObjectSourceKind::View, "v", view, true),
build_database_export_object_source_sql(
DatabaseType::Doris,
&ObjectSourceKind::View,
"v",
view,
"public",
true
),
"CREATE VIEW `v` AS select 1;"
);
}
@@ -5756,7 +5905,14 @@ mod tests {
let view = "CREATE DEFINER=`root`@`%` VIEW public.v AS SELECT 1";
assert_eq!(
build_database_export_object_source_sql(DatabaseType::Postgres, &ObjectSourceKind::View, "v", view, true),
build_database_export_object_source_sql(
DatabaseType::Postgres,
&ObjectSourceKind::View,
"v",
view,
"public",
true
),
"CREATE DEFINER=`root`@`%` VIEW public.v AS SELECT 1;"
);
}
@@ -6112,6 +6268,7 @@ mod tests {
&ObjectSourceKind::View,
"active`rows",
"CREATE VIEW `active``rows` AS SELECT 1",
"public",
true,
);
@@ -6125,6 +6282,7 @@ mod tests {
&ObjectSourceKind::Procedure,
"refresh_cache",
"CREATE PROCEDURE `refresh_cache`() BEGIN SELECT 1; END",
"public",
true,
);
let function = build_database_export_object_source_sql(
@@ -6132,6 +6290,7 @@ mod tests {
&ObjectSourceKind::Function,
"active_count",
"CREATE FUNCTION `active_count`() RETURNS INT RETURN 1",
"public",
true,
);
@@ -6152,6 +6311,7 @@ mod tests {
&ObjectSourceKind::View,
"active_rows",
"CREATE VIEW `active_rows` AS SELECT 1",
"public",
false,
);
let postgres_with_drop = build_database_export_object_source_sql(
@@ -6159,6 +6319,7 @@ mod tests {
&ObjectSourceKind::View,
"active_rows",
"CREATE VIEW active_rows AS SELECT 1",
"public",
true,
);