feat(query): 新建查询继承当前表上下文 (#874)

## 变更说明

- 当前活动标签为表数据页或表设计页时,新建查询会自动继承连接、数据库和表名上下文
- 按数据库方言正确引用表名,并为 Elasticsearch 生成原生查询模板
- 只有活动表与用户最新选择的连接、数据库一致时,才继承表名,避免跨数据库误带旧表上下文
- 无有效表上下文时保持原有新建查询行为
- 自定义模板仅在以 FROM 结尾、存在明确表名插入点时自动补全,避免破坏用户模板

## 验证

- `npm test -- src/components/QueryEditor.external-sql-save.test.tsx
src/utils/newQueryContext.test.ts
src/utils/objectQueryTemplates.test.ts`:344 项测试通过
- `npm run build`:TypeScript 与 Vite 生产构建通过
- 已基于最新 `dev`(包含 #872/#870/#875)解决 `App.tsx` 冲突

Closes #849
This commit is contained in:
virgosgod-maker
2026-08-07 22:03:18 +08:00
committed by GitHub
5 changed files with 115 additions and 4 deletions

View File

@@ -102,6 +102,8 @@ import {
import { downloadBrowserTextFile } from './utils/browserFileTransfer';
import { buildDataSyncWorkbenchTab } from './utils/dataSyncTab';
import { buildSqlAuditWorkbenchTab } from './utils/sqlAuditTab';
import { resolveDataSourceType } from './utils/dataSourceCapabilities';
import { buildContextualNewQueryTemplate } from './utils/objectQueryTemplates';
import {
extractCustomThemeAntTokens,
} from './utils/customTheme';
@@ -236,7 +238,7 @@ import {
import { useAppUpdateManager } from './hooks/useAppUpdateManager';
import { useAppLogPanelResize } from './hooks/useAppLogPanelResize';
import { useAppSidebarResize } from './hooks/useAppSidebarResize';
import { resolveNewQueryContext } from './utils/newQueryContext';
import { canInheritNewQueryTableContext, resolveNewQueryContext } from './utils/newQueryContext';
import { useAppUtilityStyles } from './hooks/useAppUtilityStyles';
import { useWorkbenchTabs } from './hooks/useWorkbenchTabs';
import {
@@ -2728,6 +2730,19 @@ function App() {
activeTab: currentTab,
validConnectionIds: new Set(connections.map(connection => connection.id)),
});
const connection = connections.find(c => c.id === targetContext.connectionId);
const inheritsTableContext = canInheritNewQueryTableContext({
activeTab: currentTab,
targetContext,
});
const tableName = inheritsTableContext ? String(currentTab?.tableName || '').trim() : '';
const contextualQuery = tableName && connection
? buildContextualNewQueryTemplate({
dbType: resolveDataSourceType(connection.config),
tableName,
customTemplate: appearance.newQuerySqlTemplate,
})
: null;
addTab({
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -2735,9 +2750,9 @@ function App() {
type: 'query',
connectionId: targetContext.connectionId,
dbName: targetContext.dbName,
query: ''
query: contextualQuery ?? '',
});
}, [activeTabId, tabs, connections, activeContext, addTab, t]);
}, [activeTabId, tabs, connections, activeContext, addTab, appearance.newQuerySqlTemplate, t]);
const switchActiveTabByOffset = useCallback((offset: 1 | -1) => {
if (tabs.length < 2) return;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveNewQueryContext } from './newQueryContext';
import { canInheritNewQueryTableContext, resolveNewQueryContext } from './newQueryContext';
describe('resolveNewQueryContext', () => {
const validConnectionIds = new Set(['conn-a', 'conn-b']);
@@ -44,4 +44,20 @@ describe('resolveNewQueryContext', () => {
validConnectionIds,
})).toEqual({ connectionId: '', dbName: '' });
});
it('only inherits a table tab when it belongs to the resolved target context', () => {
const tableTab = { type: 'table', connectionId: 'conn-a', dbName: 'database_a', tableName: 'users' };
expect(canInheritNewQueryTableContext({
activeTab: tableTab,
targetContext: { connectionId: 'conn-a', dbName: 'database_a' },
})).toBe(true);
expect(canInheritNewQueryTableContext({
activeTab: tableTab,
targetContext: { connectionId: 'conn-b', dbName: 'database_b' },
})).toBe(false);
expect(canInheritNewQueryTableContext({
activeTab: { ...tableTab, type: 'query' },
targetContext: { connectionId: 'conn-a', dbName: 'database_a' },
})).toBe(false);
});
});

