perf: 优化SQL工作台大结果渲染
- 提供一百到一万行返回档位 - 使用虚拟表格降低大结果集页面开销
This commit is contained in:
@@ -117,7 +117,6 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -22,11 +22,32 @@ vi.mock('#/api/dataspace', () => ({
|
||||
queryDataspace: apiMocks.query,
|
||||
}));
|
||||
|
||||
vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({}));
|
||||
|
||||
const mountWorkbench = () =>
|
||||
shallowMount(SqlWorkbenchPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElOption: {
|
||||
name: 'ElOption',
|
||||
props: ['label', 'value'],
|
||||
template: '<div></div>',
|
||||
},
|
||||
ElSelect: {
|
||||
name: 'ElSelect',
|
||||
props: ['modelValue'],
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
ElTooltip: { template: '<div><slot /></div>' },
|
||||
ElTableV2: {
|
||||
name: 'ElTableV2',
|
||||
props: ['columns', 'data', 'rowKey'],
|
||||
template: '<div class="table-v2-test-stub"></div>',
|
||||
},
|
||||
ElAutoResizer: {
|
||||
name: 'ElAutoResizer',
|
||||
template: '<div><slot :height="320" :width="960" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -190,6 +211,112 @@ describe('sql workbench panel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers row limits through 10000 and sends the selected value', async () => {
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => [index + 1]);
|
||||
apiMocks.query.mockResolvedValueOnce({
|
||||
columns: [
|
||||
{ jdbcType: -5, name: 'id', nullable: false, typeName: 'BIGINT' },
|
||||
],
|
||||
metrics: {
|
||||
databaseMillis: 20,
|
||||
firstRowMillis: 21,
|
||||
intermediateRows: 10_000,
|
||||
localMillis: 0,
|
||||
planCacheHit: true,
|
||||
planningMillis: 2,
|
||||
queryMode: 'SINGLE_SOURCE',
|
||||
returnedRows: 10_000,
|
||||
totalMillis: 30,
|
||||
truncated: false,
|
||||
},
|
||||
queryId: 'query-10000',
|
||||
rows,
|
||||
});
|
||||
const wrapper = mountWorkbench();
|
||||
await flushPromises();
|
||||
|
||||
const rowLimit = wrapper
|
||||
.findAllComponents({ name: 'ElSelect' })
|
||||
.find((component) => component.classes().includes('row-limit'));
|
||||
expect(rowLimit?.props('modelValue')).toBe(500);
|
||||
const labels = wrapper
|
||||
.findAllComponents({ name: 'ElOption' })
|
||||
.map((option) => option.props('label'))
|
||||
.filter((label) => String(label).startsWith('最多'));
|
||||
expect(labels).toEqual([
|
||||
'最多 100 行',
|
||||
'最多 500 行',
|
||||
'最多 1000 行',
|
||||
'最多 2000 行',
|
||||
'最多 5000 行',
|
||||
'最多 10000 行',
|
||||
]);
|
||||
|
||||
rowLimit?.vm.$emit('update:modelValue', 10_000);
|
||||
wrapper
|
||||
.getComponent({ name: 'CodeEditor' })
|
||||
.vm.$emit('update:modelValue', 'SELECT id FROM outlet');
|
||||
await flushPromises();
|
||||
await wrapper.get('[aria-label="执行 SQL"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxRows: 10_000 }),
|
||||
);
|
||||
const virtualTable = wrapper.getComponent({ name: 'ElTableV2' });
|
||||
expect(virtualTable.props('data')).toHaveLength(10_000);
|
||||
expect(virtualTable.props('columns')).toHaveLength(2);
|
||||
expect(virtualTable.props('rowKey')).toBe('rowKey');
|
||||
});
|
||||
|
||||
it('exports every returned row independently of virtual rendering', async () => {
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => [index + 1]);
|
||||
apiMocks.query.mockResolvedValueOnce({
|
||||
columns: [
|
||||
{ jdbcType: -5, name: 'id', nullable: false, typeName: 'BIGINT' },
|
||||
],
|
||||
metrics: {
|
||||
databaseMillis: 20,
|
||||
firstRowMillis: 21,
|
||||
intermediateRows: 10_000,
|
||||
localMillis: 0,
|
||||
planCacheHit: true,
|
||||
planningMillis: 2,
|
||||
queryMode: 'SINGLE_SOURCE',
|
||||
returnedRows: 10_000,
|
||||
totalMillis: 30,
|
||||
truncated: false,
|
||||
},
|
||||
queryId: 'query-export',
|
||||
rows,
|
||||
});
|
||||
const createObjectUrl = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:query-export');
|
||||
const revokeObjectUrl = vi
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => undefined);
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
const wrapper = mountWorkbench();
|
||||
await flushPromises();
|
||||
|
||||
wrapper
|
||||
.getComponent({ name: 'CodeEditor' })
|
||||
.vm.$emit('update:modelValue', 'SELECT id FROM outlet');
|
||||
await flushPromises();
|
||||
await wrapper.get('[aria-label="执行 SQL"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('[aria-label="导出 CSV"]').trigger('click');
|
||||
|
||||
const blob = createObjectUrl.mock.calls[0]?.[0];
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect((blob as Blob).size).toBeGreaterThan(48_000);
|
||||
expect(anchorClick).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:query-export');
|
||||
});
|
||||
|
||||
it('keeps result metrics and an icon-only export action beside the tabs', async () => {
|
||||
const wrapper = mountWorkbench();
|
||||
await flushPromises();
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
@@ -48,10 +49,12 @@ import {
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
import {
|
||||
ElAutoResizer as TableV2AutoResizer,
|
||||
ElTableV2,
|
||||
} from 'element-plus/es/components/table-v2/index.mjs';
|
||||
|
||||
import {
|
||||
cancelDataspaceQuery,
|
||||
@@ -65,6 +68,13 @@ import {
|
||||
import { dataspaceErrorMessage } from '../dataspace-errors';
|
||||
import { formatCalciteSql } from '../dataspace-sql';
|
||||
|
||||
import 'element-plus/es/components/table-v2/style/css.mjs';
|
||||
|
||||
interface VirtualResultRow {
|
||||
cells: unknown[];
|
||||
rowKey: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
initialDataspaceId?: DataspaceId;
|
||||
}>();
|
||||
@@ -96,11 +106,51 @@ let resizeStartHeight = 0;
|
||||
const SPLITTER_SIZE = 8;
|
||||
const MIN_EDITOR_HEIGHT = 184;
|
||||
const MIN_OUTPUT_HEIGHT = 176;
|
||||
const MAX_ROW_OPTIONS = [100, 500, 1000, 2000, 5000, 10_000];
|
||||
const RESULT_HEADER_HEIGHT = 40;
|
||||
const RESULT_ROW_HEIGHT = 40;
|
||||
|
||||
const hasOutput = computed(() =>
|
||||
Boolean(result.value || explainResult.value || errorMessage.value),
|
||||
);
|
||||
|
||||
const virtualResultRows = computed<VirtualResultRow[]>(() =>
|
||||
(result.value?.rows || []).map((cells, rowKey) => ({ cells, rowKey })),
|
||||
);
|
||||
|
||||
const virtualResultColumns = computed(() => [
|
||||
{
|
||||
align: 'right' as const,
|
||||
cellRenderer: ({ rowIndex }: { rowIndex: number }) =>
|
||||
h('span', { class: 'result-index-cell' }, String(rowIndex + 1)),
|
||||
dataKey: 'rowKey',
|
||||
key: 'rowKey',
|
||||
title: '#',
|
||||
width: 56,
|
||||
},
|
||||
...(result.value?.columns || []).map((column, index) => ({
|
||||
cellRenderer: ({ rowData }: { rowData: VirtualResultRow }) => {
|
||||
const value = rowData.cells[index];
|
||||
const text =
|
||||
value === null || value === undefined ? 'NULL' : String(value);
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: ['result-cell-value', { 'is-null': value == null }],
|
||||
title: text,
|
||||
},
|
||||
text,
|
||||
);
|
||||
},
|
||||
dataKey: `column-${index}`,
|
||||
flexGrow: 1,
|
||||
key: `${column.name}-${index}`,
|
||||
minWidth: 160,
|
||||
title: column.name,
|
||||
width: 160,
|
||||
})),
|
||||
]);
|
||||
|
||||
const filteredTables = computed(() => {
|
||||
const normalized = keyword.value.trim().toLowerCase();
|
||||
if (!normalized) return detail.value?.tables || [];
|
||||
@@ -462,6 +512,11 @@ function queryRequest(queryId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 为虚拟结果表提供稳定的隔行底色。 */
|
||||
function resultRowClass({ rowIndex }: { rowIndex: number }) {
|
||||
return rowIndex % 2 === 1 ? 'is-striped' : '';
|
||||
}
|
||||
|
||||
/** 执行 Calcite SQL,并展示数据与常用消耗指标。 */
|
||||
async function execute() {
|
||||
const epoch = ++requestEpoch;
|
||||
@@ -711,9 +766,9 @@ defineExpose({ loadSpaces });
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElSelect v-model="maxRows" class="row-limit">
|
||||
<ElSelect v-model="maxRows" class="row-limit" aria-label="最大返回行数">
|
||||
<ElOption
|
||||
v-for="size in [100, 500, 1000, 5000]"
|
||||
v-for="size in MAX_ROW_OPTIONS"
|
||||
:key="size"
|
||||
:label="`最多 ${size} 行`"
|
||||
:value="size"
|
||||
@@ -876,19 +931,32 @@ defineExpose({ loadSpaces });
|
||||
|
||||
<template v-else-if="result">
|
||||
<div v-if="resultTab === 'result'" class="result-panel">
|
||||
<ElTable :data="result.rows" height="100%" stripe>
|
||||
<ElTableColumn type="index" width="56" />
|
||||
<ElTableColumn
|
||||
v-for="(column, index) in result.columns"
|
||||
:key="`${column.name}-${index}`"
|
||||
:label="column.name"
|
||||
min-width="160"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row[index] ?? 'NULL' }}
|
||||
<ElEmpty
|
||||
v-if="virtualResultRows.length === 0"
|
||||
description="查询结果为空"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="virtual-result-table"
|
||||
role="region"
|
||||
aria-label="SQL 查询结果"
|
||||
>
|
||||
<TableV2AutoResizer>
|
||||
<template #default="{ height, width }">
|
||||
<ElTableV2
|
||||
:columns="virtualResultColumns"
|
||||
:data="virtualResultRows"
|
||||
:header-height="RESULT_HEADER_HEIGHT"
|
||||
:height="height"
|
||||
:row-class="resultRowClass"
|
||||
:row-height="RESULT_ROW_HEIGHT"
|
||||
:width="width"
|
||||
fixed
|
||||
row-key="rowKey"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</TableV2AutoResizer>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="metrics-panel">
|
||||
<div
|
||||
@@ -1577,6 +1645,7 @@ defineExpose({ loadSpaces });
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
@@ -1616,12 +1685,52 @@ defineExpose({ loadSpaces });
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table) {
|
||||
font-size: 12px;
|
||||
.virtual-result-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table__cell) {
|
||||
padding: var(--space-2) 0;
|
||||
.result-panel :deep(.el-table-v2) {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table-v2__header-cell),
|
||||
.result-panel :deep(.el-table-v2__row-cell) {
|
||||
padding: 0 var(--space-2);
|
||||
border-right: 1px solid hsl(var(--divider-faint));
|
||||
border-bottom: 1px solid hsl(var(--divider-faint));
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table-v2__header-cell) {
|
||||
color: hsl(var(--text-strong));
|
||||
font-weight: 600;
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table-v2__row.is-striped) {
|
||||
background: hsl(var(--surface-contrast-soft) / 48%);
|
||||
}
|
||||
|
||||
.result-panel :deep(.el-table-v2__row:hover) {
|
||||
background: hsl(var(--table-row-hover));
|
||||
}
|
||||
|
||||
.result-panel :deep(.result-cell-value),
|
||||
.result-panel :deep(.result-index-cell) {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-panel :deep(.result-index-cell),
|
||||
.result-panel :deep(.result-cell-value.is-null) {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.metrics-panel {
|
||||
|
||||
Reference in New Issue
Block a user