Optimize table data open latency
This commit is contained in:
parent
bd77670770
commit
33e0cfa831
|
|
@ -221,6 +221,10 @@ const props = defineProps<{
|
|||
}) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const dataGridTraceId = uuid().slice(0, 8);
|
||||
const dataGridCreatedAt = performance.now();
|
||||
const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedAt)}ms`;
|
||||
|
||||
const emit = defineEmits<{
|
||||
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
|
||||
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
|
||||
|
|
@ -228,6 +232,48 @@ const emit = defineEmits<{
|
|||
"update:whereInput": [value: string];
|
||||
}>();
|
||||
|
||||
console.info("[DBX][DataGrid:setup]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
rowCount: props.result.rows.length,
|
||||
columnCount: props.result.columns.length,
|
||||
backendMs: props.result.execution_time_ms,
|
||||
loading: props.loading,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
(result) => {
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][DataGrid:result:prop]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
rowCount: result.rows.length,
|
||||
columnCount: result.columns.length,
|
||||
backendMs: result.execution_time_ms,
|
||||
loading: props.loading,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
nextTick(() => {
|
||||
console.info("[DBX][DataGrid:result:nextTick]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
loading: props.loading,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
console.info("[DBX][DataGrid:result:first-frame]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
loading: props.loading,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const hasData = computed(() => props.result.columns.length > 0);
|
||||
|
||||
const columnTypeMap = computed(() => {
|
||||
|
|
@ -1971,15 +2017,36 @@ const displayItems = computed<RowItem[]>(() => {
|
|||
|
||||
watch(
|
||||
() => displayItems.value.length,
|
||||
() => {
|
||||
(length) => {
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][DataGrid:display-items:ready]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
displayItemCount: length,
|
||||
sourceRowCount: props.result.rows.length,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
nextTick(() => {
|
||||
const scrollerEl = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
|
||||
if (scrollerEl) {
|
||||
updateGridScrollbarGutter(scrollerEl);
|
||||
updateGridHorizontalViewport(scrollerEl);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const renderedRows = gridRef.value?.querySelectorAll(".vue-recycle-scroller__item-view").length;
|
||||
console.info("[DBX][DataGrid:display-items:first-frame]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
displayItemCount: length,
|
||||
renderedRows,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
loading: props.loading,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
interface SearchMatch {
|
||||
|
|
@ -4158,12 +4225,27 @@ watch(
|
|||
() => props.loading,
|
||||
(isLoading) => {
|
||||
clearInterval(_loadingTimer);
|
||||
console.info(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
if (isLoading) {
|
||||
_loadingStart = Date.now();
|
||||
loadingElapsed.value = 0;
|
||||
_loadingTimer = setInterval(() => {
|
||||
loadingElapsed.value = Date.now() - _loadingStart;
|
||||
}, 100);
|
||||
} else {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
console.info("[DBX][DataGrid:loading:stop:first-frame]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -153,8 +153,8 @@ const showPinnedDataTabsMenu = computed(
|
|||
const dataTabMenuItems = computed(() =>
|
||||
dataTabs.value.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tabDisplayTitle(tab),
|
||||
title: tabDisplayTitle(tab),
|
||||
label: tabDisplayTitle(tab, t),
|
||||
title: tabDisplayTitle(tab, t),
|
||||
icon: Table2,
|
||||
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||
})),
|
||||
|
|
@ -248,7 +248,7 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
|
|
@ -270,7 +270,7 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
|
||||
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
|
||||
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
|
||||
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
|
||||
const DataGrid = defineAsyncComponent(async () => {
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][DataGrid:load:start]");
|
||||
const component = await import("@/components/grid/DataGrid.vue");
|
||||
console.info("[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
|
||||
return component;
|
||||
});
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
|
|
@ -171,6 +177,35 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeTab.result,
|
||||
(result) => {
|
||||
if (!result) return;
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][ContentArea:result:observed]", {
|
||||
tabId: props.activeTab.id,
|
||||
rowCount: result.rows.length,
|
||||
columnCount: result.columns.length,
|
||||
backendMs: result.execution_time_ms,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
nextTick(() => {
|
||||
console.info("[DBX][ContentArea:result:nextTick]", {
|
||||
tabId: props.activeTab.id,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
console.info("[DBX][ContentArea:result:first-frame]", {
|
||||
tabId: props.activeTab.id,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeTab.isExecuting,
|
||||
(isExecuting, wasExecuting) => {
|
||||
|
|
@ -499,7 +534,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate"
|
||||
>
|
||||
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template
|
||||
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
|
||||
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ import {
|
|||
setActiveTableReferencePayload,
|
||||
type QueryEditorTableReferencePayload,
|
||||
} from "@/lib/queryEditorTableDrop";
|
||||
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
import {
|
||||
supportsDatabaseCreation,
|
||||
supportsDatabaseSearch,
|
||||
|
|
@ -523,49 +523,57 @@ async function openData() {
|
|||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const querySchema = node.schema || node.database;
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
const limit = settingsStore.editorSettings.pageSize;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: columns.map((column) => column.name),
|
||||
primaryKeys: pks,
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
limit,
|
||||
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
|
||||
includeRowId: false,
|
||||
});
|
||||
console.info("[DBX][openData:sql-built]", {
|
||||
traceId,
|
||||
primaryKeys: pks,
|
||||
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
|
||||
primaryKeys: [],
|
||||
includeRowId: false,
|
||||
sql,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
|
||||
const loadTableMeta = async () => {
|
||||
try {
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
|
||||
}
|
||||
};
|
||||
|
||||
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
|
||||
void loadTableMeta();
|
||||
} catch (e: any) {
|
||||
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
export function connectionDisplayName(connectionId: string): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
return connectionStore.getConfig(connectionId)?.name || connectionId;
|
||||
|
|
@ -13,8 +14,7 @@ export function connectionColor(connectionId: string): string {
|
|||
return connectionStore.getConfig(connectionId)?.color || "";
|
||||
}
|
||||
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string): string {
|
||||
const { t } = useI18n();
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
|
||||
|
|
@ -27,8 +27,8 @@ export function isPreviewTab(tab: QueryTab): boolean {
|
|||
return !!config?.name.startsWith("[Preview]");
|
||||
}
|
||||
|
||||
export function tabDisplayTitle(tab: QueryTab): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const settingsStore = useSettingsStore();
|
||||
const compact = settingsStore.editorSettings.compactTabTitle;
|
||||
if (isPreviewTab(tab)) return tab.title;
|
||||
|
|
@ -60,10 +60,9 @@ export function tabDisplayTitle(tab: QueryTab): string {
|
|||
return tab.title;
|
||||
}
|
||||
|
||||
export function tabTooltipLines(tab: QueryTab): { label: string; value: string }[] {
|
||||
const { t } = useI18n();
|
||||
export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; value: string }[] {
|
||||
const connName = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const lines: { label: string; value: string }[] = [
|
||||
{ label: t("tabs.tooltipConnection"), value: connName },
|
||||
{ label: t("tabs.tooltipDatabase"), value: database },
|
||||
|
|
@ -80,8 +79,7 @@ export function tabTooltipLines(tab: QueryTab): { label: string; value: string }
|
|||
return lines;
|
||||
}
|
||||
|
||||
export function tabModeLabel(tab: QueryTab): string {
|
||||
const { t } = useI18n();
|
||||
export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "data") return t("tabs.table");
|
||||
if (tab.mode === "query") return t("tabs.sql");
|
||||
if (tab.mode === "mongo") return t("tabs.mongo");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { defineStore } from "pinia";
|
|||
import { uuid } from "@/lib/utils";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { DatabaseType, QueryTab } from "@/types/database";
|
||||
import type { DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import { orderPinnedFirst } from "@/lib/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
|
||||
|
|
@ -91,6 +91,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = undefined;
|
||||
if (tab.mode === "query") tab.tableMeta = undefined;
|
||||
tab.resultEvicted = options.evicted ? true : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -447,34 +448,59 @@ export const useQueryStore = defineStore("query", () => {
|
|||
await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze query metadata for result tooltips and editability.
|
||||
*/
|
||||
async function analyzeQueryMetadata(tab: QueryTab, sql: string) {
|
||||
type QueryMetadataPatch = Pick<
|
||||
QueryTab,
|
||||
"queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta"
|
||||
>;
|
||||
|
||||
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
|
||||
tab.queryAnalysis = patch.queryAnalysis;
|
||||
tab.querySourceColumns = patch.querySourceColumns;
|
||||
tab.queryEditabilityReason = patch.queryEditabilityReason;
|
||||
tab.tableMeta = patch.tableMeta;
|
||||
}
|
||||
|
||||
async function buildQueryMetadataPatch(
|
||||
tab: QueryTab,
|
||||
sql: string,
|
||||
traceId?: string,
|
||||
elapsed?: () => string,
|
||||
): Promise<QueryMetadataPatch | undefined> {
|
||||
if (tab.mode !== "query") return;
|
||||
if (!tab.result || !tab.result.columns.length) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: undefined,
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
console.info("[DBX][executeTabSql:metadata:editability:start]", { traceId, elapsed: elapsed?.() });
|
||||
const editability = await api.analyzeEditableQueryEditability(sql);
|
||||
console.info("[DBX][executeTabSql:metadata:editability:done]", {
|
||||
traceId,
|
||||
editable: editability.editable,
|
||||
reason: editability.editable ? undefined : editability.reason,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
if (!editability.editable) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = editability.reason;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: editability.reason,
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
const analysis = editability.analysis;
|
||||
|
||||
if (!tab.connectionId || !tab.database) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "metadata-unavailable";
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "metadata-unavailable",
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve schema per database type
|
||||
|
|
@ -488,10 +514,20 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
|
||||
try {
|
||||
console.info("[DBX][executeTabSql:metadata:get-columns:start]", {
|
||||
traceId,
|
||||
schema,
|
||||
table: analysis.tableName,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
|
||||
console.info("[DBX][executeTabSql:metadata:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns);
|
||||
|
||||
tab.tableMeta = {
|
||||
const tableMeta = {
|
||||
schema: schema || undefined,
|
||||
tableName: analysis.tableName,
|
||||
columns,
|
||||
|
|
@ -499,38 +535,71 @@ export const useQueryStore = defineStore("query", () => {
|
|||
};
|
||||
|
||||
if (primaryKeys.length === 0) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "no-primary-key";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "no-primary-key",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, analysis)) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "primary-key-not-returned";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "primary-key-not-returned",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allEditableColumnsWriteable(analysis, tab.result.columns)) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "aliased-columns";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "aliased-columns",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
tab.queryAnalysis = analysis;
|
||||
tab.querySourceColumns = sourceColumnsForResult(analysis, tab.result.columns);
|
||||
tab.queryEditabilityReason = undefined;
|
||||
return {
|
||||
queryAnalysis: analysis,
|
||||
querySourceColumns: sourceColumnsForResult(analysis, tab.result.columns),
|
||||
queryEditabilityReason: undefined,
|
||||
tableMeta,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[DBX] ERROR fetching columns for query metadata:", err);
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "metadata-unavailable";
|
||||
tab.tableMeta = undefined;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "metadata-unavailable",
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeQueryMetadataInBackground(
|
||||
tabId: string,
|
||||
sql: string,
|
||||
result: QueryResult,
|
||||
traceId: string,
|
||||
elapsed: () => string,
|
||||
) {
|
||||
void (async () => {
|
||||
const tab = tabs.value.find((t) => t.id === tabId);
|
||||
if (!tab || tab.result !== result) return;
|
||||
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
|
||||
const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed);
|
||||
const current = tabs.value.find((t) => t.id === tabId);
|
||||
if (patch && current?.result === result) {
|
||||
applyQueryMetadataPatch(current, patch);
|
||||
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
|
||||
} else {
|
||||
console.warn("[DBX][executeTabSql:metadata:stale]", { traceId, elapsed: elapsed() });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function executeTabSql(
|
||||
id: string,
|
||||
sql: string,
|
||||
|
|
@ -704,6 +773,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
|
||||
console.info("[DBX][executeTabSql:execute-multi:start]", { traceId, elapsed: elapsed() });
|
||||
const clientSessionId = useAgentResultSession ? tab.id : undefined;
|
||||
const executionOptions = {
|
||||
...(typeof pageLimit === "number"
|
||||
? useAgentResultSession
|
||||
|
|
@ -715,13 +785,14 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
: { maxRows: pageLimit, fetchSize: pageLimit }
|
||||
: {}),
|
||||
clientSessionId: tab.id,
|
||||
...(clientSessionId ? { clientSessionId } : {}),
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
};
|
||||
const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
|
||||
const timeoutError = new Error(t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }));
|
||||
const executionSchema = tab.mode === "data" ? undefined : tab.schema;
|
||||
const results = await Promise.race([
|
||||
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, tab.schema, executionId, executionOptions),
|
||||
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, executionSchema, executionId, executionOptions),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(timeoutError), frontendTimeoutSecs * 1000)),
|
||||
]);
|
||||
console.info("[DBX][executeTabSql:execute-multi:done]", {
|
||||
|
|
@ -749,6 +820,14 @@ export const useQueryStore = defineStore("query", () => {
|
|||
current.resultPageOffset = pageOffset;
|
||||
current.resultCountSql = countSql;
|
||||
current.resultSessionId = current.result?.session_id ?? undefined;
|
||||
console.info("[DBX][executeTabSql:result:assigned]", {
|
||||
traceId,
|
||||
activeResultIndex: current.activeResultIndex,
|
||||
rowCount: current.result?.rows.length ?? 0,
|
||||
columnCount: current.result?.columns.length ?? 0,
|
||||
backendMs: current.result?.execution_time_ms,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
if (countSql && current.result?.rows.length) {
|
||||
// When the result set is smaller than the page size we already have
|
||||
// all rows — compute the total directly instead of running COUNT(*).
|
||||
|
|
@ -778,9 +857,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
});
|
||||
}
|
||||
}
|
||||
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
|
||||
await analyzeQueryMetadata(current, queryBaseSql);
|
||||
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
|
||||
if (current.mode === "query" && current.result)
|
||||
analyzeQueryMetadataInBackground(id, queryBaseSql, current.result, traceId, elapsed);
|
||||
} else {
|
||||
console.warn("[DBX][executeTabSql:stale-result]", {
|
||||
traceId,
|
||||
|
|
@ -851,7 +929,6 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.lastExplainedSql = sql;
|
||||
try {
|
||||
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
|
||||
clientSessionId: tab.id,
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
});
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
|
|
|
|||
|
|
@ -248,16 +248,30 @@ async fn execute_select_prepared(
|
|||
start: Instant,
|
||||
row_limit: usize,
|
||||
) -> Result<QueryResult, tokio_postgres::Error> {
|
||||
let prepared_start = Instant::now();
|
||||
let stmt = client.prepare_cached(sql).await?;
|
||||
log::info!(
|
||||
"[postgres][select:prepare_cached:done] elapsed_ms={} total_ms={}",
|
||||
prepared_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
let columns: Vec<String> = stmt.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
let column_types: Vec<String> = stmt.columns().iter().map(|c| c.type_().name().to_string()).collect();
|
||||
|
||||
let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
||||
let query_start = Instant::now();
|
||||
let stream = client.query_raw(&stmt, params).await?;
|
||||
log::info!(
|
||||
"[postgres][select:query_raw:done] elapsed_ms={} total_ms={} column_count={}",
|
||||
query_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
columns.len()
|
||||
);
|
||||
tokio::pin!(stream);
|
||||
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut truncated = false;
|
||||
|
||||
let rows_start = Instant::now();
|
||||
while let Some(row_result) = stream.next().await {
|
||||
if result_rows.len() >= row_limit {
|
||||
truncated = true;
|
||||
|
|
@ -270,6 +284,13 @@ async fn execute_select_prepared(
|
|||
.collect(),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"[postgres][select:rows:done] elapsed_ms={} total_ms={} row_count={} truncated={}",
|
||||
rows_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
result_rows.len(),
|
||||
truncated
|
||||
);
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
|
|
@ -950,13 +971,40 @@ pub async fn execute_query_with_schema_and_max_rows(
|
|||
sql: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let checkout_start = Instant::now();
|
||||
let client = pool.get().await.map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:pool:done] elapsed_ms={} total_ms={} schema={}",
|
||||
checkout_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
schema
|
||||
);
|
||||
let set_schema_start = Instant::now();
|
||||
client.execute(&format!("SET search_path TO {}", pg_quote_ident(schema)), &[]).await.map_err(pg_error_to_string)?;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:set-search-path:done] elapsed_ms={} total_ms={}",
|
||||
set_schema_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let query_start = Instant::now();
|
||||
let result = execute_query_with_max_rows_inner(&client, sql, max_rows).await;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:query:done] elapsed_ms={} total_ms={} ok={}",
|
||||
query_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
result.is_ok()
|
||||
);
|
||||
|
||||
// Always reset search_path so the connection is clean when returned to the pool
|
||||
let reset_start = Instant::now();
|
||||
let _ = client.execute("RESET search_path", &[]).await;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:reset-search-path:done] elapsed_ms={} total_ms={}",
|
||||
reset_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,10 +201,10 @@ test("starting a new query clears the previous result payload immediately", asyn
|
|||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
return new Response(
|
||||
JSON.stringify([{ columns: ["new"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
return new Response(JSON.stringify([{ columns: ["new"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
|
|
@ -227,6 +227,107 @@ test("starting a new query clears the previous result payload immediately", asyn
|
|||
}
|
||||
});
|
||||
|
||||
test("query execution finishes without waiting for metadata analysis", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db", "Query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
|
||||
let resolveMetadata: ((value: Response) => void) | undefined;
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(JSON.stringify({ sqlToExecute: "select id from users", useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveMetadata = resolve;
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await store.executeTabSql(tabId, "select id from users");
|
||||
|
||||
assert.equal(tab.isExecuting, false);
|
||||
assert.equal(tab.executionId, undefined);
|
||||
assert.deepEqual(tab.result?.columns, ["id"]);
|
||||
|
||||
resolveMetadata?.(
|
||||
new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("normal query execution does not create a tab-scoped client session", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db", "Query");
|
||||
let executeBody: any;
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
executeBody = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await store.executeTabSql(tabId, "select 1");
|
||||
|
||||
assert.equal(executeBody.clientSessionId, undefined);
|
||||
assert.equal(executeBody.timeoutSecs, 30);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab reuse is scoped by mode and schema instead of title alone", () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
|
|
@ -63,6 +64,7 @@ pub async fn execute_multi(
|
|||
execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone()));
|
||||
let cancel_token = registered_query.as_ref().map(|query| query.token());
|
||||
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id");
|
||||
let started_at = Instant::now();
|
||||
log::info!(
|
||||
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?} sql={}",
|
||||
trace_id,
|
||||
|
|
@ -91,12 +93,19 @@ pub async fn execute_multi(
|
|||
.await;
|
||||
match &result {
|
||||
Ok(results) => log::info!(
|
||||
"[query][execute_multi:done] trace_id={} result_count={} row_counts={:?}",
|
||||
"[query][execute_multi:done] trace_id={} elapsed_ms={} result_count={} row_counts={:?} backend_execution_times_ms={:?}",
|
||||
trace_id,
|
||||
started_at.elapsed().as_millis(),
|
||||
results.len(),
|
||||
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>()
|
||||
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>(),
|
||||
results.iter().map(|result| result.execution_time_ms).collect::<Vec<_>>()
|
||||
),
|
||||
Err(error) => log::error!(
|
||||
"[query][execute_multi:error] trace_id={} elapsed_ms={} error={}",
|
||||
trace_id,
|
||||
started_at.elapsed().as_millis(),
|
||||
error
|
||||
),
|
||||
Err(error) => log::error!("[query][execute_multi:error] trace_id={} error={}", trace_id, error),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue