Merge pull request #702 from DengQingNian/optimize/export-pagination-orderby-batchsize
perf(ui): 优化数据导出的ui体验和性能
This commit is contained in:
commit
ff2210e5a4
|
|
@ -122,6 +122,7 @@ const editDisconnectTabHandlingMode = ref<DisconnectTabHandlingMode>(
|
|||
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
|
||||
const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments);
|
||||
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
|
||||
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
|
||||
const redisScanPageSizeOptions = [200, 1000, 5000, 10000];
|
||||
const systemFonts = ref<string[]>([]);
|
||||
const systemFontsLoading = ref(false);
|
||||
|
|
@ -269,6 +270,7 @@ watch(
|
|||
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
|
||||
editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments;
|
||||
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
|
||||
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
|
||||
editSnippets.value = settingsStore.editorSettings.snippets.map((s) => ({ ...s }));
|
||||
void loadSystemFontOptions();
|
||||
}
|
||||
|
|
@ -309,6 +311,7 @@ function hasChanges(): boolean {
|
|||
editDisconnectTabHandlingMode.value !== settingsStore.editorSettings.disconnectTabHandlingMode ||
|
||||
editSidebarHideTableComments.value !== settingsStore.editorSettings.sidebarHideTableComments ||
|
||||
editSidebarAllowHorizontalScroll.value !== settingsStore.editorSettings.sidebarAllowHorizontalScroll ||
|
||||
editExportBatchSize.value !== settingsStore.editorSettings.exportBatchSize ||
|
||||
JSON.stringify(normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value)) !==
|
||||
JSON.stringify(settingsStore.editorSettings.sidebarHiddenTablePrefixes) ||
|
||||
JSON.stringify(editSnippets.value) !== JSON.stringify(settingsStore.editorSettings.snippets)
|
||||
|
|
@ -338,6 +341,7 @@ async function persistSettings() {
|
|||
sidebarHideTableComments: editSidebarHideTableComments.value,
|
||||
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
|
||||
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
|
||||
exportBatchSize: editExportBatchSize.value,
|
||||
snippets: editSnippets.value,
|
||||
});
|
||||
await settingsStore.updateDesktopSettings({
|
||||
|
|
@ -379,6 +383,7 @@ function resetDefaults() {
|
|||
editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments;
|
||||
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
|
||||
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
|
||||
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
|
||||
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
|
||||
}
|
||||
|
||||
|
|
@ -453,6 +458,7 @@ type SettingsCategory =
|
|||
| "editor"
|
||||
| "appearance"
|
||||
| "navigation"
|
||||
| "data"
|
||||
| "redis"
|
||||
| "shortcuts"
|
||||
| "snippets"
|
||||
|
|
@ -465,6 +471,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
|
|||
{ value: "editor", label: t("settings.editorTab") },
|
||||
{ value: "appearance", label: t("settings.appearanceTab") },
|
||||
{ value: "navigation", label: t("settings.navigationTab") },
|
||||
{ value: "data", label: t("settings.dataTab") },
|
||||
{ value: "redis", label: t("settings.redisTab") },
|
||||
{ value: "shortcuts", label: t("settings.shortcutsTab") },
|
||||
{ value: "snippets", label: t("settings.snippetsTab") },
|
||||
|
|
@ -478,6 +485,7 @@ const settingsTabsWithApplyFooter = new Set<SettingsCategory>([
|
|||
"editor",
|
||||
"appearance",
|
||||
"navigation",
|
||||
"data",
|
||||
"redis",
|
||||
"shortcuts",
|
||||
"snippets",
|
||||
|
|
@ -1622,6 +1630,35 @@ watch(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Tab -->
|
||||
<section v-else-if="activeSettingsTab === 'data'" class="flex flex-col gap-5 py-2">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-muted-foreground">{{ t("settings.exportSection") }}</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.exportBatchSize") }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
list="export-batch-sizes"
|
||||
min="100"
|
||||
max="100000"
|
||||
step="100"
|
||||
v-model.number="editExportBatchSize"
|
||||
class="h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<datalist id="export-batch-sizes">
|
||||
<option value="500" />
|
||||
<option value="1000" />
|
||||
<option value="2000" />
|
||||
<option value="5000" />
|
||||
<option value="10000" />
|
||||
</datalist>
|
||||
<span class="text-xs text-muted-foreground">{{ t("settings.exportBatchSizeDescription") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSettingsTab === 'redis'" class="flex flex-col gap-5 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.redisScanPageSize") }}</Label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown } from "@lucide/vue";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
|
||||
const open = ref(false);
|
||||
const showAll = ref(false);
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
const reversedTasks = computed(() => {
|
||||
return [...tasks.value].reverse();
|
||||
});
|
||||
|
||||
const visibleTasks = computed(() => {
|
||||
return showAll.value ? reversedTasks.value : reversedTasks.value.slice(0, MAX_VISIBLE);
|
||||
});
|
||||
|
||||
const hasMore = computed(() => tasks.value.length > MAX_VISIBLE);
|
||||
|
||||
const isActive = (status: string) => status === "Running" || status === "Writing";
|
||||
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
|
||||
|
||||
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
|
||||
|
||||
const progressPercent = (totalRows: number | null, rowsExported: number) => {
|
||||
if (!totalRows || totalRows <= 0) return 0;
|
||||
return Math.min(100, Math.round((rowsExported / totalRows) * 100));
|
||||
};
|
||||
|
||||
const rowsText = (task: { totalRows: number | null; rowsExported: number }) => {
|
||||
if (task.totalRows) {
|
||||
return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
|
||||
}
|
||||
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
|
||||
};
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Running":
|
||||
case "Writing":
|
||||
return Loader2;
|
||||
case "Done":
|
||||
return CheckCircle2;
|
||||
case "Error":
|
||||
return XCircle;
|
||||
case "Cancelled":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Loader2;
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "Running":
|
||||
case "Writing":
|
||||
return "text-primary";
|
||||
case "Done":
|
||||
return "text-green-500";
|
||||
case "Error":
|
||||
return "text-destructive";
|
||||
case "Cancelled":
|
||||
return "text-yellow-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
function toggleShowAll() {
|
||||
showAll.value = !showAll.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-if="tasks.length > 0" v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8"
|
||||
:title="t('exportProgress.tooltip')"
|
||||
:class="{ 'bg-accent text-primary': hasActive }"
|
||||
>
|
||||
<FileDown class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActive"
|
||||
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground"
|
||||
>
|
||||
{{ activeCount > 9 ? "9+" : activeCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" class="w-80 p-0 gap-0 overflow-hidden" :side-offset="8">
|
||||
<div class="border-b bg-muted/40 px-3 py-2">
|
||||
<div class="text-sm font-semibold">{{ t("exportProgress.popoverTitle") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="task in visibleTasks"
|
||||
:key="task.exportId"
|
||||
class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0"
|
||||
>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component
|
||||
:is="statusIcon(task.status)"
|
||||
:class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate font-medium">{{ task.tableName }}.{{ task.format }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
:class="{ 'animate-pulse': !task.totalRows }"
|
||||
:style="{ width: task.totalRows ? `${progressPercent(task.totalRows, task.rowsExported)}%` : '50%' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="task.status === 'Done'" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span class="tabular-nums">{{ rowsText(task) }}</span>
|
||||
<span
|
||||
v-if="task.status === 'Error' && task.errorMessage"
|
||||
class="truncate ml-2 text-destructive"
|
||||
:title="task.errorMessage"
|
||||
>
|
||||
{{ task.errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions: stop/cancel for active, delete for finished -->
|
||||
<div class="flex shrink-0 self-center">
|
||||
<button
|
||||
v-if="isActive(task.status)"
|
||||
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
|
||||
:title="t('exportProgress.cancel')"
|
||||
@click="cancelTask(task.exportId)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
|
||||
:title="t('exportProgress.delete')"
|
||||
@click="removeTask(task.exportId)"
|
||||
>
|
||||
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore" class="border-t bg-muted/30 px-3 py-1.5">
|
||||
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="toggleShowAll">
|
||||
{{
|
||||
showAll ? t("exportProgress.showLess") : t("exportProgress.showMore", { count: tasks.length - MAX_VISIBLE })
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="finishedCount > 0" class="border-t bg-muted/30 px-3 py-1.5">
|
||||
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="clearFinished">
|
||||
{{ t("exportProgress.clearFinished") }}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
|
@ -23,6 +23,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import WindowControls from "@/components/layout/WindowControls.vue";
|
||||
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
|
||||
import { shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
|
||||
import { currentLocale, setLocale, type Locale } from "@/i18n";
|
||||
import type { AppThemeMode } from "@/lib/appTheme";
|
||||
|
|
@ -193,6 +194,8 @@ function onToolbarDblClick(e: MouseEvent) {
|
|||
<TooltipContent>{{ t("updates.check") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ExportProgressPopover />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
|
|
@ -75,6 +74,7 @@ import { copyToClipboard } from "@/lib/clipboard";
|
|||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
|
|
@ -156,15 +156,8 @@ const showBatchDropConfirm = ref(false);
|
|||
const batchDropPreviewSql = ref("");
|
||||
let loadId = 0;
|
||||
|
||||
// Export progress state
|
||||
const showExportProgress = ref(false);
|
||||
const exportProgressId = ref("");
|
||||
const exportProgressTableName = ref("");
|
||||
const exportProgressFormat = ref("");
|
||||
const exportProgressRows = ref(0);
|
||||
const exportProgressTotal = ref<number | null>(null);
|
||||
const exportProgressStatus = ref<string>("");
|
||||
const exportProgressError = ref<string | null>(null);
|
||||
// Export via background tracker
|
||||
const { addTask: addExportTask } = useExportTracker();
|
||||
|
||||
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
|
||||
const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length);
|
||||
|
|
@ -868,56 +861,44 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
|
|||
filePath = `__web_export_${webExportId}.${format}`;
|
||||
}
|
||||
|
||||
// Set up progress state and open dialog
|
||||
const exportId = generateDatabaseExportId();
|
||||
exportProgressId.value = exportId;
|
||||
exportProgressTableName.value = row.name;
|
||||
exportProgressFormat.value = format;
|
||||
exportProgressRows.value = 0;
|
||||
exportProgressTotal.value = null;
|
||||
exportProgressStatus.value = "Running";
|
||||
exportProgressError.value = null;
|
||||
showExportProgress.value = true;
|
||||
|
||||
// Get columns for neo4j only
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const request: api.TableExportRequest = {
|
||||
exportId,
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
filePath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
};
|
||||
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
await api.startTableExport(request, (progress) => {
|
||||
exportProgressRows.value = progress.rowsExported;
|
||||
exportProgressTotal.value = progress.totalRows;
|
||||
exportProgressStatus.value = progress.status;
|
||||
exportProgressError.value = progress.errorMessage || null;
|
||||
});
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
if (exportProgressStatus.value !== "Cancelled") {
|
||||
exportProgressStatus.value = "Error";
|
||||
exportProgressError.value = e?.message || String(e);
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
async function cancelExport() {
|
||||
if (exportProgressId.value) {
|
||||
await api.cancelTableExport(exportProgressId.value);
|
||||
task = addExportTask(row.name, format, filePath);
|
||||
const currentTask = task;
|
||||
const request: api.TableExportRequest = {
|
||||
exportId: currentTask.exportId,
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
filePath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
batchSize: settingsStore.editorSettings.exportBatchSize,
|
||||
};
|
||||
|
||||
const terminalProgress = await api.startTableExport(request, (progress) => {
|
||||
currentTask.rowsExported = progress.rowsExported;
|
||||
currentTask.totalRows = progress.totalRows;
|
||||
currentTask.status = progress.status;
|
||||
currentTask.errorMessage = progress.errorMessage || null;
|
||||
});
|
||||
if (terminalProgress.status === "Done") {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (task) {
|
||||
task.status = "Error";
|
||||
task.errorMessage = e?.message || String(e);
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1708,18 +1689,6 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ExportProgressDialog
|
||||
v-model:open="showExportProgress"
|
||||
:title="t('grid.exporting')"
|
||||
:table-name="exportProgressTableName"
|
||||
:format="exportProgressFormat"
|
||||
:rows-exported="exportProgressRows"
|
||||
:total-rows="exportProgressTotal"
|
||||
:status="exportProgressStatus"
|
||||
:error-message="exportProgressError"
|
||||
@cancel="cancelExport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ import {
|
|||
} from "@/lib/treeNodeClick";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
import { generateDatabaseExportId } from "@/lib/databaseExport";
|
||||
import {
|
||||
buildCreateDatabaseSql,
|
||||
buildDuckDbAttachDatabaseSql,
|
||||
|
|
@ -127,7 +126,7 @@ import {
|
|||
} from "@/lib/sidebarTreeSelection";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatShortcut } from "@/lib/shortcutRegistry";
|
||||
|
|
@ -162,26 +161,7 @@ type StructureCopyFormat = "tsv" | "markdown";
|
|||
type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
|
||||
const { getDatabaseOptions } = useDatabaseOptions();
|
||||
const showVisibleDatabasesDialog = ref(false);
|
||||
const exportProgressDialogOpen = ref(false);
|
||||
const exportProgress = ref<{
|
||||
title: string;
|
||||
tableName: string;
|
||||
format: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
}>({
|
||||
title: "",
|
||||
tableName: "",
|
||||
format: "",
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "",
|
||||
errorMessage: null,
|
||||
});
|
||||
const exportCancelled = ref(false);
|
||||
const currentExportId = ref("");
|
||||
const { addTask: addExportTask } = useExportTracker();
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
|
|
@ -2142,6 +2122,7 @@ async function exportTableData(format: "csv" | "xlsx") {
|
|||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (!config) return;
|
||||
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
|
||||
|
|
@ -2157,20 +2138,9 @@ async function exportTableData(format: "csv" | "xlsx") {
|
|||
outputPath = path as string;
|
||||
}
|
||||
|
||||
// Step 2: Prepare progress state and open dialog
|
||||
const exportId = generateDatabaseExportId();
|
||||
currentExportId.value = exportId;
|
||||
exportCancelled.value = false;
|
||||
exportProgress.value = {
|
||||
title: t("exportProgress.title"),
|
||||
tableName: node.label,
|
||||
format,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
};
|
||||
exportProgressDialogOpen.value = true;
|
||||
// Step 2: Register task in export tracker (background)
|
||||
task = addExportTask(node.label, format, outputPath);
|
||||
const currentTask = task;
|
||||
|
||||
// Step 3: Get query columns for neo4j
|
||||
const queryColumns =
|
||||
|
|
@ -2178,9 +2148,9 @@ async function exportTableData(format: "csv" | "xlsx") {
|
|||
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((c) => c.name)
|
||||
: undefined;
|
||||
|
||||
// Step 4: Start streaming export
|
||||
// Step 4: Start streaming export (background, non-blocking)
|
||||
const request: api.TableExportRequest = {
|
||||
exportId,
|
||||
exportId: currentTask.exportId,
|
||||
connectionId,
|
||||
database,
|
||||
schema: node.schema || undefined,
|
||||
|
|
@ -2188,16 +2158,14 @@ async function exportTableData(format: "csv" | "xlsx") {
|
|||
filePath: outputPath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
batchSize: settingsStore.editorSettings.exportBatchSize,
|
||||
};
|
||||
|
||||
await api.startTableExport(request, (progress) => {
|
||||
exportProgress.value = {
|
||||
...exportProgress.value,
|
||||
rowsExported: progress.rowsExported,
|
||||
totalRows: progress.totalRows,
|
||||
status: progress.status,
|
||||
errorMessage: progress.errorMessage || null,
|
||||
};
|
||||
currentTask.rowsExported = progress.rowsExported;
|
||||
currentTask.totalRows = progress.totalRows;
|
||||
currentTask.status = progress.status;
|
||||
currentTask.errorMessage = progress.errorMessage || null;
|
||||
if (progress.status === "Done") {
|
||||
toast(t("grid.exported"));
|
||||
} else if (progress.status === "Error") {
|
||||
|
|
@ -2205,25 +2173,11 @@ async function exportTableData(format: "csv" | "xlsx") {
|
|||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!exportCancelled.value) {
|
||||
exportProgress.value = {
|
||||
...exportProgress.value,
|
||||
status: "Error",
|
||||
errorMessage: e?.message || String(e),
|
||||
};
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelExport() {
|
||||
exportCancelled.value = true;
|
||||
if (currentExportId.value) {
|
||||
try {
|
||||
await api.cancelTableExport(currentExportId.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
if (task) {
|
||||
task.status = "Error";
|
||||
task.errorMessage = e?.message || String(e);
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3709,8 +3663,6 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
:confirm-label="t('contextMenu.dropSchema')"
|
||||
@confirm="confirmDropSchema"
|
||||
/>
|
||||
|
||||
<ExportProgressDialog v-model:open="exportProgressDialogOpen" v-bind="exportProgress" @cancel="cancelExport" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { reactive, computed } from "vue";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export interface ExportTask {
|
||||
exportId: string;
|
||||
tableName: string;
|
||||
format: "csv" | "xlsx";
|
||||
filePath: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const taskMap = reactive<Map<string, ExportTask>>(new Map());
|
||||
|
||||
export function useExportTracker() {
|
||||
const tasks = computed(() => Array.from(taskMap.values()));
|
||||
|
||||
const activeCount = computed(
|
||||
() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing").length,
|
||||
);
|
||||
|
||||
const hasActive = computed(() => activeCount.value > 0);
|
||||
|
||||
function addTask(tableName: string, format: "csv" | "xlsx", filePath: string): ExportTask {
|
||||
const exportId = crypto.randomUUID();
|
||||
const task = reactive<ExportTask>({
|
||||
exportId,
|
||||
tableName,
|
||||
format,
|
||||
filePath,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
});
|
||||
taskMap.set(exportId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function removeTask(exportId: string) {
|
||||
taskMap.delete(exportId);
|
||||
}
|
||||
|
||||
function clearFinished() {
|
||||
for (const [id, task] of taskMap) {
|
||||
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") {
|
||||
taskMap.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTask(exportId: string) {
|
||||
try {
|
||||
await api.cancelTableExport(exportId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
activeCount,
|
||||
hasActive,
|
||||
addTask,
|
||||
removeTask,
|
||||
clearFinished,
|
||||
cancelTask,
|
||||
};
|
||||
}
|
||||
|
|
@ -663,8 +663,16 @@ export default {
|
|||
cancelled: "Export cancelled.",
|
||||
rowsCount: "{exported} / {total} rows",
|
||||
rowsExported: "{count} rows exported",
|
||||
rowsShort: "rows",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
tooltip: "Export Progress",
|
||||
popoverTitle: "Exports",
|
||||
noTasks: "No exports",
|
||||
clearFinished: "Clear finished",
|
||||
showMore: "Show {count} more",
|
||||
showLess: "Show less",
|
||||
delete: "Remove",
|
||||
},
|
||||
welcome: {
|
||||
title: "Database Workspace",
|
||||
|
|
@ -1555,6 +1563,7 @@ export default {
|
|||
editorTab: "Editor",
|
||||
appearanceTab: "Appearance",
|
||||
navigationTab: "Navigation",
|
||||
dataTab: "Data",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Shortcuts",
|
||||
snippetsTab: "Snippets",
|
||||
|
|
@ -1688,6 +1697,9 @@ export default {
|
|||
redisScanPageSize: "Redis scan count",
|
||||
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
|
||||
redisScanPageSizeOption: "{count} keys",
|
||||
exportBatchSize: "Export batch size",
|
||||
exportBatchSizeDescription: "Rows fetched per batch when exporting table data (100-100000).",
|
||||
exportSection: "Export",
|
||||
shortcutExecuteSql: "Execute SQL",
|
||||
shortcutFind: "Find",
|
||||
shortcutReplace: "Replace",
|
||||
|
|
|
|||
|
|
@ -596,8 +596,16 @@ export default {
|
|||
cancelled: "Exportación cancelada.",
|
||||
rowsCount: "{exported} / {total} filas",
|
||||
rowsExported: "{count} filas exportadas",
|
||||
rowsShort: "filas",
|
||||
cancel: "Cancelar",
|
||||
close: "Cerrar",
|
||||
tooltip: "Progreso de exportación",
|
||||
popoverTitle: "Exportaciones",
|
||||
noTasks: "Sin exportaciones",
|
||||
clearFinished: "Limpiar finalizados",
|
||||
showMore: "Mostrar {count} más",
|
||||
showLess: "Mostrar menos",
|
||||
delete: "Eliminar",
|
||||
},
|
||||
welcome: {
|
||||
title: "Espacio de trabajo de base de datos",
|
||||
|
|
@ -1447,6 +1455,7 @@ export default {
|
|||
editorTab: "Editor",
|
||||
appearanceTab: "Apariencia",
|
||||
navigationTab: "Navegación",
|
||||
dataTab: "Datos",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Atajos",
|
||||
snippetsTab: "Fragmentos",
|
||||
|
|
@ -1580,6 +1589,9 @@ export default {
|
|||
redisScanPageSize: "Cantidad de escaneo Redis",
|
||||
redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.",
|
||||
redisScanPageSizeOption: "{count} claves",
|
||||
exportBatchSize: "Tamaño de lote de exportación",
|
||||
exportBatchSizeDescription: "Filas obtenidas por lote al exportar datos de tabla (100-100000).",
|
||||
exportSection: "Exportar",
|
||||
shortcutExecuteSql: "Ejecutar SQL",
|
||||
shortcutFind: "Buscar",
|
||||
shortcutReplace: "Reemplazar",
|
||||
|
|
|
|||
|
|
@ -653,8 +653,16 @@ export default {
|
|||
cancelled: "导出已取消。",
|
||||
rowsCount: "已导出 {exported} / {total} 行",
|
||||
rowsExported: "已导出 {count} 行",
|
||||
rowsShort: "行",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
tooltip: "导出进度",
|
||||
popoverTitle: "导出任务",
|
||||
noTasks: "暂无导出任务",
|
||||
clearFinished: "清除已完成",
|
||||
showMore: "展开其余 {count} 个",
|
||||
showLess: "收起",
|
||||
delete: "删除",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
|
|
@ -1528,6 +1536,7 @@ export default {
|
|||
editorTab: "编辑器",
|
||||
appearanceTab: "外观",
|
||||
navigationTab: "导航",
|
||||
dataTab: "数据",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "快捷键",
|
||||
snippetsTab: "代码片段",
|
||||
|
|
@ -1647,6 +1656,9 @@ export default {
|
|||
redisScanPageSize: "Redis 扫描数量",
|
||||
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
|
||||
redisScanPageSizeOption: "{count} 个 Key",
|
||||
exportBatchSize: "导出批次大小",
|
||||
exportBatchSizeDescription: "导出表数据时每批次获取的行数(100-100000)。",
|
||||
exportSection: "导出",
|
||||
shortcutExecuteSql: "执行 SQL",
|
||||
shortcutFind: "查找",
|
||||
shortcutReplace: "替换",
|
||||
|
|
|
|||
|
|
@ -1519,6 +1519,7 @@ export interface TableExportRequest {
|
|||
filePath: string;
|
||||
format: "csv" | "xlsx";
|
||||
columns?: string[];
|
||||
batchSize?: number;
|
||||
}
|
||||
|
||||
export interface TableCsvExportOptions {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export interface EditorSettings {
|
|||
columnFormatters: Record<string, ColumnFormatterConfig>;
|
||||
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
|
||||
snippets: SqlSnippet[];
|
||||
exportBatchSize: number;
|
||||
}
|
||||
|
||||
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
|
||||
|
|
@ -277,6 +278,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
columnFormatters: {},
|
||||
customColumnFormatters: {},
|
||||
snippets: DEFAULT_SQL_SNIPPETS,
|
||||
exportBatchSize: 2000,
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "dbx-editor-settings";
|
||||
|
|
@ -425,6 +427,12 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
|
||||
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
|
||||
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
|
||||
exportBatchSize:
|
||||
typeof settings.exportBatchSize === "number" &&
|
||||
settings.exportBatchSize >= 100 &&
|
||||
settings.exportBatchSize <= 100000
|
||||
? Math.round(settings.exportBatchSize)
|
||||
: DEFAULT_EDITOR_SETTINGS.exportBatchSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -571,6 +579,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.customColumnFormatters !== undefined)
|
||||
editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
|
||||
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
|
||||
if (partial.exportBatchSize !== undefined)
|
||||
editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
|
||||
saveEditorSettings(editorSettings.value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use crate::connection::AppState;
|
|||
use crate::csv_export::{escape_csv, format_csv, value_to_csv_text};
|
||||
use crate::database_export::is_export_cancelled;
|
||||
pub use crate::database_export::ExportStatus;
|
||||
use crate::transfer::{count_sql, execute_on_pool, pagination_sql};
|
||||
use crate::transfer::{count_sql, execute_on_pool, keyset_pagination_sql, pagination_sql};
|
||||
use crate::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData};
|
||||
|
||||
const DEFAULT_BATCH_SIZE: usize = 500;
|
||||
const DEFAULT_BATCH_SIZE: usize = 2000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -24,6 +24,8 @@ pub struct TableExportRequest {
|
|||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub columns: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -76,7 +78,10 @@ pub async fn export_table_data_core(
|
|||
|
||||
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
|
||||
|
||||
// 4. Optionally filter to requested columns
|
||||
// 4. Extract primary keys from column metadata (before column filtering)
|
||||
let primary_keys: Vec<String> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.clone()).collect();
|
||||
|
||||
// 5. Optionally filter to requested columns
|
||||
let col_names = if let Some(requested_cols) = &request.columns {
|
||||
if requested_cols.is_empty() {
|
||||
col_names
|
||||
|
|
@ -95,7 +100,19 @@ pub async fn export_table_data_core(
|
|||
return Err("No columns found for table".to_string());
|
||||
}
|
||||
|
||||
// 5. Get total row count for progress estimation
|
||||
// Use keyset pagination when all PKs are in the selected (filtered) columns.
|
||||
// This avoids the OFFSET performance penalty for large tables.
|
||||
// When no PK is available, falls back to offset-based pagination.
|
||||
let use_keyset = !primary_keys.is_empty() && primary_keys.iter().all(|pk| col_names.contains(pk));
|
||||
|
||||
// PK column indices within result rows (for extracting last-row values)
|
||||
let pk_indices: Vec<usize> = if use_keyset {
|
||||
primary_keys.iter().map(|pk| col_names.iter().position(|c| c == pk).unwrap()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 6. Get total row count for progress estimation
|
||||
let count_query = count_sql(&request.table_name, request.schema.as_deref().unwrap_or(""), &db_type);
|
||||
let total_rows = match execute_on_pool(state, &pool_key, &count_query).await {
|
||||
Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v {
|
||||
|
|
@ -106,7 +123,7 @@ pub async fn export_table_data_core(
|
|||
Err(_) => None,
|
||||
};
|
||||
|
||||
// 6. Emit initial Running progress
|
||||
// 7. Emit initial Running progress
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
|
|
@ -116,12 +133,15 @@ pub async fn export_table_data_core(
|
|||
error_message: None,
|
||||
});
|
||||
|
||||
// 7. Create output file
|
||||
// 8. Create output file
|
||||
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?;
|
||||
|
||||
let mut rows_exported: u64 = 0;
|
||||
let batch_size = request.batch_size.unwrap_or(DEFAULT_BATCH_SIZE).max(1);
|
||||
let mut offset: u64 = 0;
|
||||
let batch_size = DEFAULT_BATCH_SIZE;
|
||||
|
||||
// Track last primary key values for keyset pagination
|
||||
let mut last_pk_values: Vec<Value> = Vec::new();
|
||||
|
||||
match request.format.to_lowercase().as_str() {
|
||||
"csv" => {
|
||||
|
|
@ -144,14 +164,26 @@ pub async fn export_table_data_core(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
let sql = if use_keyset {
|
||||
keyset_pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
&primary_keys,
|
||||
&last_pk_values,
|
||||
batch_size,
|
||||
)
|
||||
} else {
|
||||
pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
)
|
||||
};
|
||||
|
||||
let result = execute_on_pool(state, &pool_key, &sql).await?;
|
||||
let row_count = result.rows.len();
|
||||
|
|
@ -173,7 +205,15 @@ pub async fn export_table_data_core(
|
|||
}
|
||||
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
|
||||
if use_keyset {
|
||||
// Keyset pagination: track last PK values for next batch
|
||||
if let Some(last_row) = result.rows.last() {
|
||||
last_pk_values = pk_indices.iter().map(|&i| last_row[i].clone()).collect();
|
||||
}
|
||||
} else {
|
||||
offset += row_count as u64;
|
||||
}
|
||||
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
|
|
@ -206,14 +246,26 @@ pub async fn export_table_data_core(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
let sql = if use_keyset {
|
||||
keyset_pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
&primary_keys,
|
||||
&last_pk_values,
|
||||
batch_size,
|
||||
)
|
||||
} else {
|
||||
pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
)
|
||||
};
|
||||
|
||||
let result = execute_on_pool(state, &pool_key, &sql).await?;
|
||||
let row_count = result.rows.len();
|
||||
|
|
@ -223,7 +275,15 @@ pub async fn export_table_data_core(
|
|||
|
||||
all_rows.extend(result.rows);
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
|
||||
if use_keyset {
|
||||
// Keyset pagination: track last PK values for next batch
|
||||
if let Some(last_row) = all_rows.last() {
|
||||
last_pk_values = pk_indices.iter().map(|&i| last_row[i].clone()).collect();
|
||||
}
|
||||
} else {
|
||||
offset += row_count as u64;
|
||||
}
|
||||
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
|
|
|
|||
|
|
@ -1253,6 +1253,87 @@ pub fn count_sql(table: &str, schema: &str, db_type: &DatabaseType) -> String {
|
|||
format!("SELECT COUNT(*) FROM {full_table}")
|
||||
}
|
||||
|
||||
pub fn keyset_pagination_sql(
|
||||
columns: &[String],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
primary_keys: &[String],
|
||||
last_pk_values: &[serde_json::Value],
|
||||
limit: usize,
|
||||
) -> String {
|
||||
let full_table = qualified_table(table, schema, db_type);
|
||||
let col_list = columns.iter().map(|c| quote_identifier(c, db_type)).collect::<Vec<_>>().join(", ");
|
||||
let order =
|
||||
primary_keys.iter().map(|pk| format!("{} ASC", quote_identifier(pk, db_type))).collect::<Vec<_>>().join(", ");
|
||||
|
||||
let where_clause = keyset_where_clause(primary_keys, last_pk_values, db_type);
|
||||
|
||||
match db_type {
|
||||
DatabaseType::SqlServer | DatabaseType::Oracle => {
|
||||
format!(
|
||||
"SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} OFFSET 0 ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
format!("SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order} LIMIT {limit}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keyset_where_clause(
|
||||
primary_keys: &[String],
|
||||
last_pk_values: &[serde_json::Value],
|
||||
db_type: &DatabaseType,
|
||||
) -> String {
|
||||
if primary_keys.is_empty() || last_pk_values.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let quoted_keys = primary_keys.iter().map(|pk| quote_identifier(pk, db_type)).collect::<Vec<_>>();
|
||||
let literals = last_pk_values.iter().map(|v| value_to_sql_literal(v, db_type)).collect::<Vec<_>>();
|
||||
let comparison_count = quoted_keys.len().min(literals.len());
|
||||
if comparison_count == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut clauses = Vec::with_capacity(comparison_count);
|
||||
for index in 0..comparison_count {
|
||||
let mut parts = Vec::with_capacity(index + 1);
|
||||
for prefix_index in 0..index {
|
||||
parts.push(format!("{} = {}", quoted_keys[prefix_index], literals[prefix_index]));
|
||||
}
|
||||
parts.push(format!("{} > {}", quoted_keys[index], literals[index]));
|
||||
if parts.len() == 1 {
|
||||
clauses.push(parts.remove(0));
|
||||
} else {
|
||||
clauses.push(format!("({})", parts.join(" AND ")));
|
||||
}
|
||||
}
|
||||
|
||||
if clauses.len() == 1 {
|
||||
format!(" WHERE {}", clauses[0])
|
||||
} else {
|
||||
format!(" WHERE ({})", clauses.join(" OR "))
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_sql_literal(value: &serde_json::Value, _db_type: &DatabaseType) -> String {
|
||||
match value {
|
||||
serde_json::Value::Null => "NULL".to_string(),
|
||||
serde_json::Value::Bool(b) => {
|
||||
if *b {
|
||||
"TRUE".to_string()
|
||||
} else {
|
||||
"FALSE".to_string()
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => quote_string_literal(s),
|
||||
_ => quote_string_literal(&value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_on_pool(state: &AppState, pool_key: &str, sql: &str) -> Result<db::QueryResult, String> {
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(pool_key).ok_or("Connection not found")?;
|
||||
|
|
@ -2746,6 +2827,42 @@ mod tests {
|
|||
assert_eq!(sql, "SELECT \"id\", \"name\" FROM \"public\".\"users\" ORDER BY \"id\" LIMIT 100 OFFSET 200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_keyset_pagination_includes_offset_fetch() {
|
||||
let sql = keyset_pagination_sql(
|
||||
&[String::from("id"), String::from("name")],
|
||||
"users",
|
||||
"dbo",
|
||||
&DatabaseType::SqlServer,
|
||||
&[String::from("id")],
|
||||
&[],
|
||||
100,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"SELECT [id], [name] FROM [dbo].[users] ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_keyset_pagination_uses_portable_lexicographic_predicate() {
|
||||
let sql = keyset_pagination_sql(
|
||||
&[String::from("tenant_id"), String::from("id"), String::from("name")],
|
||||
"users",
|
||||
"dbo",
|
||||
&DatabaseType::SqlServer,
|
||||
&[String::from("tenant_id"), String::from("id")],
|
||||
&[json!(10), json!(25)],
|
||||
100,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"SELECT [tenant_id], [id], [name] FROM [dbo].[users] WHERE ([tenant_id] > 10 OR ([tenant_id] = 10 AND [id] > 25)) ORDER BY [tenant_id] ASC, [id] ASC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_generates_index_and_foreign_key_sql() {
|
||||
let indexes = vec![db::IndexInfo {
|
||||
|
|
|
|||
Loading…
Reference in New Issue