Files

194 lines
5.2 KiB
Go
Raw Permalink 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 db
// es.go —— Elasticsearch 原生接入(HTTP + X-Pack SQL REST API)。
//
// 为什么不用官方 JDBC:x-pack-sql-jdbc 在 basic 许可下直接拒绝工作
// ("current license is non-compliant for [jdbc]",实测 8.15.3),而 SQL REST API
// 在 basic 许可可用 —— 走 net/http 零依赖接入,也免除 JVM 负担。
//
// 能力边界(v1,对标 SQL 工作台):
// - 对象树:单库节点 "default" → SHOW TABLES 列出索引
// - 查询:SQL 编辑器里的 ES SQL(SELECT/SHOW/DESC…)直发 /_sql
// - ES SQL 只读:DDL/DML 由 ES 自身拒绝;危险语句拦截仍在本层先过一遍
// - 分页:/_sql 为游标式,前端 offset 翻页暂不支持(首屏 fetch_size 截断)
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func isES(d string) bool { return driverKey(d) == "elasticsearch" }
func esBase(c ConnReq) string {
host := strings.TrimSpace(c.Host)
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 9200
}
return fmt.Sprintf("http://%s:%d", host, port)
}
// esDo 发请求并在设置了用户时启用 Basic Auth(ES 开启 security 时用)。
func esDo(ctx context.Context, c ConnReq, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, esBase(c)+path, rd)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
}
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("ES HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func esPing(c ConnReq) error {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var info struct {
Tagline string `json:"tagline"`
}
if err := esDo(ctx, c, http.MethodGet, "/", nil, &info); err != nil {
return err
}
if info.Tagline == "" {
return fmt.Errorf("ES 响应异常:缺少 tagline")
}
return nil
}
// esSQLResult /_sql 的 JSON 响应(format=json)。
type esSQLResult struct {
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
Rows [][]interface{} `json:"rows"`
}
func esRunSQL(ctx context.Context, c ConnReq, sqlText string, fetchSize int) (ExecResult, error) {
var r esSQLResult
body := map[string]any{"query": sqlText}
if fetchSize > 0 {
body["fetch_size"] = fetchSize
}
if err := esDo(ctx, c, http.MethodPost, "/_sql", body, &r); err != nil {
return ExecResult{}, err
}
cols := make([]string, 0, len(r.Columns))
for _, col := range r.Columns {
cols = append(cols, col.Name)
}
rows := make([]map[string]interface{}, 0, len(r.Rows))
for _, row := range r.Rows {
m := make(map[string]interface{}, len(cols))
for i, v := range row {
if i < len(cols) {
m[cols[i]] = v
}
}
rows = append(rows, m)
}
return ExecResult{
Columns: cols,
Rows: rows,
RowCount: len(rows),
Truncated: fetchSize > 0 && len(rows) >= fetchSize,
Limit: fetchSize,
}, nil
}
func esListDatabases() []string { return []string{"default"} }
func esListTables(ctx context.Context, c ConnReq) ([]string, error) {
res, err := esRunSQL(ctx, c, "SHOW TABLES", 10000)
if err != nil {
return nil, err
}
out := []string{}
if len(res.Columns) == 0 {
return out, nil
}
// SHOW TABLES 列为 [catalog, name, type, kind]:取 "name" 列(ES 8 实测),退化为首列
idx := 0
for i, col := range res.Columns {
if col == "name" {
idx = i
break
}
}
for _, row := range res.Rows {
if v, ok := row[res.Columns[idx]].(string); ok && v != "" {
out = append(out, v)
}
}
return out, nil
}
func esListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
table := strings.TrimSpace(c.Table)
if table == "" {
return nil, fmt.Errorf("缺少表名")
}
res, err := esRunSQL(ctx, c, "DESCRIBE \""+strings.ReplaceAll(table, "\"", "")+"\"", 1000)
if err != nil {
return nil, err
}
cols := []ColumnInfo{}
for _, row := range res.Rows {
name, _ := row["column"].(string)
typ, _ := row["type"].(string)
if name == "" {
continue
}
cols = append(cols, ColumnInfo{Name: name, Type: typ, Nullable: true})
}
return cols, nil
}
// esExecSQL 全部按查询处理:ES SQL 只读,写语句由 ES 拒绝;危险语句在 ops.go 入口已拦。
func esExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
if c.Offset > 0 {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "ES 游标分页暂不支持翻页(仅首屏,可缩小范围重查)", TimeMs: time.Since(start).Milliseconds()}
}
limit := rowLimit(c)
if limit > 10000 {
limit = 10000
}
res, err := esRunSQL(ctx, c, strings.TrimSpace(c.SQL), limit)
if err != nil {
return execFail(start, err)
}
res.TimeMs = time.Since(start).Milliseconds()
return res
}