🐛 fix(tdengine): 修复旧版 TDengine 元数据查询与驱动版本选择异常

- 放开 TDengine 已安装驱动的历史版本切换入口
- 兼容低版本 SHOW TABLES FROM 语法差异
- 修复表概览加载时报 [0x2600] syntax error near
- 新增后端兼容与前端交互回归测试
- Close #531
This commit is contained in:
Syngnat
2026-06-14 17:22:02 +08:00
parent 9e224d0067
commit f3e11961dc
10 changed files with 392 additions and 26 deletions

View File

@@ -71,7 +71,32 @@ vi.mock('antd', () => {
<input value={value} onChange={onChange} placeholder={placeholder} />
);
const Select = () => null;
const Select = ({ value, options, disabled, loading, placeholder, onOpenChange, onChange }: any) => (
<select
value={value}
disabled={disabled}
data-select-loading={String(loading)}
data-select-placeholder={placeholder}
onFocus={() => onOpenChange?.(true)}
onChange={(event) => onChange?.(event.target.value)}
>
<option value="">{placeholder || ''}</option>
{(options || []).flatMap((item: any) => {
if (Array.isArray(item?.options)) {
return item.options.map((grouped: any) => (
<option key={grouped.value} value={grouped.value}>
{String(grouped.label || grouped.value)}
</option>
));
}
return (
<option key={item.value} value={item.value}>
{String(item.label || item.value)}
</option>
);
})}
</select>
);
const Progress = () => <div data-progress="true" />;
const Tag = ({ children }: any) => <span>{children}</span>;
const Switch = ({ checked, onChange, disabled }: any) => (
@@ -288,4 +313,76 @@ describe('DriverManagerModal toolbar actions', () => {
vi.useRealTimers();
}
});
it('allows switching installed TDengine drivers to a historical compatible version', async () => {
backendApp.GetDriverStatusList.mockResolvedValue({
success: true,
data: {
downloadDir: 'D:/drivers',
drivers: [
{
type: 'tdengine',
name: 'TDengine',
builtIn: false,
pinnedVersion: '3.7.8',
installedVersion: '3.7.8',
runtimeAvailable: true,
packageInstalled: true,
connectable: true,
defaultDownloadUrl: 'builtin://activate/tdengine',
message: '已启用',
},
],
},
});
backendApp.GetDriverVersionList.mockResolvedValue({
success: true,
data: {
versions: [
{ version: '3.7.8', downloadUrl: 'builtin://activate/tdengine', recommended: true },
{ version: '3.3.1', downloadUrl: 'builtin://activate/tdengine?channel=history&version=3.3.1' },
],
},
});
backendApp.DownloadDriverPackage.mockResolvedValue({ success: true });
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DriverManagerModal open onClose={vi.fn()} />);
});
await flushPromises();
const refreshButton = findButton(renderer!, '刷新');
await act(async () => {
await refreshButton.props.onClick();
});
await flushPromises();
const versionSelect = renderer!.root.findByType('select');
await act(async () => {
versionSelect.props.onFocus();
});
await flushPromises();
expect(backendApp.GetDriverVersionList).toHaveBeenCalledWith('tdengine', '');
const reloadedVersionSelect = renderer!.root.findByType('select');
await act(async () => {
reloadedVersionSelect.props.onChange({ target: { value: '3.3.1@@builtin://activate/tdengine?channel=history&version=3.3.1' } });
});
await flushPromises();
const switchButtons = renderer!.root.findAll((node) => node.type === 'button' && textContent(node).includes('切换版本'));
expect(switchButtons).toHaveLength(1);
const switchButton = switchButtons[0];
await act(async () => {
await switchButton.props.onClick();
});
expect(backendApp.DownloadDriverPackage).toHaveBeenCalledWith(
'tdengine',
'3.3.1',
'builtin://activate/tdengine?channel=history&version=3.3.1',
'D:/drivers',
);
});
});

View File

