🐛 fix(elasticsearch): 修复 ES SQL 分号结尾查询无结果

- 兼容 SELECT FROM 索引名后直接接分号的查询语句
- 避免解析失败后退回全索引 query_string 导致空结果
- 补充 ES SQL 转 _search 的回归测试

Fixes #590
This commit is contained in:
Syngnat
2026-06-24 23:36:37 +08:00
parent 6d1c034052
commit 06a984f39d
2 changed files with 91 additions and 23 deletions

View File

@@ -173,7 +173,7 @@ func resolveEsIndexName(dbName, tableName, defaultDB string) string {
// - "a"."b"."c" → a.b.c引号包裹的多段标识符
// - "a.b.c" → a.b.c单引号包裹的完整名称
// - my_index → my_index无引号
var reESSQLFrom = regexp.MustCompile(`(?i)\bFROM\s+(?:"([^"]+)"(?:\."([^"]+)")*|([a-zA-Z0-9_*][a-zA-Z0-9_.\-*]*))\s`)
var reESSQLFrom = regexp.MustCompile(`(?i)\bFROM\s+(?:"([^"]+)"(?:\."([^"]+)")*|([a-zA-Z0-9_*][a-zA-Z0-9_.\-*]*))\s*(?:;|\s|$)`)
// extractESSQLFromTable 从 SQL 语句中提取 FROM 后的索引名。
// 支持多段引号格式(如 "schema"."table"."partition")和单段格式。
@@ -203,6 +203,8 @@ func extractESSQLFromTable(sql string) string {
var parts []string
for _, seg := range strings.Split(rest, ".") {
s := strings.TrimSpace(seg)
s = strings.TrimSuffix(s, ";")
s = strings.TrimSpace(s)
s = strings.Trim(s, `"`)
if s != "" {
parts = append(parts, s)

View File

@@ -4,6 +4,7 @@ package db
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
@@ -109,11 +110,11 @@ func TestElasticsearchGetDatabases(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{}{},
"logs-2024": map[string]interface{}{},
"users": map[string]interface{}{},
".security": map[string]interface{}{},
".kibana_1": map[string]interface{}{},
"products": map[string]interface{}{},
})
return
}
@@ -247,8 +248,8 @@ func TestElasticsearchGetColumns(t *testing.T) {
if err != nil {
t.Fatalf("GetColumns 失败:%v", err)
}
if len(columns) != 4 {
t.Fatalf("期望 4 个字段,实际 %d", len(columns))
if len(columns) != 5 {
t.Fatalf("期望 5 个字段(含 _id,实际 %d", len(columns))
}
// 验证字段类型映射
@@ -261,9 +262,15 @@ func TestElasticsearchGetColumns(t *testing.T) {
t.Fatalf("字段 %q 类型期望 %q实际 %q", name, expectedType, typeMap[name])
}
}
if typeMap["_id"] != "keyword" {
t.Fatalf("_id 字段类型期望 keyword实际 %q", typeMap["_id"])
}
// 验证所有字段标记为可空
// 验证业务字段标记为可空_id 是 ES 文档定位列,不沿用 mapping nullable。
for _, col := range columns {
if col.Name == "_id" {
continue
}
if col.Nullable != "YES" {
t.Fatalf("字段 %q Nullable 期望 YES实际 %q", col.Name, col.Nullable)
}
@@ -313,8 +320,8 @@ func TestElasticsearchGetAllColumns(t *testing.T) {
if err != nil {
t.Fatalf("GetAllColumns 失败:%v", err)
}
if len(columns) != 2 {
t.Fatalf("期望 2 个字段,实际 %d", len(columns))
if len(columns) != 3 {
t.Fatalf("期望 3 个字段(含 _id,实际 %d", len(columns))
}
// 验证每个字段都带有表名标识
@@ -605,11 +612,16 @@ func TestElasticsearchGetIndexes(t *testing.T) {
if err != nil {
t.Fatalf("GetIndexes 失败:%v", err)
}
if len(indexes) != 1 {
t.Fatalf("期望 1 个索引信息,实际 %d", len(indexes))
if len(indexes) != 2 {
t.Fatalf("期望 2 个索引信息(含 PRIMARY,实际 %d", len(indexes))
}
idx := indexes[0]
primary := indexes[0]
if primary.Name != "PRIMARY" || primary.ColumnName != "_id" || primary.IndexType != "PRIMARY" {
t.Fatalf("第一个索引应为 _id PRIMARY实际%#v", primary)
}
idx := indexes[1]
if idx.Name != "test-index" {
t.Fatalf("索引名期望 test-index实际%s", idx.Name)
}
@@ -760,15 +772,15 @@ func TestExtractColumnsFromMapping(t *testing.T) {
}
columns := extractColumnsFromMapping("test-index", mapping)
if len(columns) != 3 {
t.Fatalf("期望 3 个字段,实际 %d", len(columns))
if len(columns) != 4 {
t.Fatalf("期望 4 个字段(含 _id,实际 %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"}
expectedTypes := map[string]string{"_id": "keyword", "title": "text", "count": "long", "tags": "keyword"}
for name, expectedType := range expectedTypes {
if typeMap[name] != expectedType {
t.Fatalf("字段 %q 类型期望 %q实际 %q", name, expectedType, typeMap[name])
@@ -791,11 +803,18 @@ func TestExtractColumnsFromMapping(t *testing.T) {
}
columns := extractColumnsFromMapping("idx", mapping)
if len(columns) != 1 {
t.Fatalf("期望 1 个字段,实际 %d", len(columns))
if len(columns) != 2 {
t.Fatalf("期望 2 个字段(含 _id,实际 %d", len(columns))
}
if columns[0].Comment != "用户邮箱地址" {
t.Fatalf("期望注释 '用户邮箱地址',实际:%q", columns[0].Comment)
var emailComment string
for _, col := range columns {
if col.Name == "email" {
emailComment = col.Comment
break
}
}
if emailComment != "用户邮箱地址" {
t.Fatalf("期望 email 注释 '用户邮箱地址',实际:%q", emailComment)
}
})
@@ -831,8 +850,8 @@ func TestExtractColumnsFromMapping(t *testing.T) {
},
}
columns := extractColumnsFromMapping("non-matching-key", mapping)
if len(columns) != 1 {
t.Fatalf("应自动查找 mapping 数据,期望 1 个字段,实际 %d 个", len(columns))
if len(columns) != 2 {
t.Fatalf("应自动查找 mapping 数据,期望 2 个字段(含 _id,实际 %d 个", len(columns))
}
})
}
@@ -1546,6 +1565,49 @@ func TestElasticsearchSourceFlatten(t *testing.T) {
})
}
func TestElasticsearchSQLSelectDoesNotRequireXPackSQL(t *testing.T) {
var capturedPath string
var capturedBody string
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/_search") {
w.WriteHeader(http.StatusNotFound)
return
}
capturedPath = r.URL.Path
body, _ := io.ReadAll(r.Body)
capturedBody = string(body)
if strings.Contains(capturedBody, "query_string") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"hits":{"total":{"value":0},"hits":[]}}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"hits": {
"total": {"value": 1},
"hits": [
{"_index": "products", "_id": "1", "_source": {"name": "商品A", "price": 99.9}}
]
}
}`))
})
db := newTestESDB(t, server.URL, "")
rows, columns, err := db.Query(`SELECT * FROM "products";`)
if err != nil {
t.Fatalf("ES SQL 查询应通过 _search 转换执行成功:%v", err)
}
if capturedPath != "/products/_search" {
t.Fatalf("ES SQL 查询应转为 products/_search不应依赖 _sql实际路径%s", capturedPath)
}
if strings.Contains(capturedBody, "query_string") {
t.Fatalf("SELECT 查询不应降级为 query_string实际请求体%s", capturedBody)
}
if len(rows) != 1 || rows[0]["name"] != "商品A" {
t.Fatalf("期望返回 products 命中数据,实际 rows=%#v columns=%v", rows, columns)
}
}
// ---- extractESSQLFromTable 测试 ----
func TestESExtractSQLFromTable(t *testing.T) {
@@ -1560,6 +1622,8 @@ func TestESExtractSQLFromTable(t *testing.T) {
{"通配符表名", `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 * FROM "app_log_user";`, "app_log_user"},
{"带分号的无引号表名", `SELECT * FROM my_index;`, "my_index"},
{"非 SELECT 语句", `{"query": {"match_all": {}}}`, ""},
{"空语句", ``, ""},
{"FROM 语句片段", `FROM "test"`, "test"},
@@ -1591,6 +1655,8 @@ func TestESParseSQL(t *testing.T) {
{"带点索引名", `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},
{"带分号", `SELECT * FROM "my_index";`, "my_index", 0, 0, true},
{"LIMIT 后带分号", `SELECT * FROM "my_index" LIMIT 100;`, "my_index", 100, 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},