fix(desktop): open streaming sql executor for oversized sql files
Closes #8100
This commit is contained in:
4 files changed
+139
-2
No files matched your search
@@ -25,6 +25,7 @@ import { useAppUpdater } from "@/composables/useAppUpdater";
|
|||||||
import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge";
|
import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge";
|
||||||
import { useExportTracker } from "@/composables/useExportTracker";
|
import { useExportTracker } from "@/composables/useExportTracker";
|
||||||
import { useFileDrop } from "@/composables/useFileDrop";
|
import { useFileDrop } from "@/composables/useFileDrop";
|
||||||
|
import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback";
|
||||||
import { usePanelResize } from "@/composables/usePanelResize";
|
import { usePanelResize } from "@/composables/usePanelResize";
|
||||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||||
import { useSqlExecution } from "@/composables/useSqlExecution";
|
import { useSqlExecution } from "@/composables/useSqlExecution";
|
||||||
@@ -220,6 +221,7 @@ const {
|
|||||||
getActiveTaskCount: () => trackedUpdateTaskCount.value,
|
getActiveTaskCount: () => trackedUpdateTaskCount.value,
|
||||||
});
|
});
|
||||||
const { setupFileDrop } = useFileDrop();
|
const { setupFileDrop } = useFileDrop();
|
||||||
|
const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback();
|
||||||
|
|
||||||
const isDesktop = isTauriRuntime();
|
const isDesktop = isTauriRuntime();
|
||||||
const windowContext = resolveWindowContext();
|
const windowContext = resolveWindowContext();
|
||||||
@@ -1970,6 +1972,7 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
|
|||||||
async function openSqlFile() {
|
async function openSqlFile() {
|
||||||
const tab = activeTab.value;
|
const tab = activeTab.value;
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
|
let openedSqlPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (isTauriRuntime()) {
|
if (isTauriRuntime()) {
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
@@ -1979,6 +1982,7 @@ async function openSqlFile() {
|
|||||||
});
|
});
|
||||||
if (path) {
|
if (path) {
|
||||||
const sqlPath = path as string;
|
const sqlPath = path as string;
|
||||||
|
openedSqlPath = sqlPath;
|
||||||
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath);
|
const snapshot = await api.readExternalSqlFileSnapshot(sqlPath);
|
||||||
queryStore.updateSql(tab.id, snapshot.content);
|
queryStore.updateSql(tab.id, snapshot.content);
|
||||||
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version);
|
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version);
|
||||||
@@ -2000,7 +2004,9 @@ async function openSqlFile() {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
if (!openInStreamingExecutorOnTooLarge(openedSqlPath, e)) {
|
||||||
|
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { createApp, defineComponent, h, type App } from "vue";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
toast: vi.fn(),
|
||||||
|
store: {
|
||||||
|
connections: [] as { id: string }[],
|
||||||
|
sqlFileSource: null as { connectionId: string; database: string; filePath?: string } | null,
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => mocks.store }));
|
||||||
|
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||||
|
|
||||||
|
import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback";
|
||||||
|
|
||||||
|
const mountedApps: App[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.store.connections = [];
|
||||||
|
mocks.store.sqlFileSource = null;
|
||||||
|
mocks.store.getConfig.mockReset().mockReturnValue(undefined);
|
||||||
|
mocks.toast.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of mountedApps.splice(0)) app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
function withComposable<T>(run: (fallback: ReturnType<typeof useLargeSqlFileStreamingFallback>) => T): T {
|
||||||
|
let result!: T;
|
||||||
|
const component = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const fallback = useLargeSqlFileStreamingFallback();
|
||||||
|
result = run(fallback);
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The composable reads i18n/store in setup, so mount a host component that
|
||||||
|
// invokes it and hands the result back to the test.
|
||||||
|
const app = createApp(component);
|
||||||
|
app.use(i18n);
|
||||||
|
mountedApps.push(app);
|
||||||
|
app.mount(document.createElement("div"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useLargeSqlFileStreamingFallback", () => {
|
||||||
|
it("routes oversized sql files to the streaming executor dialog", () => {
|
||||||
|
const outcome = withComposable((fallback) => {
|
||||||
|
const handled = fallback.openInStreamingExecutorOnTooLarge("/tmp/dbx_export.sql", new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024));
|
||||||
|
return { handled, error: null as unknown };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome.handled).toBe(true);
|
||||||
|
expect(mocks.store.sqlFileSource).toEqual({
|
||||||
|
connectionId: "",
|
||||||
|
database: "",
|
||||||
|
filePath: "/tmp/dbx_export.sql",
|
||||||
|
});
|
||||||
|
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(mocks.toast.mock.calls[0][0])).toContain("100.0 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unrelated open failures as errors", () => {
|
||||||
|
const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge("/tmp/dbx_export.sql", new Error("disk failure")));
|
||||||
|
|
||||||
|
expect(outcome).toBe(false);
|
||||||
|
expect(mocks.store.sqlFileSource).toBeNull();
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores oversized non-sql paths", () => {
|
||||||
|
const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge("/tmp/notes.txt", new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024)));
|
||||||
|
|
||||||
|
expect(outcome).toBe(false);
|
||||||
|
expect(mocks.store.sqlFileSource).toBeNull();
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores missing paths", () => {
|
||||||
|
const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge(undefined, new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024)));
|
||||||
|
|
||||||
|
expect(outcome).toBe(false);
|
||||||
|
expect(mocks.store.sqlFileSource).toBeNull();
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
|||||||
import { useConnectionStore } from "@/stores/connectionStore";
|
import { useConnectionStore } from "@/stores/connectionStore";
|
||||||
import { useQueryStore } from "@/stores/queryStore";
|
import { useQueryStore } from "@/stores/queryStore";
|
||||||
import { useToast } from "@/composables/useToast";
|
import { useToast } from "@/composables/useToast";
|
||||||
|
import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback";
|
||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database";
|
import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database";
|
||||||
import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection";
|
import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection";
|
||||||
@@ -23,6 +24,7 @@ export function useFileDrop() {
|
|||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const queryStore = useQueryStore();
|
const queryStore = useQueryStore();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback();
|
||||||
|
|
||||||
async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) {
|
async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) {
|
||||||
if (path) {
|
if (path) {
|
||||||
@@ -88,7 +90,9 @@ export function useFileDrop() {
|
|||||||
const snapshot = await api.readExternalSqlFileSnapshot(path);
|
const snapshot = await api.readExternalSqlFileSnapshot(path);
|
||||||
await openDroppedSqlFile(name, snapshot.content, path, snapshot.version);
|
await openDroppedSqlFile(name, snapshot.content, path, snapshot.version);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
if (!openInStreamingExecutorOnTooLarge(path, e)) {
|
||||||
|
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useConnectionStore } from "@/stores/connectionStore";
|
||||||
|
import { useToast } from "@/composables/useToast";
|
||||||
|
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||||
|
import { formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor-oversized external SQL files are not a dead end: route them to the
|
||||||
|
* streaming Execute SQL File dialog, mirroring SqlFilePanel's fallback for
|
||||||
|
* files the editor cannot load.
|
||||||
|
*/
|
||||||
|
export function useLargeSqlFileStreamingFallback() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
function openInStreamingExecutor(path: string, sizeBytes: number) {
|
||||||
|
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||||
|
connectionStore.sqlFileSource = {
|
||||||
|
connectionId: target.connectionId,
|
||||||
|
database: target.database,
|
||||||
|
filePath: path,
|
||||||
|
};
|
||||||
|
toast(t("sqlFile.largeFileExecutionOpened", { size: formatSqlFileSize(sizeBytes) }), 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInStreamingExecutorOnTooLarge(path: string | undefined, error: unknown): boolean {
|
||||||
|
if (!path || !isExternalSqlFileTooLargeError(error) || !isSqlFilePath(path)) return false;
|
||||||
|
openInStreamingExecutor(path, error.sizeBytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openInStreamingExecutor, openInStreamingExecutorOnTooLarge };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user