@@ -695,6 +695,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
return selectedOption?.version || row.pinnedVersion || '';
}, [selectedVersionMap, versionMap]);
const resolveSelectedVersionOption = useCallback((row: DriverStatusRow) => {
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
return (
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0]
);
}, [selectedVersionMap, versionMap]);
const resolveInstalledDriverVersion = useCallback((row: DriverStatusRow) => (
String(row.installedVersion || '').trim() || String(row.pinnedVersion || '').trim()
), []);
const isDriverVersionSwitchPending = useCallback((row: DriverStatusRow) => {
if (row.builtIn || (!row.packageInstalled && !row.connectable)) {
return false;
}
const selectedVersion = String(resolveSelectedVersionOption(row)?.version || '').trim();
const installedVersion = resolveInstalledDriverVersion(row);
return !!selectedVersion && !!installedVersion && selectedVersion !== installedVersion;
}, [resolveInstalledDriverVersion, resolveSelectedVersionOption]);
const installDriver = useCallback(async (
row: DriverStatusRow,
actionOptions?: { silentToast?: boolean; skipRefresh?: boolean },
@@ -714,9 +737,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) ||
(row.needsUpdate ? versionOptions.find((item) => item.version === row.pinnedVersion) : undefined) ||
(row.needsUpdate ? versionOptions.find((item) => item.recommended) : undefined) ||
versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) ||
versionOptions.find((item) => item.recommended) ||
versionOptions[0];
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
@@ -1022,20 +1045,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
return <Text type="secondary"></Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
const revisionHint = row.needsUpdate ? ',需重装' : '';
return (
<Text type="secondary" className="driver-manager-version-lock">
{installedVersion ? `${installedVersion}(已安装${revisionHint}` : `已安装${row.needsUpdate ? ',需重装' : ''}`}
</Text>
);
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const installedVersion = resolveInstalledDriverVersion(row);
const versionSwitchPending = isDriverVersionSwitchPending(row);
const selectedOption = resolveSelectedVersionOption(row);
const mongoHint = row.type === 'mongodb'
? 'MongoDB 4.0 请使用 1.17.x 兼容驱动2.x 驱动要求 MongoDB 4.2+。'
: '';
@@ -1063,6 +1078,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
void loadVersionPackageSize(row, value);
}}
/>
{(row.packageInstalled || row.connectable) ? (
<Text type="secondary" className="driver-manager-small-text">
{versionSwitchPending
? `当前已安装 ${installedVersion || '当前版本'},已选择 ${selectedOption?.version || '目标版本'},点击“切换版本”生效`
: `${installedVersion ? `${installedVersion}(已安装` : '已安装'}${row.needsUpdate ? ',需重装' : ''}${installedVersion ? '' : ''}`}
</Text>
) : null}
{mongoHint ? <Text type="secondary" className="driver-manager-small-text">{mongoHint}</Text> : null}
</div>
);
@@ -1078,6 +1100,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const versionSwitchPending = isDriverVersionSwitchPending(row);
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary">使 Full </Text>;
@@ -1087,6 +1110,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
) : versionSwitchPending ? (
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
) : row.connectable ? (
<Button danger icon={<DeleteOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TableOverview from './TableOverview';
const storeState = vi.hoisted(() => ({
theme: 'light',
appearance: { uiVersion: 'legacy' as const },
connections: [
{
id: 'conn-1',
config: {
type: 'tdengine',
host: '127.0.0.1',
port: 6041,
user: 'root',
password: 'taosdata',
database: 'metrics',
useSSH: false,
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
},
},
],
addTab: vi.fn(),
setActiveContext: vi.fn(),
setAIPanelVisible: vi.fn(),
addAIContext: vi.fn(),
pinnedSidebarTables: [] as string[],
setSidebarTablePinned: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBGetTables: vi.fn(),
DBQuery: vi.fn(),
DBShowCreateTable: vi.fn(),
ExportTable: vi.fn(),
DropTable: vi.fn(),
RenameTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
buildSidebarTablePinKey: (connectionId: string, dbName: string, tableName: string, schemaName: string) =>
`${connectionId}:${dbName}:${schemaName}:${tableName}`,
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => true,
}));
vi.mock('../utils/connectionRpcConfig', () => ({
buildRpcConnectionConfig: (config: unknown) => config,
}));
vi.mock('./V2TableContextMenu', () => ({
V2TableContextMenuView: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
TableOutlined: Icon,
SearchOutlined: Icon,
ReloadOutlined: Icon,
SortAscendingOutlined: Icon,
DatabaseOutlined: Icon,
ConsoleSqlOutlined: Icon,
EditOutlined: Icon,
CopyOutlined: Icon,
SaveOutlined: Icon,
DeleteOutlined: Icon,
ExportOutlined: Icon,
AppstoreOutlined: Icon,
UnorderedListOutlined: Icon,
WarningOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button = ({ children, onClick, ...rest }: any) => <button type="button" onClick={onClick} {...rest}>{children}</button>;
const Input: any = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
Input.Search = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
const Spin = ({ children }: any) => <div>{children}</div>;
const Empty = ({ description }: any) => <div>{description}</div>;
const Dropdown = ({ children }: any) => <div>{children}</div>;
const Tooltip = ({ children }: any) => <div>{children}</div>;
const Modal: any = ({ children }: any) => <div>{children}</div>;
Modal.confirm = vi.fn();
return {
Button,
Dropdown,
Empty,
Input,
Modal,
Spin,
Tooltip,
message: messageApi,
};
});
const flushPromises = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
};
const collectText = (node: any): string => {
if (!node) {
return '';
}
if (typeof node === 'string') {
return node;
}
if (Array.isArray(node)) {
return node.map((item) => collectText(item)).join('');
}
return collectText(node.children || []);
};
describe('TableOverview tdengine compatibility', () => {
beforeEach(() => {
vi.clearAllMocks();
backendApp.DBGetTables.mockResolvedValue({
success: true,
data: [
{ Table: 'd001' },
{ Table: 'meters' },
],
});
backendApp.DBQuery.mockResolvedValue({
success: false,
message: '[0x2600] syntax error near',
});
});
it('loads tdengine overview rows through DBGetTables instead of direct metadata SQL', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<TableOverview tab={{
id: 'tab-1',
title: '表概览 - metrics',
type: 'table-overview',
connectionId: 'conn-1',
dbName: 'metrics',
} as any} />);
});
await flushPromises();
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'metrics');
expect(backendApp.DBQuery).not.toHaveBeenCalled();
expect(messageApi.error).not.toHaveBeenCalled();
const renderedText = collectText(renderer!.toJSON());
expect(renderedText).toContain('meters');
expect(renderedText).toContain('d001');
});
});