View File

@@ -35,3 +35,20 @@ export const resolveNewQueryContext = ({
|| normalizeValidContext(activeTab, validConnectionIds)
|| { connectionId: '', dbName: '' }
);
export interface NewQueryTableContextLike extends NewQueryContextLike {
type?: unknown;
tableName?: unknown;
}
export const canInheritNewQueryTableContext = ({
activeTab,
targetContext,
}: {
activeTab?: NewQueryTableContextLike | null;
targetContext: NewQueryContext;
}): boolean => {
const tabType = String(activeTab?.type || '');
return (tabType === 'table' || tabType === 'design')
&& String(activeTab?.connectionId || '').trim() === targetContext.connectionId
&& String(activeTab?.dbName || '').trim() === targetContext.dbName;
};

View File

@@ -9,12 +9,47 @@ vi.mock('../../wailsjs/go/app/App', () => ({
}));
import {
buildContextualNewQueryTemplate,
buildTableSelectQuery,
extractTableSelectColumnNames,
isElasticsearchDbType,
resolveTableSelectQuery,
} from './objectQueryTemplates';
describe('buildContextualNewQueryTemplate', () => {
it('builds a dialect-aware select for the active table with the default template', () => {
expect(buildContextualNewQueryTemplate({
dbType: 'mysql',
tableName: 'Order Items',
customTemplate: null,
})).toBe('SELECT * FROM `Order Items`;');
});
it('appends the active table when a custom template ends with FROM', () => {
expect(buildContextualNewQueryTemplate({
dbType: 'postgres',
tableName: 'public.OrderItems',
customTemplate: 'SELECT id, total FROM ',
})).toBe('SELECT id, total FROM public."OrderItems"');
});
it('preserves custom templates that do not expose a table insertion point', () => {
expect(buildContextualNewQueryTemplate({
dbType: 'mysql',
tableName: 'users',
customTemplate: 'SELECT CURRENT_TIMESTAMP;',
})).toBeNull();
});
it('builds the native query format for an active Elasticsearch index', () => {
expect(buildContextualNewQueryTemplate({
dbType: 'elasticsearch',
tableName: 'orders-v1',
customTemplate: null,
})).toBe('GET /orders-v1/_search\n{\n "query": {\n "match_all": {}\n }\n}\n');
});
});
describe('buildTableSelectQuery', () => {
it('quotes uppercase postgres table names in new query templates', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');

View File

@@ -59,6 +59,34 @@ export const buildTableSelectQuery = (
return `SELECT\n ${selectList}\nFROM ${quotedTable}${limitSuffix};`;
};
type BuildContextualNewQueryTemplateOptions = {
dbType: string;
tableName: string;
customTemplate?: string | null;
};
/**
* Build the initial query for a new tab opened while a table-like data tab is active.
* A custom template is only augmented when it explicitly ends at a FROM insertion point.
*/
export const buildContextualNewQueryTemplate = ({
dbType,
tableName,
customTemplate,
}: BuildContextualNewQueryTemplateOptions): string | null => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) return null;
if (isElasticsearchDbType(dbType) || customTemplate === null || customTemplate === undefined) {
return buildTableSelectQuery(dbType, normalizedTableName);
}
const template = String(customTemplate)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
if (!/\bfrom\s*$/i.test(template)) return null;
return `${template}${quoteQualifiedIdent(dbType, normalizedTableName)}`;
};
type ResolveTableSelectQueryOptions = {
dbType: string;
tableName: string;