Files
MyGoNavi/internal/esconsole/sql_compat.go
Syngnat 88434e77a9 feat(elasticsearch): 新增 REST 控制台并完善安全执行链
- 复用查询页提供 ES 控制台、模板、AI 辅助及原始与表格结果
- 新增统一解析分类、危险确认、连接保护和受控 REST 执行
- 扩展 driver agent、版本适配、Wails 绑定、多语言与文档
- 修复完整回归中发现的跨平台和前端基线用例问题

Refs #782
2026-08-01 14:58:12 +08:00

455 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package esconsole
import (
"bytes"
"encoding/json"
"regexp"
"strconv"
"strings"
)
// SimplifiedSelect is the deliberately small SQL compatibility surface that
// GoNavi exposes for Elasticsearch. It is converted to a classified REST
// request before execution; the SQL text is never passed to an HTTP client.
type SimplifiedSelect struct {
Target string
Columns string
Where string
OrderBy string
Limit int
Offset int
Count bool
}
var (
esSQLFromKeyword = regexp.MustCompile(`(?i)\bFROM\s+`)
esSQLTarget = regexp.MustCompile(`^[A-Za-z0-9_*][A-Za-z0-9_.\-*]*$`)
esSQLLimit = regexp.MustCompile(`(?i)\bLIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+))?`)
esSQLOffset = regexp.MustCompile(`(?i)\bOFFSET\s+(\d+)`)
esSQLOrderBy = regexp.MustCompile(`(?i)\bORDER\s+BY\s+(.+?)(?:\bLIMIT\b|\bOFFSET\b|$)`)
esSQLWhere = regexp.MustCompile(`(?i)\bWHERE\s+(.+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)`)
esSQLCount = regexp.MustCompile(`(?i)\bCOUNT\s*\(`)
)
// ParseSimplifiedSelect parses the legacy SELECT compatibility syntax while
// applying an intentionally narrow index grammar. In particular, URL syntax,
// remote-cluster targets, encoded separators and control characters cannot be
// represented by this grammar.
func ParseSimplifiedSelect(source string) (SimplifiedSelect, error) {
sql := strings.TrimSpace(source)
if len(sql) < len("SELECT") || !strings.EqualFold(sql[:len("SELECT")], "SELECT") {
return SimplifiedSelect{}, parseError(CodeInvalidHeader, "only simplified SELECT is supported by the Elasticsearch SQL compatibility mode", nil)
}
if len(sql) > len("SELECT") && !isESSQLSpace(sql[len("SELECT")]) {
return SimplifiedSelect{}, parseError(CodeInvalidHeader, "invalid simplified SELECT statement", nil)
}
from := esSQLFromKeyword.FindStringIndex(sql)
if from == nil || from[0] <= len("SELECT") {
return SimplifiedSelect{}, parseError(CodeInvalidHeader, "simplified SELECT requires one FROM index", nil)
}
target, consumed, err := parseESSQLTarget(sql[from[1]:])
if err != nil {
return SimplifiedSelect{}, err
}
afterTarget := sql[from[1]+consumed:]
if trimmed := strings.TrimSpace(afterTarget); strings.HasPrefix(trimmed, ".") || strings.HasPrefix(trimmed, `"`) {
return SimplifiedSelect{}, parseError(CodeUnsafePath, "invalid Elasticsearch index in simplified SELECT", nil)
}
parsed := SimplifiedSelect{
Target: target,
Columns: trimESSQLClause(sql[len("SELECT"):from[0]]),
}
if parsed.Columns == "" {
parsed.Columns = "*"
}
parsed.Count = esSQLCount.MatchString(parsed.Columns)
if match := esSQLWhere.FindStringSubmatch(sql); len(match) >= 2 {
parsed.Where = trimESSQLClause(match[1])
}
if match := esSQLOrderBy.FindStringSubmatch(sql); len(match) >= 2 {
parsed.OrderBy = trimESSQLClause(match[1])
}
if match := esSQLLimit.FindStringSubmatch(sql); len(match) >= 2 {
parsed.Limit, _ = strconv.Atoi(match[1])
if len(match) >= 3 && match[2] != "" {
parsed.Offset, _ = strconv.Atoi(match[2])
}
}
if parsed.Offset == 0 {
if match := esSQLOffset.FindStringSubmatch(sql); len(match) >= 2 {
parsed.Offset, _ = strconv.Atoi(match[1])
}
}
return parsed, nil
}
func parseESSQLTarget(rest string) (string, int, error) {
leading := len(rest) - len(strings.TrimLeft(rest, " \t\r\n"))
value := rest[leading:]
if value == "" {
return "", 0, parseError(CodeUnsafePath, "simplified SELECT requires an Elasticsearch index", nil)
}
if value[0] != '"' {
end := 0
for end < len(value) && !isESSQLSpace(value[end]) && value[end] != ';' {
end++
}
target := value[:end]
if !esSQLTarget.MatchString(target) {
return "", 0, parseError(CodeUnsafePath, "invalid Elasticsearch index in simplified SELECT", nil)
}
return target, leading + end, nil
}
var parts []string
position := 0
for {
if position >= len(value) || value[position] != '"' {
return "", 0, parseError(CodeUnsafePath, "invalid quoted Elasticsearch index in simplified SELECT", nil)
}
end := strings.IndexByte(value[position+1:], '"')
if end < 0 {
return "", 0, parseError(CodeUnsafePath, "unterminated quoted Elasticsearch index", nil)
}
end += position + 1
part := value[position+1 : end]
if !esSQLTarget.MatchString(part) {
return "", 0, parseError(CodeUnsafePath, "invalid quoted Elasticsearch index in simplified SELECT", nil)
}
parts = append(parts, part)
position = end + 1
if position >= len(value) || value[position] != '.' {
break
}
position++
}
if position < len(value) && !isESSQLSpace(value[position]) && value[position] != ';' {
return "", 0, parseError(CodeUnsafePath, "invalid Elasticsearch index suffix in simplified SELECT", nil)
}
return strings.Join(parts, "."), leading + position, nil
}
func isESSQLSpace(value byte) bool {
return value == ' ' || value == '\t' || value == '\r' || value == '\n'
}
func trimESSQLClause(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimRight(value, " \t\r\n;")
return strings.TrimSpace(value)
}
func buildSimplifiedSelectRequest(source string, serverMajor int) (Batch, error) {
parsed, err := ParseSimplifiedSelect(source)
if err != nil {
return Batch{}, err
}
query := convertESSQLWhere(parsed.Where)
if query == nil {
query = map[string]interface{}{"match_all": map[string]interface{}{}}
}
payload := map[string]interface{}{"query": query}
route := "_search"
if parsed.Count {
route = "_count"
} else {
if parsed.Limit > 0 {
payload["size"] = parsed.Limit
} else {
payload["size"] = 200
}
if parsed.Offset > 0 {
payload["from"] = parsed.Offset
}
if sorts := convertESSQLOrderBy(parsed.OrderBy); len(sorts) > 0 {
payload["sort"] = sorts
}
}
body, err := json.Marshal(payload)
if err != nil {
return Batch{}, parseError(CodeInvalidJSON, "unable to encode simplified SELECT request", err)
}
requestPath, _, err := normalizeRequestTarget("/" + parsed.Target + "/" + route)
if err != nil {
return Batch{}, err
}
batch := Batch{Requests: []Request{{
Method: "POST",
Path: requestPath,
Body: string(body),
Kind: KindREST,
BodyKind: BodyJSON,
}}}
if err := prepareBatch(&batch, serverMajor); err != nil {
return Batch{}, err
}
finalizeBatch(&batch)
if batch.Blocked {
return Batch{}, parseError(CodeUnsafePath, batch.Requests[0].BlockReason, nil)
}
return batch, nil
}
func convertESSQLWhere(where string) map[string]interface{} {
where = strings.TrimSpace(where)
if where == "" {
return nil
}
for len(where) >= 2 && where[0] == '(' && where[len(where)-1] == ')' && balancedESSQLParens(where[1:len(where)-1]) {
where = strings.TrimSpace(where[1 : len(where)-1])
}
if parts := splitESSQLTopLevel(where, "AND"); len(parts) > 1 {
clauses := make([]map[string]interface{}, 0, len(parts))
for _, part := range parts {
if query := convertESSQLWhere(part); query != nil {
clauses = append(clauses, query)
}
}
if len(clauses) == 1 {
return clauses[0]
}
if len(clauses) > 1 {
return map[string]interface{}{"bool": map[string]interface{}{"must": clauses}}
}
return nil
}
if parts := splitESSQLTopLevel(where, "OR"); len(parts) > 1 {
clauses := make([]map[string]interface{}, 0, len(parts))
for _, part := range parts {
if query := convertESSQLWhere(part); query != nil {
clauses = append(clauses, query)
}
}
if len(clauses) == 1 {
return clauses[0]
}
if len(clauses) > 1 {
return map[string]interface{}{"bool": map[string]interface{}{"should": clauses}}
}
return nil
}
return parseESSQLCondition(where)
}
func parseESSQLCondition(condition string) map[string]interface{} {
condition = strings.TrimSpace(strings.Trim(strings.TrimSpace(condition), "()"))
if condition == "" {
return nil
}
patterns := []struct {
expression string
build func(string) map[string]interface{}
}{
{`(?i)^"?(.+?)"?\s+IS\s+NOT\s+NULL$`, func(field string) map[string]interface{} {
return map[string]interface{}{"exists": map[string]interface{}{"field": cleanESSQLIdentifier(field)}}
}},
{`(?i)^"?(.+?)"?\s+IS\s+NULL$`, func(field string) map[string]interface{} {
return map[string]interface{}{"bool": map[string]interface{}{"must_not": []map[string]interface{}{{"exists": map[string]interface{}{"field": cleanESSQLIdentifier(field)}}}}}
}},
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern.expression)
if match := re.FindStringSubmatch(condition); len(match) == 2 {
return pattern.build(match[1])
}
}
for _, spec := range []struct {
expression string
negated bool
}{
{`(?i)^"?(.+?)"?\s+NOT\s+LIKE\s+'(.+)'$`, true},
{`(?i)^"?(.+?)"?\s+LIKE\s+'(.+)'$`, false},
} {
re := regexp.MustCompile(spec.expression)
if match := re.FindStringSubmatch(condition); len(match) == 3 {
pattern := strings.NewReplacer("%", "*", "_", "?").Replace(match[2])
query := map[string]interface{}{"wildcard": map[string]interface{}{cleanESSQLIdentifier(match[1]): pattern}}
if spec.negated {
return map[string]interface{}{"bool": map[string]interface{}{"must_not": []map[string]interface{}{query}}}
}
return query
}
}
for _, operator := range []string{"!=", "<>", ">=", "<=", ">", "<", "="} {
if position := findESSQLOperator(condition, operator); position >= 0 {
field := cleanESSQLIdentifier(condition[:position])
value := parseESSQLValue(condition[position+len(operator):])
if field == "" {
break
}
switch operator {
case "!=", "<>":
return map[string]interface{}{"bool": map[string]interface{}{"must_not": []map[string]interface{}{{"term": map[string]interface{}{field: value}}}}}
case ">=", "<=", ">", "<":
comparison := map[string]string{">=": "gte", "<=": "lte", ">": "gt", "<": "lt"}[operator]
return map[string]interface{}{"range": map[string]interface{}{field: map[string]interface{}{comparison: value}}}
default:
return map[string]interface{}{"term": map[string]interface{}{field: value}}
}
}
}
return map[string]interface{}{"query_string": map[string]interface{}{"query": condition}}
}
func cleanESSQLIdentifier(value string) string {
return strings.Trim(strings.TrimSpace(value), `"'`)
}
func parseESSQLValue(value string) interface{} {
value = strings.Trim(strings.TrimSpace(value), `"'`)
if number, err := strconv.ParseFloat(value, 64); err == nil {
return number
}
if strings.EqualFold(value, "true") {
return true
}
if strings.EqualFold(value, "false") {
return false
}
return value
}
func findESSQLOperator(condition, operator string) int {
quote := byte(0)
depth := 0
for index := 0; index+len(operator) <= len(condition); index++ {
character := condition[index]
if character == '\'' || character == '"' {
if quote == 0 {
quote = character
} else if quote == character {
quote = 0
}
continue
}
if quote != 0 {
continue
}
if character == '(' {
depth++
continue
}
if character == ')' {
depth--
continue
}
if depth == 0 && condition[index:index+len(operator)] == operator {
if (operator == ">" || operator == "<") && index+1 < len(condition) && (condition[index+1] == '=' || condition[index+1] == '>') {
continue
}
return index
}
}
return -1
}
func splitESSQLTopLevel(value, keyword string) []string {
upper := strings.ToUpper(value)
quote := byte(0)
depth := 0
start := 0
var parts []string
for index := 0; index < len(value); index++ {
character := value[index]
if character == '\'' || character == '"' {
if quote == 0 {
quote = character
} else if quote == character {
quote = 0
}
continue
}
if quote != 0 {
continue
}
if character == '(' {
depth++
continue
}
if character == ')' {
depth--
continue
}
end := index + len(keyword)
if depth == 0 && end <= len(value) && upper[index:end] == keyword {
beforeOK := index == 0 || strings.ContainsRune(" ()\t\r\n", rune(value[index-1]))
afterOK := end == len(value) || strings.ContainsRune(" ()\t\r\n", rune(value[end]))
if beforeOK && afterOK {
parts = append(parts, strings.TrimSpace(value[start:index]))
start = end
}
}
}
parts = append(parts, strings.TrimSpace(value[start:]))
return parts
}
func balancedESSQLParens(value string) bool {
depth := 0
quote := byte(0)
for index := 0; index < len(value); index++ {
character := value[index]
if character == '\'' || character == '"' {
if quote == 0 {
quote = character
} else if quote == character {
quote = 0
}
continue
}
if quote != 0 {
continue
}
if character == '(' {
depth++
} else if character == ')' {
depth--
if depth < 0 {
return false
}
}
}
return depth == 0 && quote == 0
}
func convertESSQLOrderBy(orderBy string) []map[string]interface{} {
var sorts []map[string]interface{}
for _, raw := range strings.Split(orderBy, ",") {
fields := strings.Fields(strings.TrimSpace(raw))
if len(fields) == 0 {
continue
}
field := cleanESSQLIdentifier(fields[0])
if field == "" {
continue
}
order := "asc"
if len(fields) >= 2 && strings.EqualFold(fields[1], "DESC") {
order = "desc"
}
sorts = append(sorts, map[string]interface{}{field: order})
}
return sorts
}
// CompactSimplifiedSelectBody is used by legacy DB tests and adapters that
// need the exact normalized REST body without exposing parser internals.
func CompactSimplifiedSelectBody(source string) (SimplifiedSelect, string, error) {
parsed, err := ParseSimplifiedSelect(source)
if err != nil {
return SimplifiedSelect{}, "", err
}
batch, err := buildSimplifiedSelectRequest(source, 0)
if err != nil {
return SimplifiedSelect{}, "", err
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(batch.Requests[0].Body)); err != nil {
return SimplifiedSelect{}, "", err
}
return parsed, compact.String(), nil
}