feat(iotdb): 新增 Apache IoTDB 时序库连接支持

Refs #546
This commit is contained in:
Syngnat
2026-06-13 18:23:56 +08:00
parent c805b16fcd
commit f3dfffb8d1
45 changed files with 1292 additions and 45 deletions

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine iotdb clickhouse elasticsearch)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
@@ -43,7 +43,7 @@ normalize_driver() {
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
elasticsearch|elastic) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|clickhouse)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$name"
;;
*)

View File

@@ -0,0 +1,12 @@
//go:build gonavi_iotdb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "iotdb"
agentDatabaseFactory = func() db.Database {
return &db.IoTDBDB{}
}
}

View File

@@ -64,6 +64,18 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain('return "http://127.0.0.1:6333";');
expect(source).toContain('return "apiKey=...";');
});
it('exposes Apache IoTDB in the create-connection picker with timeseries defaults', () => {
expect(source).toContain("case 'iotdb':");
expect(source).toContain('return 6667;');
expect(source).toContain('iotdb: ["iotdb"]');
expect(source).toContain("key: 'iotdb'");
expect(source).toContain("name: 'Apache IoTDB'");
expect(source).toContain('dbType === "iotdb"');
expect(source).toContain("return 'Storage Group / Device / Timeseries';");
expect(source).toContain('return "iotdb://root:root@127.0.0.1:6667/root.sg";');
expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";');
});
});
describe('ConnectionModal Redis Sentinel configuration', () => {

View File

@@ -1873,6 +1873,9 @@ const ConnectionModal: React.FC<{
if (dbType === "qdrant") {
return "http://127.0.0.1:6333";
}
if (dbType === "iotdb") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
}
if (dbType === "redis") {
return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster";
}
@@ -1922,6 +1925,8 @@ const ConnectionModal: React.FC<{
return "schema=SYSDBA";
case "tdengine":
return "timezone=Asia%2FShanghai";
case "iotdb":
return "fetchSize=1024&timeZone=Asia%2FShanghai";
default:
return "key=value&another=value";
}

View File

@@ -7,6 +7,7 @@ import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
const { Title, Text } = Typography;
@@ -46,26 +47,11 @@ type TableOps = {
type WorkflowType = 'sync' | 'migration';
const quoteSqlIdent = (dbType: string, ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
const t = String(dbType || '').toLowerCase();
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'starrocks' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
if (t === 'sqlserver') {
return `[${raw.replace(/]/g, ']]')}]`;
}
return `"${raw.replace(/"/g, '""')}"`;
return quoteIdentPart(dbType, String(ident || '').trim());
};
const quoteSqlTable = (dbType: string, tableName: string): string => {
const raw = String(tableName || '').trim();
if (!raw) return raw;
if (!raw.includes('.')) return quoteSqlIdent(dbType, raw);
return raw
.split('.')
.map((part) => quoteSqlIdent(dbType, part))
.join('.');
return quoteQualifiedIdent(dbType, String(tableName || '').trim());
};
const toSqlLiteral = (value: any, dbType: string): string => {

View File

@@ -32,6 +32,13 @@ describe('DatabaseIcons', () => {
expect(markup).toContain('>Qd</text>');
});
it('includes Apache IoTDB in the selectable database icons', () => {
expect(DB_ICON_TYPES).toContain('iotdb');
expect(getDbIconLabel('iotdb')).toBe('Apache IoTDB');
const markup = renderToStaticMarkup(<>{getDbIcon('iotdb', undefined, 22)}</>);
expect(markup).toContain('>Io</text>');
});
it('wraps database icons in a consistent frame for sidebar sizing', () => {
const mysqlMarkup = renderToStaticMarkup(<>{getDbIcon('mysql', undefined, 22)}</>);
const jvmMarkup = renderToStaticMarkup(<>{getDbIcon('jvm', undefined, 22)}</>);

View File

@@ -49,6 +49,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
highgo: '#00A86B',
iris: '#1F6FEB',
tdengine: '#2962FF',
iotdb: '#0F766E',
chroma: '#7C3AED',
qdrant: '#DC244C',
diros: '#0050B3',
@@ -180,6 +181,9 @@ const IrisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
const IoTDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.iotdb} label="Io" />
);
const ChromaIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.chroma} label="Ch" />
);
@@ -239,6 +243,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
highgo: HighGoIcon,
iris: IrisIcon,
tdengine: TDengineIcon,
iotdb: IoTDBIcon,
chroma: ChromaIcon,
qdrant: QdrantIcon,
elasticsearch: ElasticsearchIcon,
@@ -249,7 +254,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'chroma', 'qdrant', 'elasticsearch', 'custom',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'iotdb', 'chroma', 'qdrant', 'elasticsearch', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
@@ -271,7 +276,7 @@ export const getDbIconLabel = (type: string): string => {
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
starrocks: 'StarRocks',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine', iotdb: 'Apache IoTDB',
chroma: 'Chroma',
qdrant: 'Qdrant',
elasticsearch: 'Elasticsearch',

View File

@@ -2540,16 +2540,16 @@ const Sidebar: React.FC<{
}
};
const isNonRelationalDbType = (connectionId: string): boolean => {
const isStructureOnlyDbType = (connectionId: string): boolean => {
const conn = connections.find(c => c.id === connectionId);
if (!conn) return false;
const dbType = resolveDataSourceType(conn.config);
return dbType === 'elasticsearch' || dbType === 'mongodb' || dbType === 'redis';
return dbType === 'elasticsearch' || dbType === 'mongodb' || dbType === 'redis' || dbType === 'iotdb';
};
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
const { tableName, dbName, id } = node.dataRef;
const forceReadOnly = readOnly || isNonRelationalDbType(id);
const forceReadOnly = readOnly || isStructureOnlyDbType(id);
addTab({
id: `design-${id}-${dbName}-${tableName}`,
title: `${forceReadOnly ? '表结构' : '设计表'} (${tableName})`,
@@ -2564,6 +2564,10 @@ const Sidebar: React.FC<{
const openNewTableDesign = (node: any) => {
const { dbName, id } = node.dataRef;
if (isStructureOnlyDbType(id)) {
message.warning('当前数据源暂不支持可视化新建表');
return;
}
addTab({
id: `new-table-${id}-${dbName}-${Date.now()}`,
title: `新建表 - ${dbName}`,
@@ -6405,14 +6409,15 @@ const Sidebar: React.FC<{
const groupData = node.dataRef; // { ...conn, dbName, groupKey }
const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`;
const currentSort = tableSortPreference[sortPreferenceKey] || 'name';
const canCreateTable = !isStructureOnlyDbType(String(groupData.id || ''));
return [
{
...(canCreateTable ? [{
key: 'new-table',
label: '新建表',
icon: <TableOutlined />,
onClick: () => openNewTableDesign(node)
},
}] : []),
{ type: 'divider' },
{
key: 'sort-by-name',
@@ -6845,13 +6850,14 @@ const Sidebar: React.FC<{
const capabilities = getDataSourceCapabilities(databaseConn?.config);
const isStarRocks = dialect === 'starrocks';
const supportsSchemaActions = isPostgresSchemaDialect(dialect);
const canCreateTable = !isStructureOnlyDbType(String(databaseConn?.id || ''));
return [
{
...(canCreateTable ? [{
key: 'new-table',
label: '新建表',
icon: <TableOutlined />,
onClick: () => openNewTableDesign(node)
},
}] : []),
...(supportsSchemaActions ? [
{
key: 'new-schema',
@@ -7116,7 +7122,7 @@ const Sidebar: React.FC<{
{ type: 'divider' },
{
key: 'design-table',
label: '设计表',
label: isStructureOnlyDbType(String(node.dataRef?.id || '')) ? '表结构' : '设计表',
icon: <EditOutlined />,
onClick: () => openDesign(node, 'columns', false)
},

View File

@@ -132,6 +132,11 @@ const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
const iotdbDevicePattern = (name: string) => {
const normalized = String(name || '').trim().replace(/[`"]/g, '');
if (!normalized) return '';
return normalized.endsWith('.**') ? normalized : `${normalized}.**`;
};
switch (dialect) {
case 'mysql':
case 'starrocks':
@@ -190,6 +195,10 @@ ORDER BY s.name, t.name`;
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'tdengine':
return `SHOW TABLES FROM \`${dbName.replace(/`/g, '``')}\``;
case 'iotdb': {
const pattern = iotdbDevicePattern(dbName);
return pattern ? `SHOW DEVICES ${pattern}` : 'SHOW DEVICES';
}
case 'dm':
case 'oracle': {
const owner = (schemaName || dbName).toUpperCase();
@@ -219,7 +228,7 @@ const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableSta
};
return {
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME']),
name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', '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']),
@@ -263,6 +272,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const schemaName = String((tab as any).schemaName || '').trim();
const supportsDesignWrite = metadataDialect !== 'iotdb';
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
@@ -415,17 +425,18 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
const structureOnly = !supportsDesignWrite;
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
title: `${structureOnly ? '表结构' : '设计表'} (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: false,
readOnly: structureOnly,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
}, [connection, tab.dbName, addTab, setActiveContext, supportsDesignWrite]);
const openTableDdl = useCallback((tableName: string) => {
if (!connection) return;
@@ -827,7 +838,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(table.name) },
{ key: 'design-table', label: supportsDesignWrite ? '设计表' : '表结构', icon: <EditOutlined />, onClick: () => openDesign(table.name) },
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(table.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(table.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(table.name, 'sql') },
@@ -855,6 +866,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
handleTableDataDangerAction,
openDesign,
openQueryForTable,
supportsDesignWrite,
]);
const renderOverviewSectionTitle = (section: OverviewTableSection) => (

View File

@@ -289,6 +289,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
"postgres",
"redis",
"tdengine",
"iotdb",
"oracle",
"dameng",
"kingbase",
@@ -353,6 +354,8 @@ const getDefaultPortByType = (type: string): number => {
return 6379;
case "tdengine":
return 6041;
case "iotdb":
return 6667;
case "oracle":
return 1521;
case "dameng":

View File

@@ -16,6 +16,8 @@ describe('connectionDriverType', () => {
expect(normalizeDriverType('chroma-db')).toBe('chroma');
expect(normalizeDriverType('qdrantdb')).toBe('qdrant');
expect(normalizeDriverType('qdrant-db')).toBe('qdrant');
expect(normalizeDriverType('apache-iotdb')).toBe('iotdb');
expect(normalizeDriverType('apache_iotdb')).toBe('iotdb');
expect(normalizeDriverType('doris')).toBe('diros');
expect(normalizeDriverType('open-gauss')).toBe('opengauss');
expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris');

View File

@@ -17,6 +17,7 @@ export const normalizeDriverType = (value: string): string => {
if (normalized === 'elastic') return 'elasticsearch';
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
if (normalized === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||

View File

@@ -91,6 +91,7 @@ describe('connectionModalPresentation', () => {
'qdrant',
'redis',
'tdengine',
'iotdb',
'custom',
'jvm',
];
@@ -174,6 +175,14 @@ describe('connectionModalPresentation', () => {
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('iotdb').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
});
it('uses localized labels for layout kinds shown in the modal', () => {
@@ -181,5 +190,6 @@ describe('connectionModalPresentation', () => {
expect(getConnectionConfigLayoutKindLabel('file')).toBe('文件型数据库');
expect(getConnectionConfigLayoutKindLabel('search')).toBe('搜索引擎');
expect(getConnectionConfigLayoutKindLabel('vector')).toBe('向量数据库');
expect(getConnectionConfigLayoutKindLabel('timeseries')).toBe('时序数据库');
});
});

View File

@@ -41,6 +41,7 @@ export type ConnectionConfigLayoutKind =
| 'file'
| 'search'
| 'vector'
| 'timeseries'
| 'custom'
| 'jvm'
| 'generic-sql';
@@ -163,6 +164,8 @@ export const getConnectionConfigLayoutKindLabel = (
return '搜索引擎';
case 'vector':
return '向量数据库';
case 'timeseries':
return '时序数据库';
case 'custom':
return '自定义连接';
case 'jvm':
@@ -265,6 +268,19 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'iotdb') {
return {
kind: 'timeseries',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (postgresCompatibleTypes.has(type)) {
return {
kind: 'postgres-compatible',

View File

@@ -19,6 +19,7 @@ describe('connectionTypeCapabilities', () => {
expect(singleHostUriSchemesByType.elasticsearch).toEqual(['http', 'https']);
expect(singleHostUriSchemesByType.chroma).toEqual(['http', 'https', 'chroma']);
expect(singleHostUriSchemesByType.qdrant).toEqual(['http', 'https', 'qdrant']);
expect(singleHostUriSchemesByType.iotdb).toEqual(['iotdb']);
expect(singleHostUriSchemesByType.redis).toEqual(['redis']);
});
@@ -29,6 +30,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsSSLForType('chroma')).toBe(true);
expect(supportsSSLForType('qdrant')).toBe(true);
expect(supportsSSLForType('tdengine')).toBe(true);
expect(supportsSSLForType('iotdb')).toBe(false);
expect(supportsSSLForType('dameng')).toBe(true);
expect(supportsSSLForType('sqlite')).toBe(false);
});
@@ -70,6 +72,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsConnectionParamsForType('mongodb')).toBe(true);
expect(supportsConnectionParamsForType('dameng')).toBe(true);
expect(supportsConnectionParamsForType('tdengine')).toBe(true);
expect(supportsConnectionParamsForType('iotdb')).toBe(true);
expect(supportsConnectionParamsForType('elasticsearch')).toBe(true);
expect(supportsConnectionParamsForType('chroma')).toBe(true);
expect(supportsConnectionParamsForType('qdrant')).toBe(true);

View File

@@ -7,6 +7,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
iris: ["iris", "intersystems"],
redis: ["redis"],
tdengine: ["tdengine"],
iotdb: ["iotdb"],
dameng: ["dameng", "dm"],
kingbase: ["kingbase"],
highgo: ["highgo"],
@@ -129,6 +130,7 @@ export const supportsConnectionParamsForType = (type: string) =>
type === "mongodb" ||
type === "dameng" ||
type === "tdengine" ||
type === "iotdb" ||
type === "elasticsearch" ||
type === "chroma" ||
type === "qdrant";

View File

@@ -26,6 +26,7 @@ describe('connectionTypeCatalog', () => {
expect(keys).toContain('elasticsearch');
expect(keys).toContain('chroma');
expect(keys).toContain('qdrant');
expect(keys).toContain('iotdb');
expect(keys).toContain('jvm');
expect(keys).toContain('custom');
expect(new Set(keys).size).toBe(keys.length);
@@ -42,6 +43,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeDefaultPort('elasticsearch')).toBe(9200);
expect(getConnectionTypeDefaultPort('chroma')).toBe(8000);
expect(getConnectionTypeDefaultPort('qdrant')).toBe(6333);
expect(getConnectionTypeDefaultPort('iotdb')).toBe(6667);
expect(getConnectionTypeDefaultPort('sqlite')).toBe(0);
expect(getConnectionTypeDefaultPort('duckdb')).toBe(0);
expect(getConnectionTypeDefaultPort('unknown')).toBe(3306);
@@ -53,6 +55,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeHint('elasticsearch')).toContain('Mapping');
expect(getConnectionTypeHint('chroma')).toContain('向量');
expect(getConnectionTypeHint('qdrant')).toContain('Payload');
expect(getConnectionTypeHint('iotdb')).toContain('Timeseries');
expect(getConnectionTypeHint('oceanbase')).toBe('MySQL / Oracle 租户');
expect(getConnectionTypeHint('duckdb')).toBe('本地文件连接');
expect(getConnectionTypeHint('mysql')).toBe('标准连接配置');

View File

@@ -56,6 +56,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
label: '时序数据库',
items: [
{ key: 'tdengine', name: 'TDengine' },
{ key: 'iotdb', name: 'Apache IoTDB' },
],
},
{
@@ -90,6 +91,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
return 6379;
case 'tdengine':
return 6041;
case 'iotdb':
return 6667;
case 'oracle':
return 1521;
case 'dameng':
@@ -138,6 +141,8 @@ export const getConnectionTypeHint = (type: string): string => {
return 'Collection 浏览、向量检索和元数据过滤';
case 'qdrant':
return 'Collection 浏览、向量搜索和 Payload 过滤';
case 'iotdb':
return 'Storage Group / Device / Timeseries';
case 'oceanbase':
return 'MySQL / Oracle 租户';
case 'sqlite':

View File

@@ -108,6 +108,25 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats Apache IoTDB as a queryable timeseries datasource with IoTDB-specific writes', () => {
expect(getDataSourceCapabilities({ type: 'iotdb' })).toMatchObject({
type: 'iotdb',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'apache-iotdb' })).toMatchObject({
type: 'iotdb',
supportsQueryEditor: true,
supportsCopyInsert: false,
forceReadOnlyQueryResult: true,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',

View File

@@ -27,6 +27,9 @@ const normalizeDataSourceToken = (raw: string): string => {
case 'qdrantdb':
case 'qdrant-db':
return 'qdrant';
case 'apache-iotdb':
case 'apache_iotdb':
return 'iotdb';
case 'intersystems':
case 'intersystemsiris':
case 'inter-systems':
@@ -98,7 +101,7 @@ const COPY_INSERT_TYPES = new Set([
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);

View File

@@ -25,6 +25,7 @@ describe('applyQueryAutoLimit', () => {
'duckdb',
'clickhouse',
'tdengine',
'iotdb',
];
it.each(limitDialects)('adds generic LIMIT for %s connections', (dbType) => {

View File

@@ -54,6 +54,11 @@ describe('reverseOrderBySQL', () => {
});
describe('quoteQualifiedIdent', () => {
it('quotes Apache IoTDB device paths with backticks per path segment', () => {
expect(quoteQualifiedIdent('iotdb', 'root.sg.d1'))
.toBe('`root`.`sg`.`d1`');
});
it('does not split dots inside quoted DuckDB identifiers', () => {
expect(quoteQualifiedIdent('duckdb', '"daily.events"."2026.06"'))
.toBe('"daily.events"."2026.06"');

View File

@@ -29,7 +29,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'iotdb' || dbTypeLower === 'clickhouse') {
return `\`${raw.replace(/`/g, '``')}\``;
}

View File

@@ -34,6 +34,8 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'chroma-db')).toBe('chroma');
expect(resolveSqlDialect('QdrantDB')).toBe('qdrant');
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(resolveSqlDialect('custom', 'oceanbase', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
@@ -56,9 +58,16 @@ describe('sqlDialect', () => {
expect(values(resolveColumnTypeOptions('clickhouse'))).toContain('DateTime64(3)');
expect(values(resolveColumnTypeOptions('iris'))).toContain('varchar(255)');
expect(values(resolveColumnTypeOptions('tdengine'))).toContain('TIMESTAMP');
expect(values(resolveColumnTypeOptions('iotdb'))).toContain('INT64');
expect(values(resolveColumnTypeOptions('duckdb'))).toContain('STRUCT');
});
it('resolves Apache IoTDB completion keywords and functions independently', () => {
expect(resolveSqlKeywords('iotdb')).toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'SHOW TIMESERIES', 'WITH DATATYPE']));
expect(names(resolveSqlFunctions('iotdb'))).toEqual(expect.arrayContaining(['DATE_BIN', 'DIFF', 'TOP_K']));
expect(resolveSqlKeywords('iotdb')).not.toEqual(expect.arrayContaining(['TAGS', 'USING']));
});
it('resolves oracle completion keywords and functions without mysql-only suggestions', () => {
expect(resolveSqlKeywords('oracle')).toEqual(expect.arrayContaining(['ROWNUM', 'FETCH', 'VARCHAR2', 'NUMBER']));
expect(resolveSqlKeywords('oracle')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE', 'LIMIT']));

View File

@@ -27,6 +27,7 @@ export type SqlDialect =
| 'duckdb'
| 'clickhouse'
| 'tdengine'
| 'iotdb'
| 'mongodb'
| 'redis'
| 'elasticsearch'
@@ -111,6 +112,7 @@ export const resolveSqlDialect = (
case 'duckdb':
case 'clickhouse':
case 'tdengine':
case 'iotdb':
case 'mongodb':
case 'redis':
case 'elasticsearch':
@@ -125,6 +127,9 @@ export const resolveSqlDialect = (
case 'qdrant-db':
case 'qdrant':
return 'qdrant';
case 'apache-iotdb':
case 'apache_iotdb':
return 'iotdb';
default:
break;
}
@@ -147,6 +152,7 @@ export const resolveSqlDialect = (
if (source.includes('duckdb')) return 'duckdb';
if (source.includes('clickhouse')) return 'clickhouse';
if (source.includes('tdengine')) return 'tdengine';
if (source.includes('iotdb')) return 'iotdb';
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
if (source.includes('iris') || source.includes('intersystems')) return 'iris';
if (source.includes('elastic')) return 'elasticsearch';
@@ -171,7 +177,7 @@ export const isOracleLikeDialect = (dbType: string): boolean => (
export const isSqlServerDialect = (dbType: string): boolean => resolveSqlDialect(dbType) === 'sqlserver';
export const isBacktickIdentifierDialect = (dbType: string): boolean => (
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine'].includes(resolveSqlDialect(dbType))
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine', 'iotdb'].includes(resolveSqlDialect(dbType))
);
const stripIdentifierQuotes = (part: string): string => {
@@ -470,6 +476,19 @@ const TDENGINE_TYPES = optionValues([
'GEOMETRY',
]);
const IOTDB_TYPES = optionValues([
'BOOLEAN',
'INT32',
'INT64',
'FLOAT',
'DOUBLE',
'TEXT',
'STRING',
'BLOB',
'TIMESTAMP',
'DATE',
]);
const DUCKDB_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
@@ -514,6 +533,7 @@ export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] =>
if (dialect === 'duckdb') return DUCKDB_TYPES;
if (dialect === 'clickhouse') return CLICKHOUSE_TYPES;
if (dialect === 'tdengine') return TDENGINE_TYPES;
if (dialect === 'iotdb') return IOTDB_TYPES;
return COMMON_TYPES;
};
@@ -565,6 +585,26 @@ const STARROCKS_KEYWORDS = [
const TDENGINE_KEYWORDS = ['LIMIT', 'SLIMIT', 'SOFFSET', 'TAGS', 'USING', 'INTERVAL', 'FILL', 'PARTITION BY'];
const IOTDB_KEYWORDS = [
'LIMIT',
'OFFSET',
'ALIGN BY DEVICE',
'DISABLE ALIGN',
'GROUP BY',
'LEVEL',
'FILL',
'SLIMIT',
'SOFFSET',
'CREATE TIMESERIES',
'SHOW TIMESERIES',
'SHOW DEVICES',
'SHOW DATABASES',
'STORAGE GROUP',
'WITH DATATYPE',
'ENCODING',
'COMPRESSION',
];
export const resolveSqlKeywords = (dbType: string): string[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'starrocks') return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS, ...STARROCKS_KEYWORDS]);
@@ -576,6 +616,7 @@ export const resolveSqlKeywords = (dbType: string): string[] => {
if (dialect === 'duckdb') return unique([...COMMON_KEYWORDS, ...DUCKDB_KEYWORDS]);
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
return COMMON_KEYWORDS;
};
@@ -793,6 +834,19 @@ const TDENGINE_FUNCTIONS = [
fn('IRATE', 'TDengine - 瞬时变化率'),
];
const IOTDB_FUNCTIONS = [
fn('NOW', 'IoTDB - 当前时间'),
fn('DATE_BIN', 'IoTDB - 时间分桶'),
fn('DIFF', 'IoTDB - 差分'),
fn('TIME_DIFFERENCE', 'IoTDB - 时间差'),
fn('DERIVATIVE', 'IoTDB - 导数'),
fn('NON_NEGATIVE_DERIVATIVE', 'IoTDB - 非负导数'),
fn('TOP_K', 'IoTDB - Top K'),
fn('BOTTOM_K', 'IoTDB - Bottom K'),
fn('M4', 'IoTDB - M4 降采样'),
fn('EQUAL_SIZE_BUCKET_RANDOM_SAMPLE', 'IoTDB - 等宽随机采样'),
];
const mergeFunctions = (items: SqlFunctionCompletion[]): SqlFunctionCompletion[] => {
const seen = new Set<string>();
const result: SqlFunctionCompletion[] = [];
@@ -816,5 +870,6 @@ export const resolveSqlFunctions = (dbType: string): SqlFunctionCompletion[] =>
if (dialect === 'duckdb') return mergeFunctions([...COMMON_FUNCTIONS, ...DUCKDB_FUNCTIONS]);
if (dialect === 'clickhouse') return mergeFunctions([...COMMON_FUNCTIONS, ...CLICKHOUSE_FUNCTIONS]);
if (dialect === 'tdengine') return mergeFunctions([...COMMON_FUNCTIONS, ...TDENGINE_FUNCTIONS]);
if (dialect === 'iotdb') return mergeFunctions([...COMMON_FUNCTIONS, ...IOTDB_FUNCTIONS]);
return COMMON_FUNCTIONS;
};

2
go.mod
View File

@@ -6,6 +6,7 @@ require (
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
gitee.com/chunanyong/dm v1.8.22
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
github.com/apache/iotdb-client-go v1.3.7
github.com/caretdev/go-irisnative v0.2.1
github.com/duckdb/duckdb-go/v2 v2.5.5
github.com/elastic/go-elasticsearch/v8 v8.19.6
@@ -31,6 +32,7 @@ require (
)
require (
github.com/apache/thrift v0.22.0 // indirect
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect

4
go.sum
View File

@@ -28,6 +28,9 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
github.com/apache/iotdb-client-go v1.3.7 h1:NHEW0yysGfxFQkkJpFHTlww1a/RHCINbOXBfv2/aIQ0=
github.com/apache/iotdb-client-go v1.3.7/go.mod h1:3D6QYkqRmASS/4HsjU+U/3fscyc5M9xKRfywZsKuoZY=
github.com/apache/thrift v0.15.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
@@ -96,6 +99,7 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=

View File

@@ -20,7 +20,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
if !isOceanBaseOracleProtocol(config) {
runConfig.Database = name
}
case "mysql", "mariadb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "iris", "intersystems", "intersystemsiris", "inter-systems", "inter-systems-iris", "mongodb", "tdengine", "clickhouse":
case "mysql", "mariadb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "iris", "intersystems", "intersystemsiris", "inter-systems", "inter-systems-iris", "mongodb", "tdengine", "iotdb", "clickhouse":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":
@@ -51,7 +51,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
// Elasticsearch索引名可能含多个点如 iot_pro_biz_operate_log.index.20240626
// 不能按点分割,直接返回原始数据库名和完整表名。
if dbType == "elasticsearch" {
if dbType == "elasticsearch" || dbType == "iotdb" {
return rawDB, rawTable
}

View File

@@ -217,6 +217,8 @@ func defaultPortByType(driverType string) int {
return 6379
case "tdengine":
return 6041
case "iotdb":
return 6667
case "oracle":
return 1521
case "dameng":

View File

@@ -354,6 +354,7 @@ const builtinDriverManifestJSON = `{
"iris": { "engine": "go", "version": "0.2.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/iris" },
"mongodb": { "engine": "go", "version": "1.17.9", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" },
"tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" },
"iotdb": { "engine": "go", "version": "1.3.7", "checksumPolicy": "off", "downloadUrl": "builtin://activate/iotdb" },
"clickhouse": { "engine": "go", "version": "2.43.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" },
"elasticsearch": { "engine": "go", "version": "8.19.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/elasticsearch" }
}
@@ -416,6 +417,7 @@ var latestDriverVersionMap = map[string]string{
"iris": "0.2.1",
"mongodb": "2.5.0",
"tdengine": "3.7.8",
"iotdb": "1.3.7",
"clickhouse": "2.43.1",
"elasticsearch": "8.19.6",
"oracle": "2.9.0",
@@ -440,6 +442,7 @@ var driverGoModulePathMap = map[string]string{
"iris": "github.com/caretdev/go-irisnative",
"mongodb": "go.mongodb.org/mongo-driver/v2",
"tdengine": "github.com/taosdata/driver-go/v3",
"iotdb": "github.com/apache/iotdb-client-go",
"clickhouse": "github.com/ClickHouse/clickhouse-go/v2",
"elasticsearch": "github.com/elastic/go-elasticsearch/v8",
}
@@ -1505,6 +1508,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
buildOptionalGoDriverDefinition("iris", "InterSystems IRIS", packages),
buildOptionalGoDriverDefinition("mongodb", "MongoDB", packages),
buildOptionalGoDriverDefinition("tdengine", "TDengine", packages),
buildOptionalGoDriverDefinition("iotdb", "Apache IoTDB", packages),
buildOptionalGoDriverDefinition("clickhouse", "ClickHouse", packages),
buildOptionalGoDriverDefinition("elasticsearch", "Elasticsearch", packages),
}
@@ -4081,6 +4085,8 @@ func optionalDriverBuildTag(driverType string, selectedVersion string) (string,
return "gonavi_mongodb_driver", nil
case "tdengine":
return "gonavi_tdengine_driver", nil
case "iotdb":
return "gonavi_iotdb_driver", nil
case "clickhouse":
return "gonavi_clickhouse_driver", nil
case "elasticsearch":

View File

@@ -188,6 +188,7 @@ func optionalDriverAgentRevisionTestDrivers(t *testing.T) []string {
"iris",
"mongodb",
"tdengine",
"iotdb",
"clickhouse",
"elasticsearch",
}

View File

@@ -469,6 +469,39 @@ func TestElasticsearchDriverDefinitionUsesOptionalAgent(t *testing.T) {
}
}
func TestIoTDBDriverDefinitionUsesOptionalAgent(t *testing.T) {
definition, ok := resolveDriverDefinition("iotdb")
if !ok {
t.Fatal("expected iotdb driver definition")
}
if definition.Name != "Apache IoTDB" {
t.Fatalf("unexpected iotdb driver name: %q", definition.Name)
}
if definition.BuiltIn {
t.Fatal("expected iotdb to be an optional driver agent")
}
if driverGoModulePathMap["iotdb"] != "github.com/apache/iotdb-client-go" {
t.Fatalf("unexpected iotdb go module path: %q", driverGoModulePathMap["iotdb"])
}
if definition.PinnedVersion != "1.3.7" {
t.Fatalf("unexpected iotdb definition pinned version: %q", definition.PinnedVersion)
}
if definition.DefaultDownloadURL != "builtin://activate/iotdb" {
t.Fatalf("unexpected iotdb default download URL: %q", definition.DefaultDownloadURL)
}
if latestDriverVersionMap["iotdb"] != "1.3.7" {
t.Fatalf("unexpected iotdb pinned version: %q", latestDriverVersionMap["iotdb"])
}
tags, err := optionalDriverBuildTags("iotdb", "")
if err != nil {
t.Fatalf("resolve iotdb build tags failed: %v", err)
}
if tags != "gonavi_iotdb_driver" {
t.Fatalf("unexpected iotdb build tag: %q", tags)
}
}
func TestBuildOptionalDriverInstallPlanMessagePrefersDirectThenBundle(t *testing.T) {
message := buildOptionalDriverInstallPlanMessage("SQL Server", "1.9.6", false, false, false, false, 1, 2)
if !strings.Contains(message, "先尝试 1 个预编译直链") {

View File

@@ -19,6 +19,7 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("iris"), "iris", "intersystems")
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
registerDatabaseFactory(newOptionalDriverAgentDatabase("iotdb"), "iotdb", "apache-iotdb", "apache_iotdb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
registerDatabaseFactory(newOptionalDriverAgentDatabase("elasticsearch"), "elasticsearch", "elastic")
}

View File

@@ -19,6 +19,7 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("iris"), "iris", "intersystems")
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
registerDatabaseFactory(newOptionalDriverAgentDatabase("iotdb"), "iotdb", "apache-iotdb", "apache_iotdb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
registerDatabaseFactory(newOptionalDriverAgentDatabase("elasticsearch"), "elasticsearch", "elastic")
}

View File

@@ -20,6 +20,7 @@ func init() {
"iris": "src-1b072c57af08bec4",
"mongodb": "src-57fdd8bfebdcd46e",
"tdengine": "src-939715f94df1ec9c",
"iotdb": "src-473c39891f926db2",
"clickhouse": "src-482d62ed565b3e69",
"elasticsearch": "src-2fb00b94d7067c56",
}

View File

@@ -39,6 +39,7 @@ var optionalGoDrivers = map[string]struct{}{
"iris": {},
"mongodb": {},
"tdengine": {},
"iotdb": {},
"clickhouse": {},
"elasticsearch": {},
}
@@ -72,6 +73,8 @@ func normalizeRuntimeDriverType(driverType string) string {
return "chroma"
case "qdrantdb", "qdrant-db":
return "qdrant"
case "apache-iotdb", "apache_iotdb", "iotdb":
return "iotdb"
default:
return normalized
}
@@ -119,6 +122,8 @@ func driverDisplayName(driverType string) string {
return "MongoDB"
case "tdengine":
return "TDengine"
case "iotdb":
return "Apache IoTDB"
case "clickhouse":
return "ClickHouse"
case "elasticsearch":

699
internal/db/iotdb_impl.go Normal file
View File

@@ -0,0 +1,699 @@
//go:build gonavi_full_drivers || gonavi_iotdb_driver
package db
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
iotdbclient "github.com/apache/iotdb-client-go/client"
)
const (
defaultIoTDBPort = 6667
defaultIoTDBUser = "root"
defaultIoTDBPassword = "root"
defaultIoTDBQueryTimeout = 30 * time.Second
)
type iotdbDataSet interface {
Next() (bool, error)
Close() error
IsNull(columnName string) (bool, error)
GetObject(columnName string) (interface{}, error)
GetColumnNames() []string
}
type iotdbSessionRunner interface {
Close() error
Query(ctx context.Context, sql string, timeoutMs *int64) (iotdbDataSet, error)
Exec(ctx context.Context, sql string) error
}
type iotdbClientSession struct {
session *iotdbclient.Session
}
func (s *iotdbClientSession) Close() error {
if s == nil || s.session == nil {
return nil
}
return s.session.Close()
}
func (s *iotdbClientSession) Query(ctx context.Context, sql string, timeoutMs *int64) (iotdbDataSet, error) {
if s == nil || s.session == nil {
return nil, fmt.Errorf("连接未打开")
}
return s.session.ExecuteQueryStatement(sql, timeoutMs)
}
func (s *iotdbClientSession) Exec(ctx context.Context, sql string) error {
if s == nil || s.session == nil {
return fmt.Errorf("连接未打开")
}
return s.session.ExecuteNonQueryStatement(sql)
}
var newIoTDBSessionRunner = func(config connection.ConnectionConfig) (iotdbSessionRunner, error) {
params := iotdbConnectionParams(config)
user := strings.TrimSpace(config.User)
if user == "" {
user = defaultIoTDBUser
}
password := config.Password
if password == "" {
password = defaultIoTDBPassword
}
fetchSize := int32(intFromAny(params.Get("fetchSize"), iotdbclient.DefaultFetchSize))
if fetchSize <= 0 {
fetchSize = iotdbclient.DefaultFetchSize
}
timeZone := strings.TrimSpace(firstNonEmpty(params.Get("timeZone"), params.Get("timezone"), params.Get("zoneId")))
if timeZone == "" {
timeZone = iotdbclient.DefaultTimeZone
}
retryMax := intFromAny(firstNonEmpty(params.Get("connectRetryMax"), params.Get("retryMax")), iotdbclient.DefaultConnectRetryMax)
if retryMax <= 0 {
retryMax = iotdbclient.DefaultConnectRetryMax
}
enableCompression := getOrBool(map[string]interface{}{"rpcCompression": params.Get("rpcCompression")}, "rpcCompression")
cfg := &iotdbclient.Config{
Host: strings.TrimSpace(config.Host),
Port: strconv.Itoa(config.Port),
UserName: user,
Password: password,
FetchSize: fetchSize,
TimeZone: timeZone,
ConnectRetryMax: retryMax,
}
session := iotdbclient.NewSession(cfg)
timeoutMs := getConnectTimeout(config).Milliseconds()
if timeoutMs < 0 {
timeoutMs = 0
}
if err := session.Open(enableCompression, int(timeoutMs)); err != nil {
return nil, err
}
return &iotdbClientSession{session: &session}, nil
}
// IoTDBDB implements Database for Apache IoTDB through the official Session API.
type IoTDBDB struct {
session iotdbSessionRunner
forwarder *ssh.LocalForwarder
pingTimeout time.Duration
}
func (i *IoTDBDB) Connect(config connection.ConnectionConfig) error {
if i.forwarder != nil {
_ = i.forwarder.Close()
i.forwarder = nil
}
i.session = nil
runConfig := normalizeIoTDBConfig(config)
if runConfig.UseSSH {
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
i.forwarder = forwarder
host, portText, err := net.SplitHostPort(forwarder.LocalAddr)
if err != nil {
return fmt.Errorf("解析本地转发地址失败:%w", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
return fmt.Errorf("解析本地端口失败:%w", err)
}
runConfig.Host = host
runConfig.Port = port
runConfig.UseSSH = false
logger.Infof("IoTDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
}
session, err := newIoTDBSessionRunner(runConfig)
if err != nil {
_ = i.Close()
return err
}
i.session = session
i.pingTimeout = getConnectTimeout(runConfig)
if err := i.Ping(); err != nil {
_ = i.Close()
return err
}
return nil
}
func (i *IoTDBDB) Close() error {
if i.forwarder != nil {
if err := i.forwarder.Close(); err != nil {
logger.Warnf("关闭 IoTDB SSH 端口转发失败:%v", err)
}
i.forwarder = nil
}
if i.session != nil {
err := i.session.Close()
i.session = nil
return err
}
return nil
}
func (i *IoTDBDB) Ping() error {
if i.session == nil {
return fmt.Errorf("连接未打开")
}
ctx, cancel := context.WithTimeout(context.Background(), i.effectiveTimeout())
defer cancel()
ds, err := i.session.Query(ctx, "SHOW VERSION", nil)
if err != nil {
return err
}
return ds.Close()
}
func (i *IoTDBDB) Query(query string) ([]map[string]interface{}, []string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultIoTDBQueryTimeout)
defer cancel()
return i.QueryContext(ctx, query)
}
func (i *IoTDBDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if i.session == nil {
return nil, nil, fmt.Errorf("连接未打开")
}
text := strings.TrimSpace(query)
if text == "" {
return nil, nil, fmt.Errorf("查询语句不能为空")
}
timeoutMs := int64(i.effectiveTimeout().Milliseconds())
ds, err := i.session.Query(ctx, text, &timeoutMs)
if err != nil {
return nil, nil, err
}
return scanIoTDBDataSet(ds)
}
func (i *IoTDBDB) Exec(query string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultIoTDBQueryTimeout)
defer cancel()
return i.ExecContext(ctx, query)
}
func (i *IoTDBDB) ExecContext(ctx context.Context, query string) (int64, error) {
if i.session == nil {
return 0, fmt.Errorf("连接未打开")
}
text := strings.TrimSpace(query)
if text == "" {
return 0, fmt.Errorf("执行语句不能为空")
}
if err := i.session.Exec(ctx, text); err != nil {
return 0, err
}
return 0, nil
}
func (i *IoTDBDB) GetDatabases() ([]string, error) {
queries := []string{"SHOW DATABASES", "SHOW STORAGE GROUPS", "SHOW STORAGE GROUP"}
var lastErr error
for _, query := range queries {
rows, _, err := i.Query(query)
if err != nil {
lastErr = err
continue
}
names := make([]string, 0, len(rows))
for _, row := range rows {
name := firstRowString(row, "Database", "database", "Storage Group", "storage group", "storage_group", "name")
if name == "" {
name = firstAnyRowString(row)
}
if name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
sort.Strings(names)
return names, nil
}
}
if lastErr != nil {
return nil, lastErr
}
return []string{}, nil
}
func (i *IoTDBDB) GetTables(dbName string) ([]string, error) {
queries := []string{}
if pattern := iotdbDevicePattern(dbName); pattern != "" {
queries = append(queries, "SHOW DEVICES "+pattern)
}
queries = append(queries, "SHOW DEVICES")
var lastErr error
seen := map[string]struct{}{}
tables := []string{}
for _, query := range queries {
rows, _, err := i.Query(query)
if err != nil {
lastErr = err
continue
}
for _, row := range rows {
name := firstRowString(row, "Device", "device", "devices", "Devices", "Path", "path")
if name == "" {
name = firstAnyRowString(row)
}
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
tables = append(tables, name)
}
if len(tables) > 0 {
sort.Strings(tables)
return tables, nil
}
}
if lastErr != nil {
return nil, lastErr
}
return []string{}, nil
}
func (i *IoTDBDB) GetCreateStatement(dbName, tableName string) (string, error) {
device := resolveIoTDBDevicePath(dbName, tableName)
rows, _, err := i.Query("SHOW TIMESERIES " + iotdbTimeseriesPattern(device))
if err != nil {
return "", err
}
statements := make([]string, 0, len(rows))
for _, row := range rows {
path := firstRowString(row, "Timeseries", "timeseries", "Path", "path")
if path == "" {
path = firstAnyRowString(row)
}
if path == "" {
continue
}
dataType := firstRowString(row, "DataType", "dataType", "data_type", "Type", "type")
encoding := firstRowString(row, "Encoding", "encoding")
compression := firstRowString(row, "Compression", "compression", "Compressor", "compressor")
parts := []string{}
if dataType != "" {
parts = append(parts, "DATATYPE="+dataType)
}
if encoding != "" {
parts = append(parts, "ENCODING="+encoding)
}
if compression != "" {
parts = append(parts, "COMPRESSION="+compression)
}
if len(parts) == 0 {
statements = append(statements, "CREATE TIMESERIES "+path+";")
} else {
statements = append(statements, "CREATE TIMESERIES "+path+" WITH "+strings.Join(parts, ", ")+";")
}
}
if len(statements) == 0 {
return "", fmt.Errorf("未找到 IoTDB timeseries%s", device)
}
return strings.Join(statements, "\n"), nil
}
func (i *IoTDBDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
device := resolveIoTDBDevicePath(dbName, tableName)
rows, _, err := i.Query("SHOW TIMESERIES " + iotdbTimeseriesPattern(device))
if err != nil {
return nil, err
}
columns := []connection.ColumnDefinition{{
Name: "Time",
Type: "TIMESTAMP",
Nullable: "NO",
Key: "PRI",
Comment: "IoTDB timestamp column",
}}
for _, row := range rows {
path := firstRowString(row, "Timeseries", "timeseries", "Path", "path")
if path == "" {
path = firstAnyRowString(row)
}
if path == "" {
continue
}
name := strings.TrimPrefix(path, strings.TrimRight(device, ".")+".")
dataType := firstRowString(row, "DataType", "dataType", "data_type", "Type", "type")
encoding := firstRowString(row, "Encoding", "encoding")
compression := firstRowString(row, "Compression", "compression", "Compressor", "compressor")
commentParts := []string{}
if encoding != "" {
commentParts = append(commentParts, "encoding="+encoding)
}
if compression != "" {
commentParts = append(commentParts, "compression="+compression)
}
columns = append(columns, connection.ColumnDefinition{
Name: name,
Type: dataType,
Nullable: "YES",
Comment: strings.Join(commentParts, "; "),
})
}
return columns, nil
}
func (i *IoTDBDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
tables, err := i.GetTables(dbName)
if err != nil {
return nil, err
}
var result []connection.ColumnDefinitionWithTable
for _, table := range tables {
cols, err := i.GetColumns(dbName, table)
if err != nil {
continue
}
for _, col := range cols {
result = append(result, connection.ColumnDefinitionWithTable{
TableName: table,
Name: col.Name,
Type: col.Type,
Comment: col.Comment,
})
}
}
return result, nil
}
func (i *IoTDBDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{{Name: "TIME", ColumnName: "Time", NonUnique: 0, SeqInIndex: 1, IndexType: "TIME"}}, nil
}
func (i *IoTDBDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (i *IoTDBDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func (i *IoTDBDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if i.session == nil {
return fmt.Errorf("连接未打开")
}
if strings.TrimSpace(tableName) == "" {
return fmt.Errorf("设备路径不能为空")
}
if len(changes.Updates) > 0 || len(changes.Deletes) > 0 {
return fmt.Errorf("IoTDB 目标端当前仅支持 INSERT 写入,暂不支持 UPDATE/DELETE 差异同步")
}
for _, row := range changes.Inserts {
sqlText, err := buildIoTDBInsertSQL(tableName, row)
if err != nil {
return err
}
if strings.TrimSpace(sqlText) == "" {
continue
}
if _, err := i.Exec(sqlText); err != nil {
return err
}
}
return nil
}
func normalizeIoTDBConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
runConfig := applyIoTDBURI(config)
if strings.TrimSpace(runConfig.Host) == "" {
runConfig.Host = "localhost"
}
if runConfig.Port <= 0 {
runConfig.Port = defaultIoTDBPort
}
if strings.TrimSpace(runConfig.User) == "" {
runConfig.User = defaultIoTDBUser
}
if runConfig.Password == "" {
runConfig.Password = defaultIoTDBPassword
}
return runConfig
}
func applyIoTDBURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
parsed, err := url.Parse(uriText)
if err != nil {
return config
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if scheme != "iotdb" {
return config
}
if parsed.User != nil {
if strings.TrimSpace(config.User) == "" {
config.User = parsed.User.Username()
}
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
config.Password = pass
}
}
if host := strings.TrimSpace(parsed.Host); host != "" {
if h, port, ok := parseHostPortWithDefault(host, defaultIoTDBPort); ok {
config.Host = h
config.Port = port
}
}
if dbName := strings.Trim(strings.TrimSpace(parsed.Path), "/"); dbName != "" && strings.TrimSpace(config.Database) == "" {
config.Database = dbName
}
return config
}
func iotdbConnectionParams(config connection.ConnectionConfig) url.Values {
params := url.Values{}
mergeConnectionParamValues(params, connectionParamsFromURI(config.URI, "iotdb"))
mergeConnectionParamValues(params, connectionParamsFromText(config.ConnectionParams))
return params
}
func (i *IoTDBDB) effectiveTimeout() time.Duration {
if i.pingTimeout > 0 {
return i.pingTimeout
}
return defaultIoTDBQueryTimeout
}
func scanIoTDBDataSet(ds iotdbDataSet) ([]map[string]interface{}, []string, error) {
if ds == nil {
return nil, nil, nil
}
defer ds.Close()
columns := ds.GetColumnNames()
rows := make([]map[string]interface{}, 0)
for {
hasNext, err := ds.Next()
if err != nil {
return nil, nil, err
}
if !hasNext {
break
}
row := make(map[string]interface{}, len(columns))
for _, column := range columns {
isNull, err := ds.IsNull(column)
if err == nil && isNull {
row[column] = nil
continue
}
value, err := ds.GetObject(column)
if err != nil {
row[column] = nil
continue
}
row[column] = normalizeIoTDBValue(value)
}
rows = append(rows, row)
}
return rows, columns, nil
}
func normalizeIoTDBValue(value interface{}) interface{} {
switch v := value.(type) {
case time.Time:
return v.Format(time.RFC3339Nano)
case *iotdbclient.Binary:
if v == nil {
return nil
}
return v.GetStringValue()
case fmt.Stringer:
return v.String()
default:
return value
}
}
func iotdbDevicePattern(dbName string) string {
db := strings.Trim(strings.TrimSpace(dbName), ".")
if db == "" {
return ""
}
if strings.HasSuffix(db, ".**") || strings.HasSuffix(db, ".*") {
return db
}
return db + ".**"
}
func iotdbTimeseriesPattern(device string) string {
path := strings.Trim(strings.TrimSpace(device), ".")
if path == "" {
return "root.**"
}
if strings.HasSuffix(path, ".**") || strings.HasSuffix(path, ".*") {
return path
}
return path + ".*"
}
func resolveIoTDBDevicePath(dbName, tableName string) string {
table := strings.Trim(strings.TrimSpace(tableName), ".")
if table == "" {
return strings.Trim(strings.TrimSpace(dbName), ".")
}
if strings.HasPrefix(strings.ToLower(table), "root.") || strings.TrimSpace(dbName) == "" {
return table
}
return strings.Trim(strings.TrimSpace(dbName), ".") + "." + table
}
func firstRowString(row map[string]interface{}, keys ...string) string {
for _, key := range keys {
for actual, value := range row {
if strings.EqualFold(actual, key) {
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text != "" && text != "<nil>" {
return text
}
}
}
}
return ""
}
func firstAnyRowString(row map[string]interface{}) string {
keys := make([]string, 0, len(row))
for key := range row {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
text := strings.TrimSpace(fmt.Sprintf("%v", row[key]))
if text != "" && text != "<nil>" {
return text
}
}
return ""
}
func buildIoTDBInsertSQL(device string, row map[string]interface{}) (string, error) {
path := strings.TrimSpace(device)
if path == "" {
return "", fmt.Errorf("设备路径不能为空")
}
timestamp, ok := iotdbTimestampValue(row)
if !ok {
return "", fmt.Errorf("IoTDB INSERT 行缺少 Time/time/timestamp 字段")
}
measurements := make([]string, 0, len(row))
for key := range row {
if strings.TrimSpace(key) == "" || isIoTDBTimestampColumn(key) {
continue
}
measurements = append(measurements, key)
}
if len(measurements) == 0 {
return "", nil
}
sort.Strings(measurements)
columns := append([]string{"timestamp"}, measurements...)
values := []string{iotdbTimestampLiteral(timestamp)}
for _, measurement := range measurements {
values = append(values, iotdbLiteral(row[measurement]))
}
return fmt.Sprintf("INSERT INTO %s(%s) VALUES(%s)", path, strings.Join(columns, ", "), strings.Join(values, ", ")), nil
}
func iotdbTimestampValue(row map[string]interface{}) (interface{}, bool) {
for key, value := range row {
if isIoTDBTimestampColumn(key) {
return value, true
}
}
return nil, false
}
func isIoTDBTimestampColumn(column string) bool {
switch strings.ToLower(strings.TrimSpace(column)) {
case "time", "timestamp", "_time":
return true
default:
return false
}
}
func iotdbTimestampLiteral(value interface{}) string {
switch v := value.(type) {
case time.Time:
return strconv.FormatInt(v.UnixMilli(), 10)
case string:
text := strings.TrimSpace(v)
if _, err := strconv.ParseInt(text, 10, 64); err == nil {
return text
}
return iotdbLiteral(text)
default:
return fmt.Sprintf("%v", value)
}
}
func iotdbLiteral(value interface{}) string {
switch v := value.(type) {
case nil:
return "null"
case bool:
if v {
return "true"
}
return "false"
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprintf("%v", v)
case time.Time:
return strconv.FormatInt(v.UnixMilli(), 10)
default:
text := fmt.Sprintf("%v", v)
return "'" + strings.ReplaceAll(text, "'", "''") + "'"
}
}

View File

@@ -0,0 +1,269 @@
//go:build gonavi_full_drivers || gonavi_iotdb_driver
package db
import (
"context"
"os"
"reflect"
"strconv"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
iotdbclient "github.com/apache/iotdb-client-go/client"
)
type fakeIoTDBSession struct {
queryResults map[string][]map[string]interface{}
execs []string
}
func (f *fakeIoTDBSession) Close() error { return nil }
func (f *fakeIoTDBSession) Query(_ context.Context, sql string, _ *int64) (iotdbDataSet, error) {
rows := f.queryResults[sql]
return &fakeIoTDBDataSet{rows: rows, columns: fakeIoTDBColumns(rows)}, nil
}
func (f *fakeIoTDBSession) Exec(_ context.Context, sql string) error {
f.execs = append(f.execs, sql)
return nil
}
type fakeIoTDBDataSet struct {
rows []map[string]interface{}
columns []string
index int
}
func (f *fakeIoTDBDataSet) Next() (bool, error) {
if f.index >= len(f.rows) {
return false, nil
}
f.index++
return true, nil
}
func (f *fakeIoTDBDataSet) Close() error { return nil }
func (f *fakeIoTDBDataSet) IsNull(columnName string) (bool, error) {
value, ok := f.currentRow()[columnName]
return !ok || value == nil, nil
}
func (f *fakeIoTDBDataSet) GetObject(columnName string) (interface{}, error) {
return f.currentRow()[columnName], nil
}
func (f *fakeIoTDBDataSet) GetColumnNames() []string { return append([]string(nil), f.columns...) }
func (f *fakeIoTDBDataSet) currentRow() map[string]interface{} {
if f.index <= 0 || f.index > len(f.rows) {
return map[string]interface{}{}
}
return f.rows[f.index-1]
}
func fakeIoTDBColumns(rows []map[string]interface{}) []string {
seen := map[string]struct{}{}
columns := []string{}
for _, row := range rows {
for key := range row {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
columns = append(columns, key)
}
}
return columns
}
func TestIoTDBMetadataMapsStorageGroupsDevicesAndTimeseries(t *testing.T) {
session := &fakeIoTDBSession{queryResults: map[string][]map[string]interface{}{
"SHOW DATABASES": {
{"Database": "root.zeta"},
{"Database": "root.sg"},
},
"SHOW DEVICES root.sg.**": {
{"Device": "root.sg.d2"},
{"Device": "root.sg.d1"},
},
"SHOW TIMESERIES root.sg.d1.*": {
{
"Timeseries": "root.sg.d1.temperature",
"DataType": "DOUBLE",
"Encoding": "GORILLA",
"Compression": "SNAPPY",
},
{
"Timeseries": "root.sg.d1.status",
"DataType": "TEXT",
"Encoding": "PLAIN",
"Compression": "SNAPPY",
},
},
}}
client := &IoTDBDB{session: session}
databases, err := client.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases: %v", err)
}
if !reflect.DeepEqual(databases, []string{"root.sg", "root.zeta"}) {
t.Fatalf("unexpected databases: %#v", databases)
}
tables, err := client.GetTables("root.sg")
if err != nil {
t.Fatalf("GetTables: %v", err)
}
if !reflect.DeepEqual(tables, []string{"root.sg.d1", "root.sg.d2"}) {
t.Fatalf("unexpected tables: %#v", tables)
}
columns, err := client.GetColumns("root.sg", "root.sg.d1")
if err != nil {
t.Fatalf("GetColumns: %v", err)
}
if len(columns) != 3 {
t.Fatalf("unexpected columns: %#v", columns)
}
if columns[0].Name != "Time" || columns[0].Type != "TIMESTAMP" || columns[0].Key != "PRI" {
t.Fatalf("unexpected time column: %#v", columns[0])
}
if columns[1].Name != "temperature" || columns[1].Type != "DOUBLE" || !strings.Contains(columns[1].Comment, "encoding=GORILLA") {
t.Fatalf("unexpected measurement column: %#v", columns[1])
}
ddl, err := client.GetCreateStatement("root.sg", "root.sg.d1")
if err != nil {
t.Fatalf("GetCreateStatement: %v", err)
}
if !strings.Contains(ddl, "CREATE TIMESERIES root.sg.d1.temperature WITH DATATYPE=DOUBLE, ENCODING=GORILLA, COMPRESSION=SNAPPY;") {
t.Fatalf("unexpected DDL: %s", ddl)
}
}
func TestIoTDBApplyChangesBuildsInsertAndRejectsMutatingDiffs(t *testing.T) {
session := &fakeIoTDBSession{}
client := &IoTDBDB{session: session}
err := client.ApplyChanges("root.sg.d1", connection.ChangeSet{
Inserts: []map[string]interface{}{
{
"Time": int64(1700000000000),
"temperature": 23.5,
"status": "ok",
"active": true,
},
},
})
if err != nil {
t.Fatalf("ApplyChanges insert: %v", err)
}
expected := "INSERT INTO root.sg.d1(timestamp, active, status, temperature) VALUES(1700000000000, true, 'ok', 23.5)"
if !reflect.DeepEqual(session.execs, []string{expected}) {
t.Fatalf("unexpected execs: %#v", session.execs)
}
err = client.ApplyChanges("root.sg.d1", connection.ChangeSet{
Updates: []connection.UpdateRow{{}},
})
if err == nil || !strings.Contains(err.Error(), "仅支持 INSERT") {
t.Fatalf("expected update rejection, got %v", err)
}
err = client.ApplyChanges("root.sg.d1", connection.ChangeSet{
Deletes: []map[string]interface{}{{"Time": int64(1700000000000)}},
})
if err == nil || !strings.Contains(err.Error(), "仅支持 INSERT") {
t.Fatalf("expected delete rejection, got %v", err)
}
}
func TestIoTDBConfigParsesURIAndConnectionParams(t *testing.T) {
config := normalizeIoTDBConfig(connection.ConnectionConfig{
URI: "iotdb://alice:secret@iotdb.local:16667/root.sg?fetchSize=2048&timeZone=Asia%2FShanghai",
})
if config.Host != "iotdb.local" || config.Port != 16667 || config.User != "alice" || config.Password != "secret" || config.Database != "root.sg" {
t.Fatalf("unexpected config: %#v", config)
}
params := iotdbConnectionParams(connection.ConnectionConfig{
URI: config.URI,
ConnectionParams: "connectRetryMax=3&rpcCompression=true",
})
if params.Get("fetchSize") != "2048" || params.Get("timeZone") != "Asia/Shanghai" || params.Get("connectRetryMax") != "3" || params.Get("rpcCompression") != "true" {
t.Fatalf("unexpected params: %#v", params)
}
}
func TestNormalizeIoTDBValueConvertsBinaryText(t *testing.T) {
if got := normalizeIoTDBValue(iotdbclient.NewBinary([]byte("ok"))); got != "ok" {
t.Fatalf("expected binary text to become string, got %#v", got)
}
}
func TestIoTDBLiveSmoke(t *testing.T) {
addr := strings.TrimSpace(os.Getenv("GONAVI_IOTDB_TEST_ADDR"))
if addr == "" {
t.Skip("set GONAVI_IOTDB_TEST_ADDR=host:port to run live IoTDB smoke test")
}
host, portText, ok := strings.Cut(addr, ":")
if !ok || strings.TrimSpace(host) == "" || strings.TrimSpace(portText) == "" {
t.Fatalf("invalid GONAVI_IOTDB_TEST_ADDR: %q", addr)
}
port, err := strconv.Atoi(strings.TrimSpace(portText))
if err != nil {
t.Fatalf("invalid IoTDB port: %v", err)
}
client := &IoTDBDB{}
if err := client.Connect(connection.ConnectionConfig{
Type: "iotdb",
Host: strings.TrimSpace(host),
Port: port,
User: "root",
Password: "root",
Timeout: 15,
}); err != nil {
t.Fatalf("connect iotdb: %v", err)
}
defer client.Close()
_, _ = client.ExecContext(context.Background(), "DELETE DATABASE root.gonavi_smoke")
_, _ = client.ExecContext(context.Background(), "DROP DATABASE root.gonavi_smoke")
if _, err := client.Exec("CREATE DATABASE root.gonavi_smoke"); err != nil {
t.Fatalf("create database: %v", err)
}
defer func() {
_, _ = client.Exec("DELETE DATABASE root.gonavi_smoke")
_, _ = client.Exec("DROP DATABASE root.gonavi_smoke")
}()
statements := []string{
"CREATE TIMESERIES root.gonavi_smoke.d1.temperature WITH DATATYPE=DOUBLE, ENCODING=GORILLA, COMPRESSION=SNAPPY",
"CREATE TIMESERIES root.gonavi_smoke.d1.status WITH DATATYPE=TEXT, ENCODING=PLAIN, COMPRESSION=SNAPPY",
"INSERT INTO root.gonavi_smoke.d1(timestamp, temperature, status) VALUES(1700000000000, 21.5, 'ok')",
}
for _, stmt := range statements {
if _, err := client.Exec(stmt); err != nil {
t.Fatalf("exec %q: %v", stmt, err)
}
}
rows, columns, err := client.Query("SELECT temperature, status FROM root.gonavi_smoke.d1 LIMIT 10")
if err != nil {
t.Fatalf("query smoke data: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected one row, got rows=%#v columns=%#v", rows, columns)
}
if got := rows[0]["root.gonavi_smoke.d1.status"]; got != "ok" {
t.Fatalf("unexpected status value: %#v rows=%#v columns=%#v", got, rows, columns)
}
}

View File

@@ -105,6 +105,8 @@ func buildSchemaMigrationPlanLegacy(config SyncConfig, tableName string, sourceD
plan.PlannedAction = "使用已有目标表导入"
if targetType == "tdengine" {
plan.Warnings = append(plan.Warnings, "TDengine 目标端当前仅支持 INSERT 写入;若存在差异更新/删除,执行期会被拒绝,请优先使用仅插入或全量覆盖模式")
} else if targetType == "iotdb" {
plan.Warnings = append(plan.Warnings, "IoTDB 目标端当前仅支持 INSERT 写入;若存在差异更新/删除,执行期会被拒绝,请优先使用仅插入模式")
}
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)

View File

@@ -920,6 +920,41 @@ func TestBuildSchemaMigrationPlan_TDengineTargetWarnsInsertOnlyBoundary(t *testi
}
}
func TestBuildSchemaMigrationPlan_IoTDBTargetWarnsInsertOnlyBoundary(t *testing.T) {
t.Parallel()
sourceDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"shop.metrics": {
{Name: "Time", Type: "timestamp", Nullable: "NO", Key: "PRI"},
{Name: "value", Type: "double", Nullable: "YES"},
},
},
}
targetDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"root.sg.metrics": {
{Name: "Time", Type: "timestamp", Nullable: "NO", Key: "PRI"},
{Name: "value", Type: "double", Nullable: "YES"},
},
},
}
cfg := SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
TargetConfig: connection.ConnectionConfig{Type: "iotdb", Database: "root.sg"},
Mode: "insert_update",
}
plan, _, _, err := buildSchemaMigrationPlan(cfg, "metrics", sourceDB, targetDB)
if err != nil {
t.Fatalf("buildSchemaMigrationPlan returned error: %v", err)
}
warnings := strings.Join(plan.Warnings, " ")
if !strings.Contains(warnings, "仅支持 INSERT 写入") {
t.Fatalf("expected IoTDB target warning, got: %v", plan.Warnings)
}
}
func TestBuildMySQLLikeToTDenginePlan_AutoCreateWhenTargetMissing(t *testing.T) {
t.Parallel()

View File

@@ -30,6 +30,7 @@ DRIVERS = [
"iris",
"mongodb",
"tdengine",
"iotdb",
"clickhouse",
"elasticsearch",
]

View File

@@ -7,7 +7,7 @@ cd "$SCRIPT_DIR"
SCRIPT_DIR_WINDOWS="$(pwd -W 2>/dev/null || true)"
SCRIPT_DIR_WINDOWS="${SCRIPT_DIR_WINDOWS//\\//}"
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine iotdb clickhouse elasticsearch)
TARGET_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64)
usage() {
@@ -53,7 +53,7 @@ normalize_driver() {
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|clickhouse)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
@@ -160,6 +160,7 @@ driver_tokens_from_text() {
case "$text" in *iris*) emit_driver_token iris ;; esac
case "$text" in *mongodb*) emit_driver_token mongodb ;; esac
case "$text" in *tdengine*) emit_driver_token tdengine ;; esac
case "$text" in *iotdb*|*apache-iotdb*|*apache_iotdb*) emit_driver_token iotdb ;; esac
case "$text" in *clickhouse*) emit_driver_token clickhouse ;; esac
case "$text" in *elasticsearch*) emit_driver_token elasticsearch ;; esac
@@ -187,6 +188,7 @@ driver_tokens_from_text() {
case "$text" in *github.com/caretdev/go-irisnative*|*third_party/go-irisnative*) emit_driver_token iris ;; esac
case "$text" in *go.mongodb.org/mongo-driver*|*go.mongodb.org/mongo-driver/v2*) emit_driver_token mongodb ;; esac
case "$text" in *github.com/taosdata/driver-go/v3*) emit_driver_token tdengine ;; esac
case "$text" in *github.com/apache/iotdb-client-go*) emit_driver_token iotdb ;; esac
case "$text" in *github.com/clickhouse/clickhouse-go/v2*|*github.com/clickhouse/ch-go*) emit_driver_token clickhouse ;; esac
case "$text" in *github.com/elastic/go-elasticsearch/v8*) emit_driver_token elasticsearch ;; esac
}

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase diros starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
DEFAULT_DRIVERS=(mariadb oceanbase diros starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine iotdb clickhouse elasticsearch)
usage() {
cat <<'EOF'

View File

@@ -7,7 +7,7 @@ cd "$SCRIPT_DIR"
SCRIPT_DIR_WINDOWS="$(pwd -W 2>/dev/null || true)"
SCRIPT_DIR_WINDOWS="${SCRIPT_DIR_WINDOWS//\\//}"
DEFAULT_DRIVERS=(mariadb oceanbase diros starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine clickhouse elasticsearch)
DEFAULT_DRIVERS=(mariadb oceanbase diros starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine iotdb clickhouse elasticsearch)
OUTPUT_FILE="internal/db/driver_agent_revisions_gen.go"
usage() {
@@ -30,7 +30,7 @@ normalize_driver() {
oceanbase) echo "oceanbase" ;;
opengauss|open_gauss|open-gauss) echo "opengauss" ;;
elasticsearch|elastic) echo "elasticsearch" ;;
mariadb|diros|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|clickhouse)
mariadb|diros|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
@@ -134,6 +134,7 @@ iris:internal/db/iris_impl.go|\
mongodb:internal/db/mongodb_impl.go|\
mongodb:internal/db/mongodb_impl_v1.go|\
tdengine:internal/db/tdengine_impl.go|\
iotdb:internal/db/iotdb_impl.go|\
clickhouse:internal/db/clickhouse_impl.go|\
elasticsearch:internal/db/elasticsearch_impl.go|\
elasticsearch:internal/db/elasticsearch_helpers.go)

View File

@@ -70,7 +70,7 @@ normalize_driver() {
doris|diros) echo "diros" ;;
opengauss|open_gauss|open-gauss) echo "opengauss" ;;
elasticsearch|elastic) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|clickhouse)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)