feat(sidebar): 表新建查询展开全部字段而非 select *

- 新建查询时通过 DBGetColumns 拉取字段并生成 SELECT 列表
- 元数据不可用时回退 SELECT *,消息队列仍保持 LIMIT 预览
- 覆盖 V2/旧版右键菜单与表总览入口,补充模板单测

Refs #710
This commit is contained in:
Syngnat
2026-07-25 09:25:04 +08:00
parent 365c72ad22
commit fdfdebf24e
5 changed files with 248 additions and 47 deletions

View File

@@ -12,7 +12,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { resolveTableSelectQuery } from '../utils/objectQueryTemplates';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
@@ -522,15 +522,23 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openQueryForTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: t('table_overview.menu.new_query'),
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, tableName),
});
void (async () => {
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
const queryTemplate = await resolveTableSelectQuery({
dbType: metadataDialect,
tableName,
dbName: String(tab.dbName || ''),
connectionConfig: connection.config,
});
addTab({
id: `query-${Date.now()}`,
title: t('table_overview.menu.new_query'),
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: queryTemplate,
});
})();
}, [addTab, connection, metadataDialect, setActiveContext, t, tab.dbName]);
const openTableInER = useCallback((tableName: string) => {

View File

@@ -31,7 +31,7 @@ import { t } from '../../i18n';
import { useStore } from '../../store';
import type { SavedConnection, SavedQuery, SavedQueryGroup } from '../../types';
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
import { buildTableSelectQuery } from '../../utils/objectQueryTemplates';
import { resolveTableSelectQuery } from '../../utils/objectQueryTemplates';
import {
buildSavedQueryGroupPath,
getSavedQueryGroupOwnerIds,
@@ -866,14 +866,23 @@ export const buildSidebarLegacyNodeMenuItems = (
label: t('sidebar.menu.new_query'),
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || ''))
});
void (async () => {
const tableName = String(node.dataRef?.tableName || node.dataRef?.viewName || '');
const queryTemplate = await resolveTableSelectQuery({
dbType: 'starrocks',
tableName,
dbName: String(node.dataRef?.dbName || ''),
connectionConfig: node.dataRef?.config,
});
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate,
});
})();
}
},
];
@@ -964,16 +973,23 @@ export const buildSidebarLegacyNodeMenuItems = (
label: t('sidebar.menu.new_query'),
icon: <ConsoleSqlOutlined />,
onClick: () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate
});
void (async () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = await resolveTableSelectQuery({
dbType: getMetadataDialect(node.dataRef as SavedConnection),
tableName,
dbName: String(node.dataRef?.dbName || ''),
connectionConfig: node.dataRef?.config,
});
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate,
});
})();
}
},
...(messagePublishTarget ? [{

View File

@@ -8,7 +8,7 @@ import type { ConnectionTag, SavedConnection } from '../../types';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../utils/connectionVisual';
import { normalizeConnectionEnvironmentType } from '../../utils/connectionEnvironment';
import { buildTableSelectQuery } from '../../utils/objectQueryTemplates';
import { resolveTableSelectQuery } from '../../utils/objectQueryTemplates';
import { DBReleaseConnection } from '../../../wailsjs/go/app/App';
import { getDbIcon } from '../DatabaseIcons';
import { getMetadataDialect } from './sidebarMetadataLoaders';
@@ -172,16 +172,24 @@ export const useSidebarV2ActionHandlers = ({
openDesign(node, 'columns', false);
return;
case 'new-query': {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate,
});
void (async () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const dbType = getMetadataDialect(node.dataRef as SavedConnection);
const queryTemplate = await resolveTableSelectQuery({
dbType,
tableName,
dbName: String(node.dataRef?.dbName || ''),
connectionConfig: node.dataRef?.config,
});
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate,
});
})();
return;
}
case 'publish-message':

View File

