mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 17:31:32 +08:00
Merge pull request #530 from origin/pr-530
This commit is contained in:
@@ -51,6 +51,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
|
||||
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
|
||||
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
|
||||
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
@@ -50,6 +50,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
|
||||
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
|
||||
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
@@ -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)
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss iris mongodb tdengine 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"
|
||||
@@ -42,6 +42,7 @@ normalize_driver() {
|
||||
case "$name" in
|
||||
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)
|
||||
echo "$name"
|
||||
;;
|
||||
|
||||
@@ -1 +1 @@
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
10
frontend/public/db-icons/elasticsearch.svg
Normal file
10
frontend/public/db-icons/elasticsearch.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 0 286 286" width="800px" height="800px" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M14.3443,80.1733 L203.5503,80.1733 C224.4013,80.1733 243.0203,70.6123 255.5133,55.6863 C229.4533,21.8353 188.5523,0.0003 142.5303,0.0003 C86.1783,0.0003 37.4763,32.7113 14.3443,80.1733" fill="#F0BF1A"/>
|
||||
<path d="M187.5152,102.4438 L5.7552,102.4438 C2.0332,115.1648 0.0002,128.6068 0.0002,142.5298 C0.0002,156.4538 2.0332,169.8968 5.7552,182.6168 L187.5152,182.6168 C209.3402,182.6168 227.6022,164.8008 227.6022,142.5298 C227.6022,120.2598 209.7862,102.4438 187.5152,102.4438" fill="#07A5DE"/>
|
||||
<path d="M255.9996,228.7548 C243.5856,214.1638 225.1166,204.8868 204.4406,204.8868 L14.3446,204.8868 C37.4766,252.3498 86.1786,285.0598 142.5296,285.0598 C188.8356,285.0598 229.9656,262.9628 255.9996,228.7548" fill="#3EBEB0"/>
|
||||
<path d="M5.7555,102.4438 C2.0325,115.1648 0.0005,128.6068 0.0005,142.5298 C0.0005,156.4538 2.0325,169.8968 5.7555,182.6168 L124.7135,182.6168 C127.8315,170.5908 129.6125,157.2288 129.6125,142.5298 C129.6125,127.8318 127.8315,114.4698 124.7135,102.4438 L5.7555,102.4438 Z" fill="#231F20"/>
|
||||
<path d="M70.8199,19.1528 C46.7669,33.4058 26.7239,54.7848 14.2529,80.1738 L119.3689,80.1738 C108.6789,55.6758 91.7539,35.1878 70.8199,19.1528" fill="#D7A229"/>
|
||||
<path d="M75.274,268.1347 C95.762,251.6547 112.242,229.8297 122.487,204.8867 L14.253,204.8867 C27.615,231.6117 48.995,253.8817 75.274,268.1347" fill="#019B8F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -284,12 +284,12 @@ const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
sqlserver: ["sqlserver"],
|
||||
iris: ["iris", "intersystems"],
|
||||
redis: ["redis"],
|
||||
elasticsearch: ["http", "https"],
|
||||
tdengine: ["tdengine"],
|
||||
dameng: ["dameng", "dm"],
|
||||
kingbase: ["kingbase"],
|
||||
highgo: ["highgo"],
|
||||
vastbase: ["vastbase"],
|
||||
elasticsearch: ["http", "https"],
|
||||
};
|
||||
|
||||
const sslSupportedTypes = new Set([
|
||||
@@ -311,8 +311,8 @@ const sslSupportedTypes = new Set([
|
||||
"opengauss",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"elasticsearch",
|
||||
"tdengine",
|
||||
"elasticsearch",
|
||||
]);
|
||||
|
||||
const supportsSSLForType = (type: string) =>
|
||||
@@ -357,7 +357,6 @@ const sslClientCertificateSupportedTypes = new Set([
|
||||
"opengauss",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"elasticsearch",
|
||||
]);
|
||||
|
||||
const supportsSSLCAPathForType = (type: string) =>
|
||||
@@ -411,9 +410,9 @@ const supportsConnectionParamsForType = (type: string) =>
|
||||
type === "iris" ||
|
||||
type === "clickhouse" ||
|
||||
type === "mongodb" ||
|
||||
type === "elasticsearch" ||
|
||||
type === "dameng" ||
|
||||
type === "tdengine";
|
||||
type === "tdengine" ||
|
||||
type === "elasticsearch";
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
type: string;
|
||||
@@ -431,6 +430,7 @@ const normalizeDriverType = (value: string): string => {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "postgresql") return "postgres";
|
||||
if (normalized === "elastic") return "elasticsearch";
|
||||
if (normalized === "doris") return "diros";
|
||||
if (
|
||||
normalized === "intersystems" ||
|
||||
@@ -1974,15 +1974,6 @@ const ConnectionModal: React.FC<{
|
||||
parsedValues.useSSL = false;
|
||||
parsedValues.sslMode = "disable";
|
||||
}
|
||||
} else if (type === "elasticsearch") {
|
||||
const isHTTPS = trimmedUri.toLowerCase().startsWith("https://");
|
||||
const skipVerify = normalizeBool(parsed.params.get("skip_verify"));
|
||||
parsedValues.useSSL = isHTTPS;
|
||||
parsedValues.sslMode = isHTTPS
|
||||
? skipVerify
|
||||
? "skip-verify"
|
||||
: "required"
|
||||
: "disable";
|
||||
}
|
||||
}
|
||||
return parsedValues;
|
||||
@@ -2048,9 +2039,6 @@ const ConnectionModal: React.FC<{
|
||||
if (dbType === "redis") {
|
||||
return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster";
|
||||
}
|
||||
if (dbType === "elasticsearch") {
|
||||
return "http://elastic:pass@127.0.0.1:9200/logs-*";
|
||||
}
|
||||
if (dbType === "oracle") {
|
||||
return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1";
|
||||
}
|
||||
@@ -2270,10 +2258,6 @@ const ConnectionModal: React.FC<{
|
||||
? values.useSSL
|
||||
? "https"
|
||||
: "http"
|
||||
: type === "elasticsearch"
|
||||
? values.useSSL
|
||||
? "https"
|
||||
: "http"
|
||||
: type;
|
||||
const dbPath = database ? `/${encodeURIComponent(database)}` : "";
|
||||
const params = new URLSearchParams();
|
||||
@@ -2320,11 +2304,6 @@ const ConnectionModal: React.FC<{
|
||||
if (mode === "skip-verify" || mode === "preferred") {
|
||||
params.set("skip_verify", "true");
|
||||
}
|
||||
} else if (type === "elasticsearch") {
|
||||
if (mode === "skip-verify" || mode === "preferred") {
|
||||
params.set("skip_verify", "true");
|
||||
}
|
||||
appendSSLPathParamsForUri(params, type, values);
|
||||
}
|
||||
} else if (supportsSSLForType(type)) {
|
||||
if (isPostgresCompatibleSSLType(type)) {
|
||||
@@ -3841,13 +3820,7 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
} else if (type !== "custom") {
|
||||
const defaultUser =
|
||||
type === "clickhouse"
|
||||
? "default"
|
||||
: type === "redis"
|
||||
? ""
|
||||
: type === "elasticsearch"
|
||||
? "elastic"
|
||||
: "root";
|
||||
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch") ? "" : "root";
|
||||
const sslCapableType = supportsSSLForType(type);
|
||||
setUseSSL(false);
|
||||
setUseHttpTunnel(false);
|
||||
@@ -4085,7 +4058,7 @@ const ConnectionModal: React.FC<{
|
||||
case "mongodb":
|
||||
return "单机 / 副本集";
|
||||
case "elasticsearch":
|
||||
return "索引 / JSON DSL";
|
||||
return "支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询";
|
||||
case "oceanbase":
|
||||
return "MySQL / Oracle 租户";
|
||||
case "sqlite":
|
||||
@@ -5128,25 +5101,6 @@ const ConnectionModal: React.FC<{
|
||||
),
|
||||
})}
|
||||
|
||||
{dbType === "elasticsearch" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "service",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label="默认索引(可选)"
|
||||
help="留空时 JSON DSL 和 query_string 会默认查询所有可见索引;也可以填写 logs-* 这类索引通配符。"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="例如:logs-*"
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
|
||||
{(dbType === "oracle" || isOceanBaseOracle) &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "service",
|
||||
@@ -5677,13 +5631,13 @@ const ConnectionModal: React.FC<{
|
||||
name="user"
|
||||
label="用户名"
|
||||
rules={
|
||||
dbType === "mongodb"
|
||||
(dbType === "mongodb" || dbType === "elasticsearch")
|
||||
? []
|
||||
: [createUriAwareRequiredRule("请输入用户名")]
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
<Input {...noAutoCapInputProps} placeholder={dbType === "elasticsearch" ? "未开启认证可留空" : undefined} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
@@ -5763,25 +5717,13 @@ const ConnectionModal: React.FC<{
|
||||
children: (
|
||||
<Form.Item
|
||||
name="includeDatabases"
|
||||
label={
|
||||
dbType === "elasticsearch"
|
||||
? "显示索引 (留空显示全部)"
|
||||
: "显示数据库 (留空显示全部)"
|
||||
}
|
||||
help={
|
||||
dbType === "elasticsearch"
|
||||
? "连接测试成功后可选择需要展示的索引"
|
||||
: "连接测试成功后可选择"
|
||||
}
|
||||
label="显示数据库 (留空显示全部)"
|
||||
help="连接测试成功后可选择"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={
|
||||
dbType === "elasticsearch"
|
||||
? "选择显示的索引"
|
||||
: "选择显示的数据库"
|
||||
}
|
||||
placeholder="选择显示的数据库"
|
||||
allowClear
|
||||
>
|
||||
{dbList.map((db) => (
|
||||
|
||||
@@ -35,7 +35,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||
postgres: '#336791',
|
||||
redis: '#DC382D',
|
||||
mongodb: '#47A248',
|
||||
elasticsearch: '#005571',
|
||||
elasticsearch: '#FEC514',
|
||||
jvm: '#1677FF',
|
||||
kingbase: '#1890FF',
|
||||
dameng: '#E6002D',
|
||||
@@ -62,7 +62,7 @@ export const getDbDefaultColor = (type: string): string =>
|
||||
|
||||
const BRAND_SVG_TYPES = new Set([
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
|
||||
'diros', 'sphinx', 'duckdb', 'sqlserver',
|
||||
'diros', 'sphinx', 'duckdb', 'sqlserver', 'elasticsearch',
|
||||
]);
|
||||
|
||||
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
|
||||
@@ -131,9 +131,6 @@ const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="mongodb" size={size} color={color} />
|
||||
);
|
||||
const ElasticsearchIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.elasticsearch} label="ES" />
|
||||
);
|
||||
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="clickhouse" size={size} color={color} />
|
||||
);
|
||||
@@ -184,6 +181,9 @@ const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
|
||||
);
|
||||
const ElasticsearchIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="elasticsearch" size={size} color={color} />
|
||||
);
|
||||
|
||||
/** Custom — 齿轮图标 */
|
||||
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
|
||||
@@ -218,7 +218,6 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
postgres: PostgresIcon,
|
||||
redis: RedisIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
jvm: JVMIcon,
|
||||
kingbase: KingBaseIcon,
|
||||
dameng: DamengIcon,
|
||||
@@ -232,14 +231,15 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
highgo: HighGoIcon,
|
||||
iris: IrisIcon,
|
||||
tdengine: TDengineIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
custom: CustomIcon,
|
||||
};
|
||||
|
||||
/** 可选图标类型列表(用于图标选择器 UI) */
|
||||
export const DB_ICON_TYPES: string[] = [
|
||||
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'elasticsearch', 'jvm',
|
||||
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
|
||||
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'custom',
|
||||
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'elasticsearch', 'custom',
|
||||
];
|
||||
|
||||
/** 该类型是否有品牌 SVG 文件 */
|
||||
@@ -256,12 +256,13 @@ export const getDbIcon = (type: string, color?: string, size?: number): React.Re
|
||||
export const getDbIconLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
mysql: 'MySQL', mariadb: 'MariaDB', oceanbase: 'OceanBase', postgres: 'PostgreSQL',
|
||||
redis: 'Redis', mongodb: 'MongoDB', elasticsearch: 'Elasticsearch', jvm: 'JVM',
|
||||
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
|
||||
oracle: 'Oracle',
|
||||
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
|
||||
starrocks: 'StarRocks',
|
||||
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
||||
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine',
|
||||
elasticsearch: 'Elasticsearch',
|
||||
custom: '自定义',
|
||||
};
|
||||
return labels[type?.toLowerCase()] || type;
|
||||
|
||||
@@ -62,7 +62,7 @@ import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDa
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { splitQualifiedNameLast } from '../utils/qualifiedName';
|
||||
@@ -935,6 +935,7 @@ const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
|
||||
const normalizeDriverType = (value: string): string => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
|
||||
if (normalized === 'elastic') return 'elasticsearch';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
if (
|
||||
normalized === 'open_gauss' ||
|
||||
@@ -3279,17 +3280,25 @@ const Sidebar: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const isNonRelationalDbType = (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';
|
||||
};
|
||||
|
||||
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
|
||||
const { tableName, dbName, id } = node.dataRef;
|
||||
const forceReadOnly = readOnly || isNonRelationalDbType(id);
|
||||
addTab({
|
||||
id: `design-${id}-${dbName}-${tableName}`,
|
||||
title: `${readOnly ? '表结构' : '设计表'} (${tableName})`,
|
||||
title: `${forceReadOnly ? '表结构' : '设计表'} (${tableName})`,
|
||||
type: 'design',
|
||||
connectionId: id,
|
||||
dbName: dbName,
|
||||
tableName: tableName,
|
||||
initialTab: initialTab,
|
||||
readOnly: readOnly
|
||||
readOnly: forceReadOnly
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1469,7 +1469,7 @@ ${selectedTrigger.statement}`;
|
||||
const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType);
|
||||
const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType);
|
||||
const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType);
|
||||
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
|
||||
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb' || dbType === 'elasticsearch';
|
||||
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
|
||||
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
|
||||
|
||||
@@ -3162,9 +3162,13 @@ END;`;
|
||||
>
|
||||
查看语句
|
||||
</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}>删除</Button>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}>删除</Button>
|
||||
</>
|
||||
)}
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||
{selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface BuildIndexCreateSqlResult {
|
||||
severity?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
|
||||
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb' || dbType === 'elasticsearch';
|
||||
|
||||
export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): BuildIndexCreateSqlResult => {
|
||||
const dbType = input.dbType;
|
||||
|
||||
@@ -92,7 +92,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', 'elasticsearch']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', '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']);
|
||||
|
||||
6
go.mod
6
go.mod
@@ -29,8 +29,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
|
||||
github.com/elastic/go-elasticsearch/v8 v8.19.6 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
)
|
||||
|
||||
|
||||
13
go.sum
13
go.sum
@@ -65,10 +65,19 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk=
|
||||
github.com/elastic/go-elasticsearch/v8 v8.19.6/go.mod h1:jeWebApE1oFEW/hKZqx/IRYmP/aa2+WMJkOfk+AduSI=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
@@ -276,8 +285,12 @@ go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
|
||||
@@ -48,6 +48,13 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
}
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
|
||||
// Elasticsearch:索引名可能含多个点(如 iot_pro_biz_operate_log.index.20240626),
|
||||
// 不能按点分割,直接返回原始数据库名和完整表名。
|
||||
if dbType == "elasticsearch" {
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
if dbType == "sqlserver" {
|
||||
// SQL Server 的 DB 接口约定:第一个参数是数据库名,schema 由 tableName(如 dbo.users) 自行解析。
|
||||
// 不能把 schema(dbo) 传到第一个参数,否则会拼出 dbo.sys.columns 等无效对象名。
|
||||
|
||||
@@ -290,6 +290,11 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
// Elasticsearch:索引名可能含多个点,不能按点分割
|
||||
if dbType == "elasticsearch" {
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
if dbType == "kingbase" {
|
||||
schema, table := db.SplitKingbaseQualifiedName(rawTable)
|
||||
if schema != "" && table != "" {
|
||||
|
||||
@@ -21,6 +21,6 @@ func init() {
|
||||
"mongodb": "src-57fdd8bfebdcd46e",
|
||||
"tdengine": "src-939715f94df1ec9c",
|
||||
"clickhouse": "src-482d62ed565b3e69",
|
||||
"elasticsearch": "src-62e8aa80212bd2e4",
|
||||
"elasticsearch": "src-2fb00b94d7067c56",
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,14 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
|
||||
"github.com/elastic/go-elasticsearch/v8"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,7 +29,7 @@ const (
|
||||
|
||||
// ElasticsearchDB 实现 Database 接口,提供 Elasticsearch 数据源连接能力。
|
||||
type ElasticsearchDB struct {
|
||||
client *esRESTClient
|
||||
client *elasticsearch.Client
|
||||
database string // 默认索引名
|
||||
pingTimeout time.Duration
|
||||
forwarder *ssh.LocalForwarder
|
||||
@@ -85,7 +88,7 @@ func (e *ElasticsearchDB) Connect(config connection.ConnectionConfig) error {
|
||||
idx+1, len(attempts), sslLabel, attempt.Host, attempt.Port)
|
||||
|
||||
esCfg := buildESClientConfig(attempt)
|
||||
client, err := newESRESTClient(esCfg)
|
||||
client, err := elasticsearch.NewClient(esCfg)
|
||||
if err != nil {
|
||||
logger.Warnf("Elasticsearch 创建客户端失败:%d/%d 模式=%s 错误=%v", idx+1, len(attempts), sslLabel, err)
|
||||
lastErr = err
|
||||
@@ -113,7 +116,7 @@ func (e *ElasticsearchDB) Connect(config connection.ConnectionConfig) error {
|
||||
return fmt.Errorf("Elasticsearch 连接失败:无可用连接方案")
|
||||
}
|
||||
|
||||
// Close 关闭 Elasticsearch 连接。
|
||||
// Close 关闭 Elasticsearch 连接并释放底层资源。
|
||||
func (e *ElasticsearchDB) Close() error {
|
||||
if e.forwarder != nil {
|
||||
if err := e.forwarder.Close(); err != nil {
|
||||
@@ -121,7 +124,14 @@ func (e *ElasticsearchDB) Close() error {
|
||||
}
|
||||
e.forwarder = nil
|
||||
}
|
||||
e.client = nil
|
||||
if e.client != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := e.client.Close(ctx); err != nil {
|
||||
logger.Warnf("关闭 Elasticsearch 客户端失败:%v", err)
|
||||
}
|
||||
e.client = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,20 +147,32 @@ func (e *ElasticsearchDB) Ping() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := e.client.do(ctx, http.MethodHead, "/", nil, nil)
|
||||
res, err := e.client.Ping(e.client.Ping.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if esResponseIsError(res) {
|
||||
return fmt.Errorf("Elasticsearch Ping 失败:%s", esResponseStatus(res))
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("Elasticsearch Ping 失败:%s", res.Status())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query 执行 Elasticsearch 查询,支持 JSON DSL 和 query_string 两种模式。
|
||||
func (e *ElasticsearchDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultEsQueryTimeout)
|
||||
defer cancel()
|
||||
return e.queryWithContext(ctx, query)
|
||||
}
|
||||
|
||||
// QueryContext 带上下文执行 Elasticsearch 查询,支持外部超时控制。
|
||||
func (e *ElasticsearchDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
return e.queryWithContext(ctx, query)
|
||||
}
|
||||
|
||||
// queryWithContext 查询的核心实现,被 Query 和 QueryContext 共用。
|
||||
func (e *ElasticsearchDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if e.client == nil {
|
||||
return nil, nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
@@ -160,21 +182,147 @@ func (e *ElasticsearchDB) Query(query string) ([]map[string]interface{}, []strin
|
||||
return nil, nil, fmt.Errorf("查询语句不能为空")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultEsQueryTimeout)
|
||||
defer cancel()
|
||||
// Elasticsearch 不支持 information_schema / pg_catalog 等关系型元数据查询。
|
||||
// 前端会为视图、函数、触发器等功能自动生成这些查询,直接返回空结果避免报错。
|
||||
if isESMetadataQuery(query) {
|
||||
return []map[string]interface{}{}, []string{}, nil
|
||||
}
|
||||
|
||||
// 优先尝试 DevTools 风格解析
|
||||
if req, ok := parseESConsoleRequest(query); ok {
|
||||
return e.esQueryConsole(ctx, req)
|
||||
}
|
||||
|
||||
// JSON DSL(以 { 开头)
|
||||
if isJSONDSL(query) {
|
||||
return e.esQueryWithDSL(ctx, query)
|
||||
}
|
||||
|
||||
// query_string
|
||||
return e.esQueryWithString(ctx, query)
|
||||
}
|
||||
|
||||
// validateESConsolePath 校验 DevTools 风格请求的路径和方法是否安全。
|
||||
// 使用规范化路径匹配,而非子字符串匹配。
|
||||
func validateESConsolePath(method, rawPath string) error {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
cleanPath := "/" + strings.TrimPrefix(strings.TrimSpace(rawPath), "/")
|
||||
|
||||
// 拒绝写入端点
|
||||
for _, blocked := range []string{"/_bulk", "/_delete_by_query", "/_update_by_query"} {
|
||||
if cleanPath == blocked || strings.HasSuffix(cleanPath, blocked) {
|
||||
return fmt.Errorf("Elasticsearch DevTools 查询拒绝:不支持的写入端点 %s", rawPath)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
// _search: GET / POST
|
||||
case cleanPath == "/_search" || strings.HasSuffix(cleanPath, "/_search"):
|
||||
if method != "GET" && method != "POST" {
|
||||
return fmt.Errorf("Elasticsearch _search 端点仅支持 GET/POST")
|
||||
}
|
||||
return nil
|
||||
|
||||
// _mapping / _settings: 仅 GET
|
||||
case cleanPath == "/_mapping" || strings.HasSuffix(cleanPath, "/_mapping"):
|
||||
return requireESMethod(method, "GET")
|
||||
case cleanPath == "/_settings" || strings.HasSuffix(cleanPath, "/_settings"):
|
||||
return requireESMethod(method, "GET")
|
||||
|
||||
// _cluster/health: 仅 GET
|
||||
case cleanPath == "/_cluster/health":
|
||||
return requireESMethod(method, "GET")
|
||||
|
||||
// _resolve/index: 仅 GET(支持 /_resolve/index 和 /_resolve/index/*)
|
||||
case cleanPath == "/_resolve/index" || strings.HasPrefix(cleanPath, "/_resolve/index/"):
|
||||
return requireESMethod(method, "GET")
|
||||
|
||||
default:
|
||||
return fmt.Errorf("Elasticsearch DevTools 查询拒绝:不支持的端点 %s(仅允许 _search/_mapping/_settings/_cluster/health/_resolve/index)", rawPath)
|
||||
}
|
||||
}
|
||||
|
||||
// requireESMethod 检查方法是否在允许列表中。
|
||||
func requireESMethod(method string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if method == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Elasticsearch 端点不支持 %s 方法,仅允许 %s", method, strings.Join(allowed, "/"))
|
||||
}
|
||||
|
||||
// esQueryConsole 执行 Kibana DevTools 风格查询。
|
||||
// 使用低层 Perform 方法发送原始 HTTP 请求。
|
||||
func (e *ElasticsearchDB) esQueryConsole(ctx context.Context, req esConsoleRequest) ([]map[string]interface{}, []string, error) {
|
||||
if err := validateESConsolePath(req.Method, req.Path); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 构建 HTTP 请求
|
||||
var bodyReader *bytes.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = bytes.NewReader([]byte(req.Body))
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte{})
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.Path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("构造 DevTools 请求失败:%w", err)
|
||||
}
|
||||
if req.Body != "" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
httpRes, err := e.client.Perform(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Elasticsearch DevTools 请求失败:%w", err)
|
||||
}
|
||||
defer httpRes.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(httpRes.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取 DevTools 响应失败:%w", err)
|
||||
}
|
||||
|
||||
if httpRes.StatusCode >= 400 {
|
||||
return nil, nil, fmt.Errorf("Elasticsearch DevTools 查询错误:%s", string(body))
|
||||
}
|
||||
|
||||
// _search 端点使用标准响应解析
|
||||
if strings.Contains(req.Path, "/_search") {
|
||||
return e.parseConsoleSearchResponse(body)
|
||||
}
|
||||
|
||||
// 其他端点返回原始 JSON 作为单行结果
|
||||
var pretty map[string]interface{}
|
||||
if err := json.Unmarshal(body, &pretty); err != nil {
|
||||
// 非 JSON 响应,返回纯文本
|
||||
return []map[string]interface{}{{"result": string(body)}}, []string{"result"}, nil
|
||||
}
|
||||
formatted, _ := json.MarshalIndent(pretty, "", " ")
|
||||
return []map[string]interface{}{{"result": string(formatted)}}, []string{"result"}, nil
|
||||
}
|
||||
|
||||
// parseConsoleSearchResponse 解析 DevTools _search 响应。
|
||||
func (e *ElasticsearchDB) parseConsoleSearchResponse(body []byte) ([]map[string]interface{}, []string, error) {
|
||||
return parseSearchResponseJSON(body)
|
||||
}
|
||||
|
||||
// Exec 不支持 Elasticsearch 非查询语句执行。
|
||||
func (e *ElasticsearchDB) Exec(query string) (int64, error) {
|
||||
return 0, fmt.Errorf("Elasticsearch 不支持执行非查询语句")
|
||||
}
|
||||
|
||||
// GetDatabases 列出所有 Elasticsearch 索引(排除隐藏索引)。
|
||||
// ExecContext 带上下文的 Exec,ES 不支持非查询语句执行。
|
||||
func (e *ElasticsearchDB) ExecContext(_ context.Context, _ string) (int64, error) {
|
||||
return 0, fmt.Errorf("Elasticsearch 不支持执行非查询语句")
|
||||
}
|
||||
|
||||
// GetDatabases 列出所有 Elasticsearch 索引。
|
||||
func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
|
||||
if e.client == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
@@ -183,38 +331,39 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("format", "json")
|
||||
query.Set("h", "index")
|
||||
res, err := e.client.do(ctx, http.MethodGet, "/_cat/indices", query, nil)
|
||||
res, err := e.client.Indices.Get(
|
||||
[]string{"*"},
|
||||
e.client.Indices.Get.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引列表失败:%w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if esResponseIsError(res) {
|
||||
return nil, fmt.Errorf("获取索引列表失败:%s", esResponseStatus(res))
|
||||
if res.IsError() {
|
||||
return nil, fmt.Errorf("获取索引列表失败:%s", res.Status())
|
||||
}
|
||||
|
||||
var indices []struct {
|
||||
Index string `json:"index"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&indices); err != nil {
|
||||
var indexMap map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&indexMap); err != nil {
|
||||
return nil, fmt.Errorf("解析索引列表失败:%w", err)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(indices))
|
||||
for _, idx := range indices {
|
||||
name := strings.TrimSpace(idx.Index)
|
||||
if name != "" && !isHiddenIndex(name) {
|
||||
result := make([]string, 0, len(indexMap))
|
||||
for name := range indexMap {
|
||||
if name := strings.TrimSpace(name); name != "" {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTables 对 ES 而言索引即表,返回索引自身名称。
|
||||
// GetTables 对 ES 而言索引即表,返回索引自身名称及别名。
|
||||
func (e *ElasticsearchDB) GetTables(dbName string) ([]string, error) {
|
||||
if e.client == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(dbName)
|
||||
if target == "" {
|
||||
target = e.database
|
||||
@@ -222,7 +371,11 @@ func (e *ElasticsearchDB) GetTables(dbName string) ([]string, error) {
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("未指定索引名")
|
||||
}
|
||||
return []string{target}, nil
|
||||
|
||||
tables := []string{target}
|
||||
aliases := e.esFetchIndexAliases(target)
|
||||
tables = append(tables, aliases...)
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// GetCreateStatement 返回索引的 settings + mappings 组合 JSON。
|
||||
@@ -239,14 +392,17 @@ func (e *ElasticsearchDB) GetCreateStatement(dbName, tableName string) (string,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := e.client.do(ctx, http.MethodGet, "/"+esPathSegment(indexName), nil, nil)
|
||||
res, err := e.client.Indices.Get(
|
||||
[]string{indexName},
|
||||
e.client.Indices.Get.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取索引定义失败:%w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if esResponseIsError(res) {
|
||||
return "", fmt.Errorf("获取索引定义失败:%s", esResponseStatus(res))
|
||||
if res.IsError() {
|
||||
return "", fmt.Errorf("获取索引定义失败:%s", res.Status())
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
@@ -259,11 +415,15 @@ func (e *ElasticsearchDB) GetCreateStatement(dbName, tableName string) (string,
|
||||
return string(body), nil
|
||||
}
|
||||
formatted, _ := json.MarshalIndent(pretty, "", " ")
|
||||
return string(formatted), nil
|
||||
return fmt.Sprintf("// Elasticsearch index: %s\n%s", indexName, string(formatted)), nil
|
||||
}
|
||||
|
||||
// GetColumns 返回索引的 mapping 字段定义。
|
||||
func (e *ElasticsearchDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
if e.client == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
indexName := resolveEsIndexName(dbName, tableName, e.database)
|
||||
if indexName == "" {
|
||||
return nil, fmt.Errorf("未指定索引名")
|
||||
@@ -278,6 +438,10 @@ func (e *ElasticsearchDB) GetColumns(dbName, tableName string) ([]connection.Col
|
||||
|
||||
// GetAllColumns 返回索引的全部字段定义(带表名标识)。
|
||||
func (e *ElasticsearchDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
if e.client == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(dbName)
|
||||
if target == "" {
|
||||
target = e.database
|
||||
@@ -304,7 +468,7 @@ func (e *ElasticsearchDB) GetAllColumns(dbName string) ([]connection.ColumnDefin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetIndexes 返回索引的统计信息。
|
||||
// GetIndexes 返回索引的 settings 中定义的分片与副本信息。
|
||||
func (e *ElasticsearchDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
if e.client == nil {
|
||||
return nil, fmt.Errorf("连接未打开")
|
||||
@@ -318,35 +482,63 @@ func (e *ElasticsearchDB) GetIndexes(dbName, tableName string) ([]connection.Ind
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("format", "json")
|
||||
query.Set("h", "index,health,status,docs.count,store.size")
|
||||
res, err := e.client.do(ctx, http.MethodGet, "/_cat/indices/"+esPathSegment(indexName), query, nil)
|
||||
res, err := e.client.Indices.GetSettings(
|
||||
e.client.Indices.GetSettings.WithContext(ctx),
|
||||
e.client.Indices.GetSettings.WithIndex(indexName),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引信息失败:%w", err)
|
||||
return nil, fmt.Errorf("获取索引设置失败:%w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if esResponseIsError(res) {
|
||||
return nil, fmt.Errorf("获取索引信息失败:%s", esResponseStatus(res))
|
||||
if res.IsError() {
|
||||
return nil, fmt.Errorf("获取索引设置失败:%s", res.Status())
|
||||
}
|
||||
|
||||
var info []esIndexInfo
|
||||
if err := json.NewDecoder(res.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("解析索引信息失败:%w", err)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取索引设置失败:%w", err)
|
||||
}
|
||||
|
||||
result := make([]connection.IndexDefinition, 0, len(info))
|
||||
for _, idx := range info {
|
||||
result = append(result, connection.IndexDefinition{
|
||||
Name: idx.Index,
|
||||
ColumnName: fmt.Sprintf("health=%s status=%s docs=%s size=%s", idx.Health, idx.Status, idx.DocsCount, idx.StoreSize),
|
||||
var settings map[string]map[string]interface{}
|
||||
if err := json.Unmarshal(body, &settings); err != nil {
|
||||
return nil, fmt.Errorf("解析索引设置失败:%w", err)
|
||||
}
|
||||
|
||||
var indexes []connection.IndexDefinition
|
||||
|
||||
// ES 无传统主键概念,_id 字段是每条文档的唯一标识,等效于主键。
|
||||
// 返回 _id 作为 "PRIMARY" 索引,使前端识别到唯一标识并解除只读模式。
|
||||
indexes = append(indexes, connection.IndexDefinition{
|
||||
Name: "PRIMARY",
|
||||
ColumnName: "_id",
|
||||
NonUnique: 0,
|
||||
SeqInIndex: 1,
|
||||
IndexType: "PRIMARY",
|
||||
})
|
||||
|
||||
for name, data := range settings {
|
||||
idxSettings, _ := data["settings"].(map[string]interface{})
|
||||
indexSection, _ := idxSettings["index"].(map[string]interface{})
|
||||
|
||||
shards := "1"
|
||||
replicas := "1"
|
||||
if s, ok := indexSection["number_of_shards"].(string); ok {
|
||||
shards = s
|
||||
}
|
||||
if r, ok := indexSection["number_of_replicas"].(string); ok {
|
||||
replicas = r
|
||||
}
|
||||
|
||||
indexes = append(indexes, connection.IndexDefinition{
|
||||
Name: name,
|
||||
ColumnName: fmt.Sprintf("shards=%s replicas=%s", shards, replicas),
|
||||
NonUnique: 0,
|
||||
SeqInIndex: 1,
|
||||
IndexType: "INDEX",
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
// GetForeignKeys ES 不支持外键,返回空列表。
|
||||
@@ -358,3 +550,220 @@ func (e *ElasticsearchDB) GetForeignKeys(dbName, tableName string) ([]connection
|
||||
func (e *ElasticsearchDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
}
|
||||
|
||||
// esBulkActionMeta 构建 ES _bulk API 的 action 行元数据。
|
||||
// ES 6.x 需要 _type 字段,ES 7.x+ 已废弃。
|
||||
func (e *ElasticsearchDB) esBulkActionMeta(action, indexName string, docID string) map[string]interface{} {
|
||||
meta := map[string]interface{}{
|
||||
"_index": indexName,
|
||||
"_type": "_doc",
|
||||
}
|
||||
if docID != "" {
|
||||
meta["_id"] = docID
|
||||
}
|
||||
return map[string]interface{}{action: meta}
|
||||
}
|
||||
|
||||
// resolveWriteIndex 解析别名对应的实际可写索引名。
|
||||
// 如果 indexOrAlias 是直接索引名,原样返回。
|
||||
// 如果是别名,返回该别名下最新的索引名(按名称倒序)。
|
||||
func (e *ElasticsearchDB) resolveWriteIndex(indexOrAlias string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := e.client.Indices.GetAlias(
|
||||
e.client.Indices.GetAlias.WithContext(ctx),
|
||||
e.client.Indices.GetAlias.WithIndex(indexOrAlias),
|
||||
)
|
||||
if err != nil {
|
||||
return indexOrAlias, nil // 网络错误时回退到原名
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
// 404 表示不是别名而是直接索引名
|
||||
return indexOrAlias, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return indexOrAlias, nil
|
||||
}
|
||||
|
||||
var aliasMap map[string]interface{}
|
||||
if err := json.Unmarshal(body, &aliasMap); err != nil {
|
||||
return indexOrAlias, nil
|
||||
}
|
||||
|
||||
// aliasMap 的 key 是实际索引名,如果没有 key 或只有一个,直接用
|
||||
var indices []string
|
||||
for name := range aliasMap {
|
||||
indices = append(indices, name)
|
||||
}
|
||||
|
||||
if len(indices) == 0 {
|
||||
return indexOrAlias, nil
|
||||
}
|
||||
if len(indices) == 1 {
|
||||
return indices[0], nil
|
||||
}
|
||||
|
||||
// 多个索引对应同一别名时,取名称最新的(ES 通常用日期后缀,倒序取第一个)
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(indices)))
|
||||
return indices[0], nil
|
||||
}
|
||||
|
||||
// isESMetaField 判断字段名是否为 ES 元字段(不应写入文档 _source)。
|
||||
func isESMetaField(name string) bool {
|
||||
switch strings.TrimSpace(name) {
|
||||
case "_id", "_index", "_type", "_score", "_source", "_routing", "_version", "_seq_no", "_primary_term", "_aggregations":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyChanges 实现 BatchApplier 接口,通过 ES _bulk API 批量提交增删改。
|
||||
func (e *ElasticsearchDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if e.client == nil {
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
indexName := resolveEsIndexName(tableName, "", e.database)
|
||||
if indexName == "" {
|
||||
return fmt.Errorf("未指定索引名")
|
||||
}
|
||||
|
||||
var bulkBody bytes.Buffer
|
||||
|
||||
// 如果目标是别名(非直接索引),解析出实际的可写索引名。
|
||||
writeIndexName := indexName
|
||||
if resolved, err := e.resolveWriteIndex(indexName); err == nil && resolved != "" {
|
||||
writeIndexName = resolved
|
||||
}
|
||||
|
||||
// resolveWriteIndex 确定写操作的目标索引。
|
||||
// 如果文档数据中包含 _index(来自查询结果),使用实际索引名而非别名。
|
||||
resolveWriteIndex := func(vals map[string]interface{}) string {
|
||||
if idx, ok := vals["_index"]; ok {
|
||||
if idxStr := strings.TrimSpace(fmt.Sprintf("%v", idx)); idxStr != "" {
|
||||
return idxStr
|
||||
}
|
||||
}
|
||||
return writeIndexName
|
||||
}
|
||||
|
||||
// 删除操作
|
||||
for _, pk := range changes.Deletes {
|
||||
idVal, ok := pk["_id"]
|
||||
if !ok {
|
||||
return fmt.Errorf("删除操作缺少 _id")
|
||||
}
|
||||
writeIdx := resolveWriteIndex(pk)
|
||||
actionJSON, _ := json.Marshal(e.esBulkActionMeta("delete", writeIdx, fmt.Sprintf("%v", idVal)))
|
||||
bulkBody.Write(actionJSON)
|
||||
bulkBody.WriteByte('\n')
|
||||
}
|
||||
|
||||
// 更新操作
|
||||
for _, update := range changes.Updates {
|
||||
idVal, ok := update.Keys["_id"]
|
||||
if !ok {
|
||||
return fmt.Errorf("更新操作缺少 _id")
|
||||
}
|
||||
writeIdx := resolveWriteIndex(update.Values)
|
||||
actionJSON, _ := json.Marshal(e.esBulkActionMeta("update", writeIdx, fmt.Sprintf("%v", idVal)))
|
||||
bulkBody.Write(actionJSON)
|
||||
bulkBody.WriteByte('\n')
|
||||
|
||||
// 过滤 ES 元字段,只保留实际文档字段
|
||||
doc := make(map[string]interface{}, len(update.Values))
|
||||
for k, v := range update.Values {
|
||||
if !isESMetaField(k) {
|
||||
doc[k] = v
|
||||
}
|
||||
}
|
||||
wrapper := map[string]interface{}{"doc": doc}
|
||||
docJSON, _ := json.Marshal(wrapper)
|
||||
bulkBody.Write(docJSON)
|
||||
bulkBody.WriteByte('\n')
|
||||
}
|
||||
|
||||
// 新增操作
|
||||
for _, insert := range changes.Inserts {
|
||||
var docID string
|
||||
if id, ok := insert["_id"]; ok {
|
||||
docID = fmt.Sprintf("%v", id)
|
||||
}
|
||||
|
||||
// 从文档中移除 _id 和其他 ES 元字段
|
||||
doc := make(map[string]interface{}, len(insert))
|
||||
for k, v := range insert {
|
||||
if !isESMetaField(k) {
|
||||
doc[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
writeIdx := resolveWriteIndex(insert)
|
||||
actionJSON, _ := json.Marshal(e.esBulkActionMeta("index", writeIdx, docID))
|
||||
bulkBody.Write(actionJSON)
|
||||
bulkBody.WriteByte('\n')
|
||||
docJSON, _ := json.Marshal(doc)
|
||||
bulkBody.Write(docJSON)
|
||||
bulkBody.WriteByte('\n')
|
||||
}
|
||||
|
||||
if bulkBody.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := e.client.Bulk(
|
||||
bytes.NewReader(bulkBody.Bytes()),
|
||||
e.client.Bulk.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ES 批量操作失败:%w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 ES 批量操作响应失败:%w", err)
|
||||
}
|
||||
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("ES 批量操作错误:%s", string(body))
|
||||
}
|
||||
|
||||
// 检查是否有单条操作失败
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err == nil {
|
||||
if hasErrors, ok := result["errors"].(bool); ok && hasErrors {
|
||||
if items, ok := result["items"].([]interface{}); ok {
|
||||
for _, item := range items {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, op := range itemMap {
|
||||
opMap, ok := op.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if errMap, ok := opMap["error"].(map[string]interface{}); ok {
|
||||
reason, _ := errMap["reason"].(string)
|
||||
return fmt.Errorf("ES 批量操作部分失败:%s", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ES 批量操作部分失败")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("ES 批量操作完成:索引=%s 删除=%d 更新=%d 新增=%d",
|
||||
indexName, len(changes.Deletes), len(changes.Updates), len(changes.Inserts))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
|
||||
"github.com/elastic/go-elasticsearch/v8"
|
||||
)
|
||||
|
||||
// ---- 测试辅助函数 ----
|
||||
@@ -27,21 +29,19 @@ func newMockESServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
// newTestESClient 创建连接到测试服务器的 ES REST 客户端。
|
||||
func newTestESClient(t *testing.T, serverURL string) *esRESTClient {
|
||||
t.Helper()
|
||||
client, err := newESRESTClient(esHTTPClientConfig{BaseURL: serverURL})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试 ES 客户端失败: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// newTestESDB 创建连接到测试服务器的 ElasticsearchDB 实例。
|
||||
func newTestESDB(t *testing.T, serverURL, defaultIndex string) *ElasticsearchDB {
|
||||
t.Helper()
|
||||
cfg := elasticsearch.Config{
|
||||
Addresses: []string{serverURL},
|
||||
Transport: &esProductCheckBypassTransport{inner: http.DefaultTransport},
|
||||
}
|
||||
client, err := elasticsearch.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试 ES 客户端失败: %v", err)
|
||||
}
|
||||
return &ElasticsearchDB{
|
||||
client: newTestESClient(t, serverURL),
|
||||
client: client,
|
||||
database: defaultIndex,
|
||||
}
|
||||
}
|
||||
@@ -103,17 +103,17 @@ func TestElasticsearchPing(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchGetDatabases 测试获取索引列表,验证隐藏索引过滤。
|
||||
// TestElasticsearchGetDatabases 测试获取索引列表。
|
||||
func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
t.Run("正常获取并过滤隐藏索引", func(t *testing.T) {
|
||||
t.Run("正常获取全部索引", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/_cat/indices") && r.Method == http.MethodGet {
|
||||
writeJSON(w, []map[string]string{
|
||||
{"index": "logs-2024"},
|
||||
{"index": "users"},
|
||||
{"index": ".security"},
|
||||
{"index": ".kibana_1"},
|
||||
{"index": "products"},
|
||||
if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/*") && !strings.Contains(r.URL.Path, "_") {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"logs-2024": map[string]interface{}{},
|
||||
"users": map[string]interface{}{},
|
||||
".security": map[string]interface{}{},
|
||||
".kibana_1": map[string]interface{}{},
|
||||
"products": map[string]interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
}
|
||||
|
||||
slices.Sort(databases)
|
||||
expected := []string{"logs-2024", "products", "users"}
|
||||
expected := []string{".kibana_1", ".security", "logs-2024", "products", "users"}
|
||||
if len(databases) != len(expected) {
|
||||
t.Fatalf("期望 %d 个索引,实际 %d:%v", len(expected), len(databases), databases)
|
||||
}
|
||||
@@ -147,21 +147,53 @@ func TestElasticsearchGetDatabases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchGetTables 测试 GetTables 返回索引名。
|
||||
// TestElasticsearchGetTables 测试 GetTables 返回索引名及别名。
|
||||
func TestElasticsearchGetTables(t *testing.T) {
|
||||
t.Run("指定索引名", func(t *testing.T) {
|
||||
db := &ElasticsearchDB{database: "default-index"}
|
||||
t.Run("指定索引名并返回别名", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/_alias") {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"my-index": map[string]interface{}{
|
||||
"aliases": map[string]interface{}{
|
||||
"my-alias": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "default-index")
|
||||
tables, err := db.GetTables("my-index")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTables 失败:%v", err)
|
||||
}
|
||||
if len(tables) != 1 || tables[0] != "my-index" {
|
||||
t.Fatalf("期望 [my-index],实际:%v", tables)
|
||||
if len(tables) < 1 || tables[0] != "my-index" {
|
||||
t.Fatalf("期望第一个为 my-index,实际:%v", tables)
|
||||
}
|
||||
// 应包含别名
|
||||
found := false
|
||||
for _, tbl := range tables {
|
||||
if tbl == "my-alias" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("期望包含别名 my-alias,实际:%v", tables)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("回退到默认索引", func(t *testing.T) {
|
||||
db := &ElasticsearchDB{database: "default-index"}
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/_alias") {
|
||||
writeJSON(w, map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "default-index")
|
||||
tables, err := db.GetTables("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTables 失败:%v", err)
|
||||
@@ -172,12 +204,23 @@ func TestElasticsearchGetTables(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("无索引名时报错", func(t *testing.T) {
|
||||
db := &ElasticsearchDB{}
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
db := newTestESDB(t, server.URL, "")
|
||||
_, err := db.GetTables("")
|
||||
if err == nil || !strings.Contains(err.Error(), "未指定索引名") {
|
||||
t.Fatalf("期望 '未指定索引名' 错误,实际:%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("连接未打开时返回错误", func(t *testing.T) {
|
||||
db := &ElasticsearchDB{database: "test"}
|
||||
_, err := db.GetTables("test")
|
||||
if err == nil || !strings.Contains(err.Error(), "连接未打开") {
|
||||
t.Fatalf("期望 '连接未打开' 错误,实际:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchGetColumns 测试从 mapping 中提取字段定义。
|
||||
@@ -538,17 +581,18 @@ func TestElasticsearchGetCreateStatement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestElasticsearchGetIndexes 测试获取索引统计信息。
|
||||
// TestElasticsearchGetIndexes 测试获取索引 settings 信息。
|
||||
func TestElasticsearchGetIndexes(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/_cat/indices") && r.Method == http.MethodGet {
|
||||
writeJSON(w, []map[string]string{
|
||||
{
|
||||
"index": "test-index",
|
||||
"health": "green",
|
||||
"status": "open",
|
||||
"docs.count": "1000",
|
||||
"store.size": "5mb",
|
||||
if strings.HasPrefix(r.URL.Path, "/test-index/_settings") && r.Method == http.MethodGet {
|
||||
writeJSON(w, map[string]map[string]interface{}{
|
||||
"test-index": {
|
||||
"settings": map[string]interface{}{
|
||||
"index": map[string]interface{}{
|
||||
"number_of_shards": "3",
|
||||
"number_of_replicas": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -572,11 +616,11 @@ func TestElasticsearchGetIndexes(t *testing.T) {
|
||||
if idx.IndexType != "INDEX" {
|
||||
t.Fatalf("索引类型期望 INDEX,实际:%s", idx.IndexType)
|
||||
}
|
||||
if !strings.Contains(idx.ColumnName, "green") {
|
||||
t.Fatalf("索引信息应包含 health=green,实际:%s", idx.ColumnName)
|
||||
if !strings.Contains(idx.ColumnName, "shards=3") {
|
||||
t.Fatalf("索引信息应包含 shards=3,实际:%s", idx.ColumnName)
|
||||
}
|
||||
if !strings.Contains(idx.ColumnName, "1000") {
|
||||
t.Fatalf("索引信息应包含 docs=1000,实际:%s", idx.ColumnName)
|
||||
if !strings.Contains(idx.ColumnName, "replicas=1") {
|
||||
t.Fatalf("索引信息应包含 replicas=1,实际:%s", idx.ColumnName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,29 +837,6 @@ func TestExtractColumnsFromMapping(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsHiddenIndex 测试隐藏索引判断。
|
||||
func TestIsHiddenIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"隐藏索引 .security", ".security", true},
|
||||
{"隐藏索引 .kibana_1", ".kibana_1", true},
|
||||
{"普通索引 logs-2024", "logs-2024", false},
|
||||
{"普通索引 users", "users", false},
|
||||
{"空字符串", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isHiddenIndex(tt.input); got != tt.expected {
|
||||
t.Fatalf("isHiddenIndex(%q) = %v,期望 %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsJSONDSL 测试 JSON DSL 检测。
|
||||
func TestIsJSONDSL(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -826,7 +847,7 @@ func TestIsJSONDSL(t *testing.T) {
|
||||
{"JSON DSL", `{"query":{"match_all":{}}}`, true},
|
||||
{"简单字符串", "hello world", false},
|
||||
{"空字符串", "", false},
|
||||
{"JSON 对象以空格开头", ` {"query":{}}`, false},
|
||||
{"JSON 对象以空格开头", ` {"query":{}}`, true},
|
||||
{"非查询 JSON 前缀", `[1,2,3]`, false},
|
||||
}
|
||||
|
||||
@@ -882,34 +903,6 @@ func TestExtractEsFieldType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildESClientConfig 测试 ES 客户端配置构建。
|
||||
func TestBuildESClientConfig(t *testing.T) {
|
||||
t.Run("HTTP 配置", func(t *testing.T) {
|
||||
cfg := buildESClientConfig(connection.ConnectionConfig{
|
||||
Host: "localhost",
|
||||
Port: 9200,
|
||||
User: "elastic",
|
||||
})
|
||||
if cfg.BaseURL != "http://localhost:9200" {
|
||||
t.Fatalf("HTTP 地址期望 http://localhost:9200,实际:%v", cfg.BaseURL)
|
||||
}
|
||||
if cfg.Username != "elastic" {
|
||||
t.Fatalf("用户名期望 elastic,实际:%q", cfg.Username)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTPS 配置", func(t *testing.T) {
|
||||
cfg := buildESClientConfig(connection.ConnectionConfig{
|
||||
Host: "es.example.com",
|
||||
Port: 9200,
|
||||
UseSSL: true,
|
||||
})
|
||||
if cfg.BaseURL != "https://es.example.com:9200" {
|
||||
t.Fatalf("HTTPS 地址期望 https://es.example.com:9200,实际:%v", cfg.BaseURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveEsIndexName 测试索引名解析。
|
||||
func TestResolveEsIndexName(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -986,18 +979,41 @@ func TestESMockIntegration(t *testing.T) {
|
||||
case r.Method == http.MethodHead && path == "/":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Cat Indices
|
||||
case strings.HasPrefix(path, "/_cat/indices") && r.Method == http.MethodGet:
|
||||
writeJSON(w, []map[string]string{
|
||||
{"index": "products"},
|
||||
{"index": "orders"},
|
||||
{"index": ".internal"},
|
||||
// Indices.Get("*") — 返回所有索引
|
||||
case r.Method == http.MethodGet && (path == "/" || path == "/*") && !strings.Contains(path, "_"):
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"products": map[string]interface{}{},
|
||||
"orders": map[string]interface{}{},
|
||||
".internal": map[string]interface{}{},
|
||||
})
|
||||
|
||||
// Indices.GetAlias — 返回别名映射
|
||||
case strings.Contains(path, "/_alias") && r.Method == http.MethodGet:
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"products": map[string]interface{}{
|
||||
"aliases": map[string]interface{}{
|
||||
"products-alias": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mapping
|
||||
case strings.HasSuffix(path, "/_mapping"):
|
||||
writeJSON(w, mappingData)
|
||||
|
||||
// Settings
|
||||
case strings.HasSuffix(path, "/_settings"):
|
||||
writeJSON(w, map[string]map[string]interface{}{
|
||||
"products": {
|
||||
"settings": map[string]interface{}{
|
||||
"index": map[string]interface{}{
|
||||
"number_of_shards": "1",
|
||||
"number_of_replicas": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// GetCreateStatement
|
||||
case r.Method == http.MethodGet && !strings.Contains(path, "_"):
|
||||
writeJSON(w, map[string]interface{}{
|
||||
@@ -1033,23 +1049,32 @@ func TestESMockIntegration(t *testing.T) {
|
||||
t.Fatalf("Ping 失败:%v", err)
|
||||
}
|
||||
|
||||
// 验证 GetDatabases(应过滤 .internal)
|
||||
// 验证 GetDatabases(应返回全部索引包括系统索引)
|
||||
databases, err := db.GetDatabases()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatabases 失败:%v", err)
|
||||
}
|
||||
slices.Sort(databases)
|
||||
if len(databases) != 2 || databases[0] != "orders" || databases[1] != "products" {
|
||||
t.Fatalf("GetDatabases 期望 [orders, products],实际:%v", databases)
|
||||
if len(databases) != 3 || databases[0] != ".internal" || databases[1] != "orders" || databases[2] != "products" {
|
||||
t.Fatalf("GetDatabases 期望 [.internal, orders, products],实际:%v", databases)
|
||||
}
|
||||
|
||||
// 验证 GetTables
|
||||
// 验证 GetTables(应返回索引名和别名)
|
||||
tables, err := db.GetTables("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTables 失败:%v", err)
|
||||
}
|
||||
if len(tables) != 1 || tables[0] != "products" {
|
||||
t.Fatalf("GetTables 期望 [products],实际:%v", tables)
|
||||
if len(tables) < 1 || tables[0] != "products" {
|
||||
t.Fatalf("GetTables 第一个元素应为 products,实际:%v", tables)
|
||||
}
|
||||
hasAlias := false
|
||||
for _, tbl := range tables {
|
||||
if tbl == "products-alias" {
|
||||
hasAlias = true
|
||||
}
|
||||
}
|
||||
if !hasAlias {
|
||||
t.Fatalf("GetTables 应包含别名 products-alias,实际:%v", tables)
|
||||
}
|
||||
|
||||
// 验证 GetColumns
|
||||
@@ -1057,8 +1082,8 @@ func TestESMockIntegration(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetColumns 失败:%v", err)
|
||||
}
|
||||
if len(columns) != 5 {
|
||||
t.Fatalf("GetColumns 期望 5 个字段,实际 %d", len(columns))
|
||||
if len(columns) != 6 { // _id + 5 个 mapping 字段
|
||||
t.Fatalf("GetColumns 期望 6 个字段,实际 %d", len(columns))
|
||||
}
|
||||
|
||||
// 验证 DSL 查询
|
||||
@@ -1104,3 +1129,523 @@ func TestESMockIntegration(t *testing.T) {
|
||||
t.Fatalf("GetTriggers 应返回空,实际:%d", len(triggers))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- P1 功能测试 ----
|
||||
|
||||
// TestParseESConsoleRequest 测试 DevTools 风格查询解析。
|
||||
func TestParseESConsoleRequest(t *testing.T) {
|
||||
t.Run("带 body 的 GET 请求", func(t *testing.T) {
|
||||
input := "GET /logs-*/_search\n{\"query\":{\"match_all\":{}}}"
|
||||
req, ok := parseESConsoleRequest(input)
|
||||
if !ok {
|
||||
t.Fatal("解析应成功")
|
||||
}
|
||||
if req.Method != "GET" {
|
||||
t.Fatalf("方法期望 GET,实际:%q", req.Method)
|
||||
}
|
||||
if req.Path != "/logs-*/_search" {
|
||||
t.Fatalf("路径期望 /logs-*/_search,实际:%q", req.Path)
|
||||
}
|
||||
if len(req.Body) == 0 {
|
||||
t.Fatal("body 不应为空")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("带 body 的 POST 请求", func(t *testing.T) {
|
||||
input := "POST /orders/_search\n{\"size\":10}"
|
||||
req, ok := parseESConsoleRequest(input)
|
||||
if !ok {
|
||||
t.Fatal("解析应成功")
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Fatalf("方法期望 POST,实际:%q", req.Method)
|
||||
}
|
||||
if req.Path != "/orders/_search" {
|
||||
t.Fatalf("路径期望 /orders/_search,实际:%q", req.Path)
|
||||
}
|
||||
if len(req.Body) == 0 {
|
||||
t.Fatal("body 不应为空")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无 body 的 GET 请求", func(t *testing.T) {
|
||||
input := "GET /_cluster/health"
|
||||
req, ok := parseESConsoleRequest(input)
|
||||
if !ok {
|
||||
t.Fatal("解析应成功")
|
||||
}
|
||||
if req.Method != "GET" {
|
||||
t.Fatalf("方法期望 GET,实际:%q", req.Method)
|
||||
}
|
||||
if req.Path != "/_cluster/health" {
|
||||
t.Fatalf("路径期望 /_cluster/health,实际:%q", req.Path)
|
||||
}
|
||||
if len(req.Body) != 0 {
|
||||
t.Fatalf("无 body 时应为空,实际长度:%d", len(req.Body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE 方法应被拒绝", func(t *testing.T) {
|
||||
input := "DELETE /index"
|
||||
_, ok := parseESConsoleRequest(input)
|
||||
if ok {
|
||||
t.Fatal("DELETE 请求应解析失败")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("纯 JSON 应被拒绝", func(t *testing.T) {
|
||||
input := "{\"query\":{\"match_all\":{}}}"
|
||||
_, ok := parseESConsoleRequest(input)
|
||||
if ok {
|
||||
t.Fatal("纯 JSON 不是 DevTools 格式,应解析失败")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SQL 语句应被拒绝", func(t *testing.T) {
|
||||
input := "select * from test"
|
||||
_, ok := parseESConsoleRequest(input)
|
||||
if ok {
|
||||
t.Fatal("SQL 语句不是 DevTools 格式,应解析失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlattenESSource 测试嵌套对象展开为点分路径。
|
||||
func TestFlattenESSource(t *testing.T) {
|
||||
t.Run("嵌套对象展开", func(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "张三",
|
||||
"age": 18,
|
||||
},
|
||||
}
|
||||
row := make(map[string]interface{})
|
||||
flattenESSource("", source, row)
|
||||
|
||||
if row["user.name"] != "张三" {
|
||||
t.Fatalf("user.name 期望 张三,实际:%v", row["user.name"])
|
||||
}
|
||||
if row["user.age"] != 18 {
|
||||
t.Fatalf("user.age 期望 18,实际:%v", row["user.age"])
|
||||
}
|
||||
// 原始嵌套键不应保留
|
||||
if _, ok := row["user"]; ok {
|
||||
t.Fatal("展开后不应保留原始嵌套键 user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("数组序列化为 JSON 字符串", func(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"tags": []interface{}{"a", "b"},
|
||||
}
|
||||
row := make(map[string]interface{})
|
||||
flattenESSource("", source, row)
|
||||
|
||||
tags, ok := row["tags"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("tags 应序列化为 JSON 字符串,实际类型:%T", row["tags"])
|
||||
}
|
||||
if tags != `["a","b"]` {
|
||||
t.Fatalf("tags JSON 不匹配,实际:%v", tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多层嵌套展开", func(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"a": map[string]interface{}{
|
||||
"b": map[string]interface{}{
|
||||
"c": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
row := make(map[string]interface{})
|
||||
flattenESSource("", source, row)
|
||||
|
||||
if row["a.b.c"] != 1 {
|
||||
t.Fatalf("a.b.c 期望 1,实际:%v", row["a.b.c"])
|
||||
}
|
||||
if _, ok := row["a"]; ok {
|
||||
t.Fatal("展开后不应保留原始嵌套键 a")
|
||||
}
|
||||
if _, ok := row["a.b"]; ok {
|
||||
t.Fatal("展开后不应保留中间嵌套键 a.b")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空对象返回空", func(t *testing.T) {
|
||||
source := map[string]interface{}{}
|
||||
row := make(map[string]interface{})
|
||||
flattenESSource("", source, row)
|
||||
|
||||
if len(row) != 0 {
|
||||
t.Fatalf("空对象展开后应为空,实际长度:%d", len(row))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchQueryConsole 测试 DevTools 风格查询端到端。
|
||||
func TestElasticsearchQueryConsole(t *testing.T) {
|
||||
t.Run("DevTools 格式查询能正确执行", func(t *testing.T) {
|
||||
var capturedMethod, capturedPath string
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedMethod = r.Method
|
||||
capturedPath = r.URL.Path
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/test-index/_search" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"hits": {
|
||||
"total": {"value": 1},
|
||||
"hits": [
|
||||
{"_index": "test-index", "_id": "1", "_source": {"name": "测试文档"}}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "test-index")
|
||||
|
||||
// 模拟 DevTools 格式查询
|
||||
consoleQuery := "GET /test-index/_search\n{\"query\":{\"match_all\":{}}}"
|
||||
rows, _, err := db.Query(consoleQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("DevTools 查询失败:%v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("期望 1 条结果,实际 %d", len(rows))
|
||||
}
|
||||
if rows[0]["name"] != "测试文档" {
|
||||
t.Fatalf("期望 name=测试文档,实际:%v", rows[0]["name"])
|
||||
}
|
||||
|
||||
// 验证请求路径正确
|
||||
if capturedMethod != "GET" {
|
||||
t.Fatalf("请求方法期望 GET,实际:%q", capturedMethod)
|
||||
}
|
||||
if capturedPath != "/test-index/_search" {
|
||||
t.Fatalf("请求路径期望 /test-index/_search,实际:%q", capturedPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("带 index 的 DevTools 查询", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/my-index/_search" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"hits":{"total":{"value":0},"hits":[]}}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "default-index")
|
||||
query := "GET /my-index/_search\n{\"query\":{\"match_all\":{}}}"
|
||||
rows, _, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败:%v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("期望 0 条结果,实际 %d", len(rows))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchAggregations 测试 aggregation 结果展示。
|
||||
func TestElasticsearchAggregations(t *testing.T) {
|
||||
t.Run("仅有 aggregations 无 hits", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"hits": {
|
||||
"total": {"value": 0},
|
||||
"hits": []
|
||||
},
|
||||
"aggregations": {
|
||||
"status_count": {
|
||||
"buckets": [
|
||||
{"key": "active", "doc_count": 42},
|
||||
{"key": "inactive", "doc_count": 8}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`))
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "test-index")
|
||||
rows, columns, err := db.Query(`{"aggs":{"status_count":{"terms":{"field":"status"}}}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("聚合查询失败:%v", err)
|
||||
}
|
||||
|
||||
// hits 为空时应仍返回 _aggregations 行
|
||||
if len(rows) < 1 {
|
||||
t.Fatal("聚合结果不应为空,至少应包含 _aggregations 行")
|
||||
}
|
||||
|
||||
// 验证列中包含 _aggregations 标识
|
||||
hasAgg := false
|
||||
for _, col := range columns {
|
||||
if col == "_aggregations" {
|
||||
hasAgg = true
|
||||
}
|
||||
}
|
||||
if !hasAgg {
|
||||
t.Fatalf("结果列应包含 _aggregations,实际:%v", columns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hits 和 aggregations 同时存在", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"hits": {
|
||||
"total": {"value": 2},
|
||||
"hits": [
|
||||
{"_index": "test", "_id": "1", "_source": {"status": "active"}},
|
||||
{"_index": "test", "_id": "2", "_source": {"status": "active"}}
|
||||
]
|
||||
},
|
||||
"aggregations": {
|
||||
"avg_score": {
|
||||
"value": 85.5
|
||||
}
|
||||
}
|
||||
}`))
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "test-index")
|
||||
rows, columns, err := db.Query(`{"aggs":{"avg_score":{"avg":{"field":"score"}}}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("聚合查询失败:%v", err)
|
||||
}
|
||||
|
||||
// 应包含 hits 数据
|
||||
if len(rows) < 2 {
|
||||
t.Fatalf("期望至少 2 条 hits 结果,实际 %d", len(rows))
|
||||
}
|
||||
|
||||
// 验证列中包含 _aggregations
|
||||
hasAgg := false
|
||||
for _, col := range columns {
|
||||
if col == "_aggregations" {
|
||||
hasAgg = true
|
||||
}
|
||||
}
|
||||
if !hasAgg {
|
||||
t.Fatalf("结果列应包含 _aggregations,实际:%v", columns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestESAPIKeyAuth 测试 API Key 认证配置。
|
||||
func TestESAPIKeyAuth(t *testing.T) {
|
||||
t.Run("ConnectionParams 中的 apiKey 应设置到配置", func(t *testing.T) {
|
||||
cfg := buildESClientConfig(connection.ConnectionConfig{
|
||||
Host: "localhost",
|
||||
Port: 9200,
|
||||
ConnectionParams: "apiKey=test-key-123",
|
||||
})
|
||||
if cfg.APIKey != "test-key-123" {
|
||||
t.Fatalf("APIKey 期望 test-key-123,实际:%q", cfg.APIKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("使用 API Key 时 Basic Auth 应被清除", func(t *testing.T) {
|
||||
cfg := buildESClientConfig(connection.ConnectionConfig{
|
||||
Host: "localhost",
|
||||
Port: 9200,
|
||||
User: "elastic",
|
||||
Password: "pass",
|
||||
ConnectionParams: "apiKey=test-key-123",
|
||||
})
|
||||
if cfg.APIKey != "test-key-123" {
|
||||
t.Fatalf("APIKey 期望 test-key-123,实际:%q", cfg.APIKey)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("使用 API Key 时 Username 应为空,实际:%q", cfg.Username)
|
||||
}
|
||||
if cfg.Password != "" {
|
||||
t.Fatalf("使用 API Key 时 Password 应为空,实际:%q", cfg.Password)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestElasticsearchSourceFlatten 测试 _source 嵌套对象扁平化端到端。
|
||||
func TestElasticsearchSourceFlatten(t *testing.T) {
|
||||
t.Run("嵌套对象在结果中扁平化", func(t *testing.T) {
|
||||
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"hits": {
|
||||
"total": {"value": 1},
|
||||
"hits": [
|
||||
{
|
||||
"_index": "test-index",
|
||||
"_id": "1",
|
||||
"_source": {
|
||||
"user": {"name": "张三", "age": 18},
|
||||
"title": "测试"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
})
|
||||
|
||||
db := newTestESDB(t, server.URL, "test-index")
|
||||
rows, columns, err := db.Query(`{"query":{"match_all":{}}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败:%v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("期望 1 条结果,实际 %d", len(rows))
|
||||
}
|
||||
|
||||
// 验证扁平化字段存在
|
||||
if rows[0]["user.name"] != "张三" {
|
||||
t.Fatalf("user.name 期望 张三,实际:%v", rows[0]["user.name"])
|
||||
}
|
||||
// JSON 数字解析为 float64
|
||||
if age, ok := rows[0]["user.age"].(float64); !ok || age != 18 {
|
||||
t.Fatalf("user.age 期望 18,实际:%v (类型:%T)", rows[0]["user.age"], rows[0]["user.age"])
|
||||
}
|
||||
if rows[0]["title"] != "测试" {
|
||||
t.Fatalf("title 期望 测试,实际:%v", rows[0]["title"])
|
||||
}
|
||||
|
||||
// 验证列中包含扁平化字段
|
||||
colSet := make(map[string]bool)
|
||||
for _, col := range columns {
|
||||
colSet[col] = true
|
||||
}
|
||||
if !colSet["user.name"] {
|
||||
t.Fatalf("列应包含 user.name,实际:%v", columns)
|
||||
}
|
||||
if !colSet["user.age"] {
|
||||
t.Fatalf("列应包含 user.age,实际:%v", columns)
|
||||
}
|
||||
|
||||
// 验证 _source 原始 JSON 保留(序列化为 JSON 字符串)
|
||||
sourceRaw, ok := rows[0]["_source"]
|
||||
if !ok {
|
||||
t.Fatal("结果应包含 _source 原始 JSON")
|
||||
}
|
||||
sourceStr, ok := sourceRaw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("_source 应为 JSON 字符串类型,实际类型:%T", sourceRaw)
|
||||
}
|
||||
var sourceMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(sourceStr), &sourceMap); err != nil {
|
||||
t.Fatalf("_source JSON 解析失败:%v", err)
|
||||
}
|
||||
if _, hasNested := sourceMap["user"]; !hasNested {
|
||||
t.Fatal("_source 原始 JSON 中应保留嵌套结构 user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- extractESSQLFromTable 测试 ----
|
||||
|
||||
func TestESExtractSQLFromTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sql string
|
||||
want string
|
||||
}{
|
||||
{"简单表名", `SELECT * FROM "app_log_user" LIMIT 101 OFFSET 0`, "app_log_user"},
|
||||
{"无引号表名", `SELECT * FROM my_index LIMIT 10`, "my_index"},
|
||||
{"带点的表名", `SELECT * FROM "iot_pro_biz_operate_log.index.20240626" LIMIT 101`, "iot_pro_biz_operate_log.index.20240626"},
|
||||
{"通配符表名", `SELECT * FROM "logs-*" LIMIT 10`, "logs-*"},
|
||||
{"多段引号标识符", `SELECT * FROM "iot_pro_biz_operate_log"."index"."20250515" WHERE (("_score">45)) LIMIT 101 OFFSET 0`, "iot_pro_biz_operate_log.index.20250515"},
|
||||
{"两段引号标识符", `SELECT * FROM "my_schema"."my_table" LIMIT 10`, "my_schema.my_table"},
|
||||
{"非 SELECT 语句", `{"query": {"match_all": {}}}`, ""},
|
||||
{"空语句", ``, ""},
|
||||
{"FROM 语句片段", `FROM "test"`, "test"},
|
||||
{"FROM 后无表名", `SELECT * FROM`, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractESSQLFromTable(tt.sql)
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractESSQLFromTable(%q) = %q, want %q", tt.sql, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseESSQL 测试 ----
|
||||
|
||||
func TestESParseSQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sql string
|
||||
wantTable string
|
||||
wantLimit int
|
||||
wantOff int
|
||||
wantOK bool
|
||||
}{
|
||||
{"基础SELECT", `SELECT * FROM "app_log_user" LIMIT 101 OFFSET 0`, "app_log_user", 101, 0, true},
|
||||
{"带点索引名", `SELECT * FROM "iot.index.2024" LIMIT 200`, "iot.index.2024", 200, 0, true},
|
||||
{"多段引号", `SELECT * FROM "schema"."table" LIMIT 50 OFFSET 10`, "schema.table", 50, 10, true},
|
||||
{"无LIMIT", `SELECT * FROM "my_index"`, "my_index", 0, 0, true},
|
||||
{"DSL JSON", `{"query": {"match_all": {}}}`, "", 0, 0, false},
|
||||
{"分页_第1页", `SELECT * FROM "app_log_user" LIMIT 101 OFFSET 0`, "app_log_user", 101, 0, true},
|
||||
{"分页_第2页", `SELECT * FROM "app_log_user" LIMIT 101 OFFSET 100`, "app_log_user", 101, 100, true},
|
||||
{"分页_第3页", `SELECT * FROM "app_log_user" LIMIT 101 OFFSET 200`, "app_log_user", 101, 200, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, ok := parseESSQL(tt.sql)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseESSQL(%q) ok=%v want %v", tt.sql, ok, tt.wantOK)
|
||||
}
|
||||
if !tt.wantOK {
|
||||
return
|
||||
}
|
||||
if parsed.Table != tt.wantTable {
|
||||
t.Errorf("Table=%q want %q", parsed.Table, tt.wantTable)
|
||||
}
|
||||
if parsed.Limit != tt.wantLimit {
|
||||
t.Errorf("Limit=%d want %d", parsed.Limit, tt.wantLimit)
|
||||
}
|
||||
if parsed.Offset != tt.wantOff {
|
||||
t.Errorf("Offset=%d want %d", parsed.Offset, tt.wantOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestESConvertWhere(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
where string
|
||||
key string
|
||||
}{
|
||||
{"等值", `"status" = 'active'`, "term"},
|
||||
{"范围", `"age" > 18`, "range"},
|
||||
{"score", `"_score" > 45`, "range"},
|
||||
{"AND", `"a" = '1' AND "b" > 2`, "bool"},
|
||||
{"OR", `"a" = '1' OR "b" = '2'`, "bool"},
|
||||
{"IS NULL", `"name" IS NULL`, "bool"},
|
||||
{"IS NOT NULL", `"name" IS NOT NULL`, "exists"},
|
||||
{"LIKE", `"name" LIKE 'test%'`, "wildcard"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertSQLWhereToESQuery(tt.where)
|
||||
if result == nil {
|
||||
t.Fatal("convertSQLWhereToESQuery returned nil")
|
||||
}
|
||||
if _, ok := result[tt.key]; !ok {
|
||||
keys := make([]string, 0, len(result))
|
||||
for k := range result {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
t.Errorf("expected key %q, got %v", tt.key, keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -27,7 +27,7 @@ normalize_driver() {
|
||||
doris|diros) echo "diros" ;;
|
||||
oceanbase) echo "oceanbase" ;;
|
||||
opengauss|open_gauss|open-gauss) echo "opengauss" ;;
|
||||
elastic|elasticsearch) echo "elasticsearch" ;;
|
||||
elasticsearch|elastic) echo "elasticsearch" ;;
|
||||
mariadb|diros|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|iris|mongodb|tdengine|clickhouse)
|
||||
echo "$value"
|
||||
;;
|
||||
@@ -132,8 +132,8 @@ mongodb:internal/db/mongodb_impl.go|\
|
||||
mongodb:internal/db/mongodb_impl_v1.go|\
|
||||
tdengine:internal/db/tdengine_impl.go|\
|
||||
clickhouse:internal/db/clickhouse_impl.go|\
|
||||
elasticsearch:internal/db/elasticsearch_helpers.go|\
|
||||
elasticsearch:internal/db/elasticsearch_impl.go)
|
||||
elasticsearch:internal/db/elasticsearch_impl.go|\
|
||||
elasticsearch:internal/db/elasticsearch_helpers.go)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user