//go:build gonavi_full_drivers || gonavi_elasticsearch_driver package db import ( "encoding/json" "net/http" "net/http/httptest" "slices" "strings" "testing" "GoNavi-Wails/internal/connection" "github.com/elastic/go-elasticsearch/v8" ) // ---- 测试辅助函数 ---- // newMockESServer 创建模拟 Elasticsearch REST API 的 HTTP 测试服务器。 // 自动为所有响应添加 go-elasticsearch v8 客户端要求的 X-Elastic-Product 头。 func newMockESServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Elastic-Product", "Elasticsearch") handler(w, r) })) t.Cleanup(server.Close) return server } // 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: client, database: defaultIndex, } } // buildMockESMappingResponse 构造模拟的 mapping 响应 JSON。 func buildMockESMappingResponse(indexName string, fields map[string]string) map[string]interface{} { properties := make(map[string]interface{}) for name, fieldType := range fields { properties[name] = map[string]interface{}{"type": fieldType} } return map[string]interface{}{ indexName: map[string]interface{}{ "mappings": map[string]interface{}{ "properties": properties, }, }, } } // writeJSON 将数据以 JSON 格式写入 HTTP 响应。 func writeJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(data) } // ---- 核心功能测试 ---- // TestElasticsearchPing 测试 Ping 成功和失败路径。 func TestElasticsearchPing(t *testing.T) { t.Run("ping 成功", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { // Ping 使用 HEAD / 方法 if r.Method == http.MethodHead && r.URL.Path == "/" { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "") if err := db.Ping(); err != nil { t.Fatalf("Ping 应成功,但返回错误:%v", err) } }) t.Run("ping 服务端返回错误", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) db := newTestESDB(t, server.URL, "") err := db.Ping() if err == nil { t.Fatal("Ping 服务端 500 时应返回错误") } if !strings.Contains(err.Error(), "500") { t.Fatalf("错误信息应包含状态码,实际:%v", err) } }) } // TestElasticsearchGetDatabases 测试获取索引列表。 func TestElasticsearchGetDatabases(t *testing.T) { t.Run("正常获取全部索引", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { 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 } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "") databases, err := db.GetDatabases() if err != nil { t.Fatalf("GetDatabases 失败:%v", err) } slices.Sort(databases) expected := []string{".kibana_1", ".security", "logs-2024", "products", "users"} if len(databases) != len(expected) { t.Fatalf("期望 %d 个索引,实际 %d:%v", len(expected), len(databases), databases) } for i, name := range expected { if databases[i] != name { t.Fatalf("索引 [%d] 期望 %q,实际 %q", i, name, databases[i]) } } }) t.Run("连接未打开时返回错误", func(t *testing.T) { db := &ElasticsearchDB{} _, err := db.GetDatabases() if err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("期望 '连接未打开' 错误,实际:%v", err) } }) } // TestElasticsearchGetTables 测试 GetTables 返回索引名及别名。 func TestElasticsearchGetTables(t *testing.T) { 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) } // 应包含别名 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) { 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) } if len(tables) != 1 || tables[0] != "default-index" { t.Fatalf("期望 [default-index],实际:%v", tables) } }) t.Run("无索引名时报错", func(t *testing.T) { 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 中提取字段定义。 func TestElasticsearchGetColumns(t *testing.T) { t.Run("正常提取字段", func(t *testing.T) { fields := map[string]string{ "title": "text", "status": "keyword", "price": "float", "quantity": "integer", } mapping := buildMockESMappingResponse("test-index", fields) server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/_mapping") && r.Method == http.MethodGet { writeJSON(w, mapping) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "test-index") columns, err := db.GetColumns("test-index", "") if err != nil { t.Fatalf("GetColumns 失败:%v", err) } if len(columns) != 4 { t.Fatalf("期望 4 个字段,实际 %d", len(columns)) } // 验证字段类型映射 typeMap := make(map[string]string) for _, col := range columns { typeMap[col.Name] = col.Type } for name, expectedType := range fields { if typeMap[name] != expectedType { t.Fatalf("字段 %q 类型期望 %q,实际 %q", name, expectedType, typeMap[name]) } } // 验证所有字段标记为可空 for _, col := range columns { if col.Nullable != "YES" { t.Fatalf("字段 %q Nullable 期望 YES,实际 %q", col.Name, col.Nullable) } } }) t.Run("服务端返回错误", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"index_not_found"}`)) }) db := newTestESDB(t, server.URL, "test-index") _, err := db.GetColumns("test-index", "") if err == nil { t.Fatal("GetColumns 服务端 404 时应返回错误") } }) t.Run("连接未打开时返回错误", func(t *testing.T) { db := &ElasticsearchDB{} _, err := db.GetColumns("test-index", "") if err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("期望 '连接未打开' 错误,实际:%v", err) } }) } // TestElasticsearchGetAllColumns 测试获取全部字段。 func TestElasticsearchGetAllColumns(t *testing.T) { fields := map[string]string{ "name": "text", "email": "keyword", } mapping := buildMockESMappingResponse("users", fields) server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/_mapping") { writeJSON(w, mapping) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "users") columns, err := db.GetAllColumns("") if err != nil { t.Fatalf("GetAllColumns 失败:%v", err) } if len(columns) != 2 { t.Fatalf("期望 2 个字段,实际 %d", len(columns)) } // 验证每个字段都带有表名标识 for _, col := range columns { if col.TableName != "users" { t.Fatalf("字段 %q 的 TableName 期望 users,实际 %s", col.Name, col.TableName) } } } // TestElasticsearchQueryDSL 测试 JSON DSL 查询模式。 func TestElasticsearchQueryDSL(t *testing.T) { t.Run("指定索引的 DSL 查询", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "hits": { "total": {"value": 1}, "hits": [ {"_index": "test-index", "_id": "1", "_source": {"title": "测试文档", "status": "active"}} ] } }`)) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "test-index") dsl := `{"query":{"match_all":{}}}` rows, columns, err := db.Query(dsl) if err != nil { t.Fatalf("DSL 查询失败:%v", err) } if len(rows) != 1 { t.Fatalf("期望 1 条结果,实际 %d", len(rows)) } // 验证包含 _index 和 _id 元数据列 colSet := make(map[string]bool) for _, col := range columns { colSet[col] = true } if !colSet["_index"] || !colSet["_id"] { t.Fatalf("结果列应包含 _index 和 _id,实际:%v", columns) } // 验证数据内容 if rows[0]["title"] != "测试文档" { t.Fatalf("期望 title=测试文档,实际:%v", rows[0]["title"]) } if rows[0]["_index"] != "test-index" { t.Fatalf("期望 _index=test-index,实际:%v", rows[0]["_index"]) } }) t.Run("无默认索引时使用通配符", func(t *testing.T) { var capturedPath string server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") { capturedPath = r.URL.Path 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, "") _, _, err := db.Query(`{"query":{"match_all":{}}}`) if err != nil { t.Fatalf("DSL 查询失败:%v", err) } if !strings.HasPrefix(capturedPath, "/*/_search") { t.Fatalf("无默认索引时应使用 * 通配符查询,实际路径:%s", capturedPath) } }) t.Run("查询服务端返回错误", func(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"parsing_exception"}`)) }) db := newTestESDB(t, server.URL, "test-index") _, _, err := db.Query(`{"query":{"invalid":{}}}`) if err == nil { t.Fatal("DSL 查询服务端错误时应返回错误") } }) } // TestElasticsearchQueryString 测试 query_string 查询模式。 func TestElasticsearchQueryString(t *testing.T) { t.Run("简单字符串查询", func(t *testing.T) { var capturedBody string server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") { buf := make([]byte, r.ContentLength) _, _ = r.Body.Read(buf) capturedBody = string(buf) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "hits": { "total": {"value": 2}, "hits": [ {"_index": "test", "_id": "1", "_source": {"title": "匹配结果1"}}, {"_index": "test", "_id": "2", "_source": {"title": "匹配结果2"}} ] } }`)) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "test") rows, _, err := db.Query("hello world") if err != nil { t.Fatalf("query_string 查询失败:%v", err) } if len(rows) != 2 { t.Fatalf("期望 2 条结果,实际 %d", len(rows)) } // 验证请求体包含 query_string 包装 if !strings.Contains(capturedBody, "query_string") { t.Fatalf("请求体应包含 query_string,实际:%s", capturedBody) } if !strings.Contains(capturedBody, "hello world") { t.Fatalf("请求体应包含查询文本,实际:%s", capturedBody) } }) t.Run("查询语句为空时报错", func(t *testing.T) { db := newTestESDB(t, "http://localhost:9200", "test") _, _, err := db.Query(" ") if err == nil || !strings.Contains(err.Error(), "查询语句不能为空") { t.Fatalf("期望 '查询语句不能为空' 错误,实际:%v", err) } }) t.Run("连接未打开时返回错误", func(t *testing.T) { db := &ElasticsearchDB{} _, _, err := db.Query("test") if err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("期望 '连接未打开' 错误,实际:%v", err) } }) } // TestElasticsearchExecNotSupported 测试 Exec 返回不支持错误。 func TestElasticsearchExecNotSupported(t *testing.T) { db := &ElasticsearchDB{} rowsAffected, err := db.Exec("DELETE FROM test") if err == nil || !strings.Contains(err.Error(), "不支持执行非查询语句") { t.Fatalf("期望 '不支持执行非查询语句' 错误,实际:%v", err) } if rowsAffected != 0 { t.Fatalf("Exec 应返回 0 受影响行数,实际:%d", rowsAffected) } } // TestElasticsearchGetForeignKeys 测试返回空外键列表。 func TestElasticsearchGetForeignKeys(t *testing.T) { db := &ElasticsearchDB{} fks, err := db.GetForeignKeys("test-index", "test-table") if err != nil { t.Fatalf("GetForeignKeys 不应返回错误:%v", err) } if len(fks) != 0 { t.Fatalf("GetForeignKeys 应返回空列表,实际:%v", fks) } } // TestElasticsearchGetTriggers 测试返回空触发器列表。 func TestElasticsearchGetTriggers(t *testing.T) { db := &ElasticsearchDB{} triggers, err := db.GetTriggers("test-index", "test-table") if err != nil { t.Fatalf("GetTriggers 不应返回错误:%v", err) } if len(triggers) != 0 { t.Fatalf("GetTriggers 应返回空列表,实际:%v", triggers) } } // TestElasticsearchConnectNilClient 测试未连接时各操作返回错误。 func TestElasticsearchConnectNilClient(t *testing.T) { db := &ElasticsearchDB{} // Ping if err := db.Ping(); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("Ping: 期望 '连接未打开' 错误,实际:%v", err) } // GetDatabases if _, err := db.GetDatabases(); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("GetDatabases: 期望 '连接未打开' 错误,实际:%v", err) } // GetColumns if _, err := db.GetColumns("idx", ""); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("GetColumns: 期望 '连接未打开' 错误,实际:%v", err) } // GetAllColumns if _, err := db.GetAllColumns("idx"); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("GetAllColumns: 期望 '连接未打开' 错误,实际:%v", err) } // GetIndexes if _, err := db.GetIndexes("idx", "tbl"); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("GetIndexes: 期望 '连接未打开' 错误,实际:%v", err) } // GetCreateStatement(间接通过 esFetchIndexMapping) if _, err := db.GetCreateStatement("idx", "tbl"); err == nil || !strings.Contains(err.Error(), "连接未打开") { t.Fatalf("GetCreateStatement: 期望 '连接未打开' 错误,实际:%v", err) } } // TestElasticsearchGetCreateStatement 测试获取索引 settings + mappings。 func TestElasticsearchGetCreateStatement(t *testing.T) { indexDef := map[string]interface{}{ "test-index": map[string]interface{}{ "settings": map[string]interface{}{ "index": map[string]interface{}{ "number_of_shards": "1", "number_of_replicas": "0", }, }, "mappings": map[string]interface{}{ "properties": map[string]interface{}{ "title": map[string]interface{}{"type": "text"}, }, }, }, } server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { // GetCreateStatement 使用 Indices.Get 方法,路径为 / if r.Method == http.MethodGet && r.URL.Path == "/test-index" && !strings.Contains(r.URL.Path, "_") { writeJSON(w, indexDef) return } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "test-index") stmt, err := db.GetCreateStatement("test-index", "") if err != nil { t.Fatalf("GetCreateStatement 失败:%v", err) } if !strings.Contains(stmt, "test-index") { t.Fatalf("CreateStatement 应包含索引名,实际:%s", stmt) } if !strings.Contains(stmt, "number_of_shards") { t.Fatalf("CreateStatement 应包含 settings,实际:%s", stmt) } if !strings.Contains(stmt, "mappings") { t.Fatalf("CreateStatement 应包含 mappings,实际:%s", stmt) } } // TestElasticsearchGetIndexes 测试获取索引 settings 信息。 func TestElasticsearchGetIndexes(t *testing.T) { server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { 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 } w.WriteHeader(http.StatusNotFound) }) db := newTestESDB(t, server.URL, "test-index") indexes, err := db.GetIndexes("test-index", "") if err != nil { t.Fatalf("GetIndexes 失败:%v", err) } if len(indexes) != 1 { t.Fatalf("期望 1 个索引信息,实际 %d", len(indexes)) } idx := indexes[0] if idx.Name != "test-index" { t.Fatalf("索引名期望 test-index,实际:%s", idx.Name) } if idx.IndexType != "INDEX" { t.Fatalf("索引类型期望 INDEX,实际:%s", idx.IndexType) } if !strings.Contains(idx.ColumnName, "shards=3") { t.Fatalf("索引信息应包含 shards=3,实际:%s", idx.ColumnName) } if !strings.Contains(idx.ColumnName, "replicas=1") { t.Fatalf("索引信息应包含 replicas=1,实际:%s", idx.ColumnName) } } // ---- 辅助函数测试 ---- // TestNormalizeElasticsearchConfig 测试配置规范化。 func TestNormalizeElasticsearchConfig(t *testing.T) { t.Run("设置默认值", func(t *testing.T) { config := normalizeElasticsearchConfig(connection.ConnectionConfig{ Type: "elasticsearch", }) if config.Host != "localhost" { t.Fatalf("默认 Host 期望 localhost,实际:%q", config.Host) } if config.Port != defaultEsPort { t.Fatalf("默认 Port 期望 %d,实际:%d", defaultEsPort, config.Port) } if config.User != "" { t.Fatalf("默认 User 期望空字符串,实际:%q", config.User) } }) t.Run("保留用户设置", func(t *testing.T) { config := normalizeElasticsearchConfig(connection.ConnectionConfig{ Type: "elasticsearch", Host: "es.example.com", Port: 9201, User: "admin", Password: "secret", }) if config.Host != "es.example.com" { t.Fatalf("Host 期望 es.example.com,实际:%q", config.Host) } if config.Port != 9201 { t.Fatalf("Port 期望 9201,实际:%d", config.Port) } if config.User != "admin" { t.Fatalf("User 期望 admin,实际:%q", config.User) } }) t.Run("从 URI 中提取配置", func(t *testing.T) { config := normalizeElasticsearchConfig(connection.ConnectionConfig{ Type: "elasticsearch", URI: "http://uri-user:uri-pass@es-host:9202", }) if config.User != "uri-user" { t.Fatalf("User 期望从 URI 提取 uri-user,实际:%q", config.User) } if config.Password != "uri-pass" { t.Fatalf("Password 期望从 URI 提取 uri-pass,实际:%q", config.Password) } if config.Host != "es-host" { t.Fatalf("Host 期望从 URI 提取 es-host,实际:%q", config.Host) } if config.Port != 9202 { t.Fatalf("Port 期望从 URI 提取 9202,实际:%d", config.Port) } }) t.Run("已有 Host 时不从 URI 覆盖", func(t *testing.T) { config := normalizeElasticsearchConfig(connection.ConnectionConfig{ Type: "elasticsearch", Host: "custom-host", URI: "http://uri-user:uri-pass@uri-host:9200", }) if config.Host != "custom-host" { t.Fatalf("已有 Host 时不应覆盖,期望 custom-host,实际:%q", config.Host) } }) } // TestApplyElasticsearchURI 测试 URI 解析。 func TestApplyElasticsearchURI(t *testing.T) { t.Run("HTTPS URI 启用 SSL", func(t *testing.T) { config := applyElasticsearchURI(connection.ConnectionConfig{ URI: "https://user:pass@es.example.com:9200", }) if !config.UseSSL { t.Fatal("HTTPS URI 应启用 SSL") } if config.SSLMode != "required" { t.Fatalf("SSLMode 期望 required,实际:%q", config.SSLMode) } if config.Host != "es.example.com" { t.Fatalf("Host 期望 es.example.com,实际:%q", config.Host) } }) t.Run("非 HTTP 协议忽略", func(t *testing.T) { config := applyElasticsearchURI(connection.ConnectionConfig{ URI: "tcp://localhost:9200", }) if config.Host != "" { t.Fatalf("非 HTTP 协议不应设置 Host,实际:%q", config.Host) } }) t.Run("空 URI 不修改配置", func(t *testing.T) { config := applyElasticsearchURI(connection.ConnectionConfig{ Host: "original-host", Port: 9300, }) if config.Host != "original-host" || config.Port != 9300 { t.Fatal("空 URI 不应修改原有配置") } }) t.Run("已有用户凭证不被 URI 覆盖", func(t *testing.T) { config := applyElasticsearchURI(connection.ConnectionConfig{ User: "existing-user", Password: "existing-pass", URI: "http://uri-user:uri-pass@localhost:9200", }) if config.User != "existing-user" { t.Fatalf("已有 User 不应覆盖,期望 existing-user,实际:%q", config.User) } if config.Password != "existing-pass" { t.Fatalf("已有 Password 不应覆盖,期望 existing-pass,实际:%q", config.Password) } }) } // TestExtractColumnsFromMapping 测试 mapping 字段提取。 func TestExtractColumnsFromMapping(t *testing.T) { t.Run("标准字段提取", func(t *testing.T) { mapping := map[string]interface{}{ "test-index": map[string]interface{}{ "mappings": map[string]interface{}{ "properties": map[string]interface{}{ "title": map[string]interface{}{"type": "text"}, "count": map[string]interface{}{"type": "long"}, "tags": map[string]interface{}{"type": "keyword"}, }, }, }, } columns := extractColumnsFromMapping("test-index", mapping) if len(columns) != 3 { t.Fatalf("期望 3 个字段,实际 %d", len(columns)) } typeMap := make(map[string]string) for _, col := range columns { typeMap[col.Name] = col.Type } expectedTypes := map[string]string{"title": "text", "count": "long", "tags": "keyword"} for name, expectedType := range expectedTypes { if typeMap[name] != expectedType { t.Fatalf("字段 %q 类型期望 %q,实际 %q", name, expectedType, typeMap[name]) } } }) t.Run("含 description 的字段提取注释", func(t *testing.T) { mapping := map[string]interface{}{ "idx": map[string]interface{}{ "mappings": map[string]interface{}{ "properties": map[string]interface{}{ "email": map[string]interface{}{ "type": "keyword", "description": "用户邮箱地址", }, }, }, }, } columns := extractColumnsFromMapping("idx", mapping) if len(columns) != 1 { t.Fatalf("期望 1 个字段,实际 %d", len(columns)) } if columns[0].Comment != "用户邮箱地址" { t.Fatalf("期望注释 '用户邮箱地址',实际:%q", columns[0].Comment) } }) t.Run("空 mapping 返回空列表", func(t *testing.T) { mapping := map[string]interface{}{} columns := extractColumnsFromMapping("non-existent", mapping) if len(columns) != 0 { t.Fatalf("空 mapping 应返回空列表,实际 %d 个", len(columns)) } }) t.Run("索引数据无 mappings 字段", func(t *testing.T) { mapping := map[string]interface{}{ "idx": map[string]interface{}{ "settings": map[string]interface{}{}, }, } columns := extractColumnsFromMapping("idx", mapping) if len(columns) != 0 { t.Fatalf("无 mappings 时应返回空列表,实际 %d 个", len(columns)) } }) t.Run("从 mapping 响应中自动查找索引数据", func(t *testing.T) { // 模拟 ES 返回的 mapping 响应,键名不完全匹配(如带日期后缀的索引别名) mapping := map[string]interface{}{ "logs-2024.01.01": map[string]interface{}{ "mappings": map[string]interface{}{ "properties": map[string]interface{}{ "message": map[string]interface{}{"type": "text"}, }, }, }, } columns := extractColumnsFromMapping("non-matching-key", mapping) if len(columns) != 1 { t.Fatalf("应自动查找 mapping 数据,期望 1 个字段,实际 %d 个", len(columns)) } }) } // TestIsJSONDSL 测试 JSON DSL 检测。 func TestIsJSONDSL(t *testing.T) { tests := []struct { name string input string expected bool }{ {"JSON DSL", `{"query":{"match_all":{}}}`, true}, {"简单字符串", "hello world", false}, {"空字符串", "", false}, {"JSON 对象以空格开头", ` {"query":{}}`, true}, {"非查询 JSON 前缀", `[1,2,3]`, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isJSONDSL(tt.input); got != tt.expected { t.Fatalf("isJSONDSL(%q) = %v,期望 %v", tt.input, got, tt.expected) } }) } } // TestExtractEsFieldType 测试字段类型提取。 func TestExtractEsFieldType(t *testing.T) { tests := []struct { name string prop interface{} expected string }{ { name: "标准字段类型", prop: map[string]interface{}{"type": "keyword"}, expected: "keyword", }, { name: "嵌套对象类型", prop: map[string]interface{}{"properties": map[string]interface{}{}}, expected: "object", }, { name: "无 type 无 properties", prop: map[string]interface{}{"enabled": true}, expected: "unknown", }, { name: "非 map 类型", prop: "invalid", expected: "unknown", }, { name: "nil 值", prop: nil, expected: "unknown", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := extractEsFieldType(tt.prop); got != tt.expected { t.Fatalf("extractEsFieldType() = %q,期望 %q", got, tt.expected) } }) } } // TestResolveEsIndexName 测试索引名解析。 func TestResolveEsIndexName(t *testing.T) { tests := []struct { name string dbName string tableName string defaultDB string expected string }{ { name: "优先使用 tableName", dbName: "db1", tableName: "tbl1", defaultDB: "default", expected: "tbl1", }, { name: "回退到 dbName", dbName: "db1", tableName: "", defaultDB: "default", expected: "db1", }, { name: "回退到默认值", dbName: "", tableName: "", defaultDB: "default", expected: "default", }, { name: "全部为空", dbName: "", tableName: "", defaultDB: "", expected: "", }, { name: "空白字符等同于空", dbName: " ", tableName: " ", defaultDB: "default", expected: "default", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := resolveEsIndexName(tt.dbName, tt.tableName, tt.defaultDB) if got != tt.expected { t.Fatalf("resolveEsIndexName(%q, %q, %q) = %q,期望 %q", tt.dbName, tt.tableName, tt.defaultDB, got, tt.expected) } }) } } // TestESMockIntegration 使用完整 mock 服务器的集成测试。 func TestESMockIntegration(t *testing.T) { // 构造完整的 mock mapping 响应 mappingData := buildMockESMappingResponse("products", map[string]string{ "name": "text", "price": "float", "in_stock": "boolean", "created_at": "date", "description": "text", }) server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { // Ping case r.Method == http.MethodHead && path == "/": w.WriteHeader(http.StatusOK) // 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{}{ "products": map[string]interface{}{ "settings": map[string]interface{}{"index": map[string]interface{}{"number_of_shards": "1"}}, "mappings": map[string]interface{}{"properties": map[string]interface{}{"name": map[string]interface{}{"type": "text"}}}, }, }) // Search case r.Method == http.MethodPost && strings.HasSuffix(path, "/_search"): w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "hits": { "total": {"value": 3}, "hits": [ {"_index": "products", "_id": "1", "_source": {"name": "商品A", "price": 99.9}}, {"_index": "products", "_id": "2", "_source": {"name": "商品B", "price": 199.9}}, {"_index": "products", "_id": "3", "_source": {"name": "商品C", "price": 299.9}} ] } }`)) default: w.WriteHeader(http.StatusNotFound) } }) db := newTestESDB(t, server.URL, "products") // 验证 Ping if err := db.Ping(); err != nil { t.Fatalf("Ping 失败:%v", err) } // 验证 GetDatabases(应返回全部索引包括系统索引) databases, err := db.GetDatabases() if err != nil { t.Fatalf("GetDatabases 失败:%v", err) } slices.Sort(databases) if len(databases) != 3 || databases[0] != ".internal" || databases[1] != "orders" || databases[2] != "products" { t.Fatalf("GetDatabases 期望 [.internal, orders, products],实际:%v", databases) } // 验证 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) } hasAlias := false for _, tbl := range tables { if tbl == "products-alias" { hasAlias = true } } if !hasAlias { t.Fatalf("GetTables 应包含别名 products-alias,实际:%v", tables) } // 验证 GetColumns columns, err := db.GetColumns("products", "") if err != nil { t.Fatalf("GetColumns 失败:%v", err) } if len(columns) != 6 { // _id + 5 个 mapping 字段 t.Fatalf("GetColumns 期望 6 个字段,实际 %d", len(columns)) } // 验证 DSL 查询 rows, _, err := db.Query(`{"query":{"match_all":{}}}`) if err != nil { t.Fatalf("DSL 查询失败:%v", err) } if len(rows) != 3 { t.Fatalf("DSL 查询期望 3 条结果,实际 %d", len(rows)) } // 验证 query_string 查询 rows, _, err = db.Query("商品") if err != nil { t.Fatalf("query_string 查询失败:%v", err) } if len(rows) != 3 { t.Fatalf("query_string 查询期望 3 条结果,实际 %d", len(rows)) } // 验证 GetCreateStatement stmt, err := db.GetCreateStatement("products", "") if err != nil { t.Fatalf("GetCreateStatement 失败:%v", err) } if !strings.Contains(stmt, "products") { t.Fatalf("GetCreateStatement 应包含索引名,实际:%s", stmt) } // 验证 Exec 不支持 _, err = db.Exec("DELETE products") if err == nil || !strings.Contains(err.Error(), "不支持") { t.Fatalf("Exec 应返回不支持错误,实际:%v", err) } // 验证 GetForeignKeys / GetTriggers 返回空 fks, _ := db.GetForeignKeys("products", "") if len(fks) != 0 { t.Fatalf("GetForeignKeys 应返回空,实际:%d", len(fks)) } triggers, _ := db.GetTriggers("products", "") if len(triggers) != 0 { 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) } }) } }