@@ -1,12 +1,36 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTableSelectQuery } from './objectQueryTemplates';
const { dbGetColumnsMock } = vi.hoisted(() => ({
dbGetColumnsMock: vi.fn(),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
DBGetColumns: dbGetColumnsMock,
}));
import {
buildTableSelectQuery,
extractTableSelectColumnNames,
resolveTableSelectQuery,
} from './objectQueryTemplates';
describe('buildTableSelectQuery', () => {
it('quotes uppercase postgres table names in new query templates', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
});
it('expands provided columns into a multi-line select list', () => {
expect(buildTableSelectQuery('mysql', 'users', ['id', 'name', 'created_at'])).toBe(
'SELECT\n `id`,\n `name`,\n `created_at`\nFROM `users`;',
);
});
it('quotes reserved and uppercase column names for postgres', () => {
expect(buildTableSelectQuery('postgres', 'public.orders', ['user', 'OrderID'])).toBe(
'SELECT\n "user",\n "OrderID"\nFROM public.orders;',
);
});
it('adds a preview limit for RocketMQ topic browsing', () => {
expect(buildTableSelectQuery('rocketmq', 'orders.events')).toBe('SELECT * FROM "orders.events" LIMIT 100;');
});
@@ -23,3 +47,65 @@ describe('buildTableSelectQuery', () => {
expect(buildTableSelectQuery('rabbitmq', 'orders.events.v1')).toBe('SELECT * FROM "orders.events.v1" LIMIT 100;');
});
});
describe('extractTableSelectColumnNames', () => {
it('reads mixed-case column name fields and keeps order without duplicates', () => {
expect(extractTableSelectColumnNames([
{ Name: 'id' },
{ name: 'name' },
{ COLUMN_NAME: 'name' },
{ field: 'created_at' },
{},
])).toEqual(['id', 'name', 'created_at']);
});
});
describe('resolveTableSelectQuery', () => {
beforeEach(() => {
dbGetColumnsMock.mockReset();
});
it('loads columns and expands them into the select list', async () => {
dbGetColumnsMock.mockResolvedValueOnce({
success: true,
data: [
{ name: 'id' },
{ name: 'email' },
],
});
await expect(resolveTableSelectQuery({
dbType: 'mysql',
tableName: 'users',
dbName: 'app',
connectionConfig: { type: 'mysql', host: 'localhost' },
})).resolves.toBe('SELECT\n `id`,\n `email`\nFROM `users`;');
expect(dbGetColumnsMock).toHaveBeenCalledTimes(1);
});
it('falls back to select star when column metadata is unavailable', async () => {
dbGetColumnsMock.mockResolvedValueOnce({
success: false,
message: 'unavailable',
data: null,
});
await expect(resolveTableSelectQuery({
dbType: 'postgres',
tableName: 'public.users',
dbName: 'app',
connectionConfig: { type: 'postgres', host: 'localhost' },
})).resolves.toBe('SELECT * FROM public.users;');
});
it('keeps message-queue templates on select star without loading columns', async () => {
await expect(resolveTableSelectQuery({
dbType: 'kafka',
tableName: 'logs.app-1',
connectionConfig: { type: 'kafka' },
})).resolves.toBe('SELECT * FROM "logs.app-1" LIMIT 100;');
expect(dbGetColumnsMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,95 @@
import { quoteQualifiedIdent } from './sql';
import { DBGetColumns } from '../../wailsjs/go/app/App';
import { getColumnDefinitionName } from './columnDefinition';
import { buildRpcConnectionConfig } from './connectionRpcConfig';
import { quoteIdentPart, quoteQualifiedIdent } from './sql';
export const buildTableSelectQuery = (dbType: string, tableName: string): string => {
const MESSAGE_QUEUE_DB_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const isMessageQueueDbType = (dbType: string): boolean => (
MESSAGE_QUEUE_DB_TYPES.has(String(dbType || '').trim().toLowerCase())
);
export const extractTableSelectColumnNames = (columns: unknown): string[] => {
if (!Array.isArray(columns)) return [];
const names: string[] = [];
const seen = new Set<string>();
for (const column of columns) {
const name = getColumnDefinitionName(column);
if (!name || seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
};
export const buildTableSelectQuery = (
dbType: string,
tableName: string,
columns: string[] = [],
): string => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) {
return 'SELECT * FROM ';
}
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)} LIMIT 100;`;
const quotedTable = quoteQualifiedIdent(dbType, normalizedTableName);
const limitSuffix = isMessageQueueDbType(dbType) ? ' LIMIT 100' : '';
const normalizedColumns = columns
.map((column) => String(column || '').trim())
.filter(Boolean);
if (normalizedColumns.length === 0) {
return `SELECT * FROM ${quotedTable}${limitSuffix};`;
}
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
const selectList = normalizedColumns
.map((column) => quoteIdentPart(dbType, column))
.join(',\n ');
return `SELECT\n ${selectList}\nFROM ${quotedTable}${limitSuffix};`;
};
type ResolveTableSelectQueryOptions = {
dbType: string;
tableName: string;
dbName?: string;
connectionConfig?: unknown;
};
/**
* Build a SELECT template for "new query" from a table/view.
* Prefers expanding all column names; falls back to SELECT * when metadata is unavailable.
*/
export const resolveTableSelectQuery = async ({
dbType,
tableName,
dbName = '',
connectionConfig,
}: ResolveTableSelectQueryOptions): Promise<string> => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) {
return buildTableSelectQuery(dbType, normalizedTableName);
}
// Message-queue "tables" are topics/queues; column expansion is not meaningful.
if (isMessageQueueDbType(dbType) || !connectionConfig) {
return buildTableSelectQuery(dbType, normalizedTableName);
}
try {
const res = await DBGetColumns(
buildRpcConnectionConfig(connectionConfig as any) as any,
String(dbName || ''),
normalizedTableName,
);
if (res?.success && Array.isArray(res.data)) {
const columnNames = extractTableSelectColumnNames(res.data);
if (columnNames.length > 0) {
return buildTableSelectQuery(dbType, normalizedTableName, columnNames);
}
}
} catch {
// Fall back to SELECT * when metadata lookup fails.
}
return buildTableSelectQuery(dbType, normalizedTableName);
};