View File

@@ -4,7 +4,7 @@ import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'a
import type { MenuProps } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { buildSidebarTablePinKey, useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import { DBGetTables, DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -231,7 +231,7 @@ const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableSta
};
return {
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Device', 'device']),
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Table', 'table', 'Device', 'device']),
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
@@ -290,6 +290,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
if (metadataDialect === 'tdengine') {
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, tab.dbName || '');
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
return;
}
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', schemaName);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {connection} from '../models';

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

View File

@@ -252,6 +252,56 @@ func TestTDengineGetTablesIncludesSuperTables(t *testing.T) {
}
}
func TestTDengineGetTablesFallsBackToLegacyFromSyntax(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["SHOW TABLES FROM `metrics`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`'"),
}
state.queryResults["SHOW STABLES FROM `metrics`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`'"),
}
state.queryResults["SHOW TABLES FROM metrics"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"d001"},
},
}
state.queryResults["SHOW STABLES FROM metrics"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"meters"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
tables, err := td.GetTables("metrics")
if err != nil {
t.Fatalf("GetTables returned error: %v", err)
}
wantTables := []string{"d001", "meters"}
if !reflect.DeepEqual(tables, wantTables) {
t.Fatalf("unexpected tables: got=%v want=%v", tables, wantTables)
}
queries := state.snapshotQueries()
wantQueries := []string{
"SHOW TABLES FROM `metrics`",
"SHOW STABLES FROM `metrics`",
"SHOW TABLES FROM metrics",
"SHOW STABLES FROM metrics",
"SHOW TABLES",
"SHOW STABLES",
}
if !reflect.DeepEqual(queries, wantQueries) {
t.Fatalf("unexpected query sequence: got=%v want=%v", queries, wantQueries)
}
}
func TestTDengineGetColumnsFallsBackToLegacyDescribeSyntax(t *testing.T) {
t.Parallel()

View File

@@ -211,13 +211,7 @@ func (t *TDengineDB) GetDatabases() ([]string, error) {
}
func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
queries := make([]string, 0, 4)
if strings.TrimSpace(dbName) != "" {
queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName)))
queries = append(queries, fmt.Sprintf("SHOW STABLES FROM `%s`", escapeBacktickIdent(dbName)))
}
queries = append(queries, "SHOW TABLES")
queries = append(queries, "SHOW STABLES")
queries := tdengineShowTablesQueries(dbName)
var lastErr error
tableSet := make(map[string]struct{})
@@ -515,6 +509,35 @@ func escapeBacktickIdent(ident string) string {
return strings.ReplaceAll(strings.TrimSpace(ident), "`", "``")
}
func tdengineShowTablesQueries(dbName string) []string {
queries := make([]string, 0, 6)
appendQuery := func(query string) {
query = strings.TrimSpace(query)
if query == "" {
return
}
for _, existing := range queries {
if existing == query {
return
}
}
queries = append(queries, query)
}
db := strings.TrimSpace(dbName)
if db != "" {
escaped := escapeBacktickIdent(db)
appendQuery(fmt.Sprintf("SHOW TABLES FROM `%s`", escaped))
appendQuery(fmt.Sprintf("SHOW STABLES FROM `%s`", escaped))
appendQuery(fmt.Sprintf("SHOW TABLES FROM %s", db))
appendQuery(fmt.Sprintf("SHOW STABLES FROM %s", db))
}
appendQuery("SHOW TABLES")
appendQuery("SHOW STABLES")
return queries
}
func tdengineDescribeQueries(dbName, tableName string) []string {
qualified := quoteTDengineTable(dbName, tableName)
legacyQualified := quoteTDengineTableLegacy(dbName, tableName)