feat(query-runner): soporte completo Redis
Backend: - Agrega github.com/redis/go-redis/v9 como dependencia - isRedisDriver() detecta 'redis' y 'valkey' en nombre del tipo de BD - redisConnect(): abre cliente Redis con auth, host, port, db index - redisTestConnection(): PING para probar conexión - redisListDatabases(): lee CONFIG GET databases → devuelve db0..dbN - redisListKeys(): SCAN iterativo, hasta 200 keys del db seleccionado - redisExecuteCommand(): parsea comandos línea a línea, multi-comando devuelve tabla command/result/error; comando único devuelve QueryResult tipado (string, int64, []any para KEYS/SMEMBERS/LRANGE, HGETALL como field/value table) - redisParseArgs(): tokenizador respetando comillas simples y dobles - Todos los comandos se guardan en query_history Frontend: - isRedis: false estado y reset en onConxChange() - Detecta redis/valkey en tipo_db.nombre - Editor cambia label 'Redis CLI', placeholder con ejemplos Redis - Sidebar: 'Keys' en lugar de 'Tablas' para Redis - insertTable(): TYPE + TTL + GET para inspeccionar una key de Redis - Panel de chips rojos: KEYS *, DBSIZE, INFO, CONFIG GET, CLIENT LIST, SLOWLOG - Generador Redis: select de 24 comandos + formulario contextual (key, value, ttl, field según el comando elegido) - generateRedisCmd(): genera el comando en el editor - Badge MySQL no aparece cuando es Redis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
fc647944b0
commit
63d124215b
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
@@ -84,6 +86,9 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult {
|
||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return mongoExecuteSQL(conx, database, sqlText)
|
||||
}
|
||||
if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return redisExecuteCommand(conx, database, sqlText)
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
db, err := openDynamicDB(conx)
|
||||
@@ -204,6 +209,9 @@ func ListDatabases(conx models.ConxDb) ([]string, error) {
|
||||
if isMongoDriver(driver) {
|
||||
return mongoListDatabases(conx)
|
||||
}
|
||||
if isRedisDriver(driver) {
|
||||
return redisListDatabases(conx)
|
||||
}
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -245,6 +253,9 @@ func ListTables(conx models.ConxDb, database string) ([]string, error) {
|
||||
if isMongoDriver(driver) {
|
||||
return mongoListCollections(conx, database)
|
||||
}
|
||||
if isRedisDriver(driver) {
|
||||
return redisListKeys(conx, database)
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
@@ -294,6 +305,9 @@ func TestDBConnection(conx models.ConxDb) error {
|
||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return mongoTestConnection(conx)
|
||||
}
|
||||
if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
return redisTestConnection(conx)
|
||||
}
|
||||
db, err := openDynamicDB(conx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -465,6 +479,317 @@ func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs i
|
||||
})
|
||||
}
|
||||
|
||||
// ── Redis ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func isRedisDriver(driver string) bool {
|
||||
return strings.Contains(driver, "redis") || strings.Contains(driver, "valkey")
|
||||
}
|
||||
|
||||
func redisConnect(c models.ConxDb, dbIndex int) (*goredis.Client, error) {
|
||||
host := c.Servidor.IpServidor
|
||||
port := c.Puerto
|
||||
pass := c.Password
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
client := goredis.NewClient(&goredis.Options{
|
||||
Addr: fmt.Sprintf("%s:%s", host, port),
|
||||
Password: pass,
|
||||
DB: dbIndex,
|
||||
DialTimeout: 8 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("no se pudo conectar a Redis: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func redisTestConnection(c models.ConxDb) error {
|
||||
client, err := redisConnect(c, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// redisListDatabases devuelve db0..dbN según CONFIG GET databases.
|
||||
func redisListDatabases(c models.ConxDb) ([]string, error) {
|
||||
client, err := redisConnect(c, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
total := 16 // default
|
||||
vals, err2 := client.ConfigGet(ctx, "databases").Result()
|
||||
if err2 == nil && len(vals) >= 2 {
|
||||
if n, parseErr := strconv.Atoi(fmt.Sprint(vals["databases"])); parseErr == nil && n > 0 {
|
||||
total = n
|
||||
}
|
||||
}
|
||||
|
||||
dbs := make([]string, total)
|
||||
for i := range dbs {
|
||||
dbs[i] = fmt.Sprintf("db%d", i)
|
||||
}
|
||||
return dbs, nil
|
||||
}
|
||||
|
||||
// redisListKeys devuelve hasta 200 keys del db seleccionado (SCAN con limit).
|
||||
func redisListKeys(c models.ConxDb, database string) ([]string, error) {
|
||||
dbIndex := 0
|
||||
if strings.HasPrefix(database, "db") {
|
||||
if n, err := strconv.Atoi(database[2:]); err == nil {
|
||||
dbIndex = n
|
||||
}
|
||||
}
|
||||
client, err := redisConnect(c, dbIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var keys []string
|
||||
iter := client.Scan(ctx, 0, "*", 200).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
keys = append(keys, iter.Val())
|
||||
if len(keys) >= 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// redisExecuteCommand parsea y ejecuta un comando Redis.
|
||||
// Los comandos se escriben como en redis-cli: GET key / SET key value / etc.
|
||||
// Soporta múltiples líneas: cada línea no vacía es un comando independiente.
|
||||
func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResult {
|
||||
start := time.Now()
|
||||
|
||||
dbIndex := 0
|
||||
if strings.HasPrefix(database, "db") {
|
||||
if n, err := strconv.Atoi(database[2:]); err == nil {
|
||||
dbIndex = n
|
||||
}
|
||||
}
|
||||
|
||||
client, err := redisConnect(conx, dbIndex)
|
||||
if err != nil {
|
||||
saveHistory(conx.ID, cmdText, "error", err.Error(), 0, time.Since(start).Milliseconds())
|
||||
return QueryResult{Error: err.Error()}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Dividir en líneas, ignorar vacías y comentarios (#)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(cmdText, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return QueryResult{Error: "comando vacío"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Si hay múltiples comandos, ejecutarlos en pipeline y devolver tabla de resultados
|
||||
if len(lines) > 1 {
|
||||
var rows []map[string]any
|
||||
for _, line := range lines {
|
||||
args := redisParseArgs(line)
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
ifaces := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
ifaces[i] = a
|
||||
}
|
||||
val, execErr := client.Do(ctx, ifaces...).Result()
|
||||
row := map[string]any{
|
||||
"command": line,
|
||||
"result": redisValToString(val),
|
||||
"error": "",
|
||||
}
|
||||
if execErr != nil && execErr != goredis.Nil {
|
||||
row["error"] = execErr.Error()
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
result := QueryResult{
|
||||
Columns: []string{"command", "result", "error"},
|
||||
Rows: rows,
|
||||
RowCount: len(rows),
|
||||
IsSelect: true,
|
||||
DurationMs: elapsed,
|
||||
}
|
||||
saveHistory(conx.ID, cmdText, "ok", "", int64(len(rows)), elapsed)
|
||||
return result
|
||||
}
|
||||
|
||||
// Comando único
|
||||
args := redisParseArgs(lines[0])
|
||||
if len(args) == 0 {
|
||||
return QueryResult{Error: "comando vacío"}
|
||||
}
|
||||
ifaces := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
ifaces[i] = a
|
||||
}
|
||||
|
||||
val, execErr := client.Do(ctx, ifaces...).Result()
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
|
||||
if execErr != nil && execErr != goredis.Nil {
|
||||
saveHistory(conx.ID, cmdText, "error", execErr.Error(), 0, elapsed)
|
||||
return QueryResult{Error: execErr.Error(), DurationMs: elapsed}
|
||||
}
|
||||
|
||||
result := redisResultToQueryResult(val, lines[0])
|
||||
result.DurationMs = elapsed
|
||||
saveHistory(conx.ID, cmdText, "ok", "", int64(result.RowCount), elapsed)
|
||||
return result
|
||||
}
|
||||
|
||||
// redisResultToQueryResult convierte la respuesta de Redis en QueryResult presentable.
|
||||
func redisResultToQueryResult(val any, cmd string) QueryResult {
|
||||
upper := strings.ToUpper(strings.Fields(cmd)[0])
|
||||
|
||||
switch v := val.(type) {
|
||||
case nil:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": "(nil)"}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case string:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": v}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case int64:
|
||||
label := "result"
|
||||
if upper == "DEL" || upper == "EXISTS" || upper == "SREM" || upper == "LREM" {
|
||||
label = "affected"
|
||||
} else if upper == "TTL" || upper == "PTTL" {
|
||||
label = "ttl_seconds"
|
||||
} else if upper == "DBSIZE" || upper == "LLEN" || upper == "SCARD" || upper == "ZCARD" || upper == "HLEN" {
|
||||
label = "count"
|
||||
}
|
||||
return QueryResult{
|
||||
IsSelect: false,
|
||||
AffectedRows: v,
|
||||
Columns: []string{label},
|
||||
Rows: []map[string]any{{label: v}},
|
||||
RowCount: 1,
|
||||
}
|
||||
case []any:
|
||||
// Lista o conjunto de valores
|
||||
if upper == "HGETALL" && len(v)%2 == 0 {
|
||||
// Alternar field/value → formato tabla
|
||||
var rows []map[string]any
|
||||
for i := 0; i+1 < len(v); i += 2 {
|
||||
rows = append(rows, map[string]any{
|
||||
"field": redisValToString(v[i]),
|
||||
"value": redisValToString(v[i+1]),
|
||||
})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows)}
|
||||
}
|
||||
// KEYS, SMEMBERS, LRANGE, etc.
|
||||
var rows []map[string]any
|
||||
for _, item := range v {
|
||||
rows = append(rows, map[string]any{"value": redisValToString(item)})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"value"}, Rows: rows, RowCount: len(rows)}
|
||||
case map[any]any:
|
||||
var rows []map[string]any
|
||||
for k, mv := range v {
|
||||
rows = append(rows, map[string]any{
|
||||
"field": redisValToString(k),
|
||||
"value": redisValToString(mv),
|
||||
})
|
||||
}
|
||||
return QueryResult{IsSelect: true, Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows)}
|
||||
default:
|
||||
return QueryResult{
|
||||
IsSelect: true,
|
||||
Columns: []string{"result"},
|
||||
Rows: []map[string]any{{"result": fmt.Sprintf("%v", val)}},
|
||||
RowCount: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func redisValToString(v any) string {
|
||||
if v == nil {
|
||||
return "(nil)"
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// redisParseArgs divide un comando Redis en tokens respetando comillas.
|
||||
// Ej: SET mykey "hello world" → ["SET", "mykey", "hello world"]
|
||||
func redisParseArgs(cmd string) []string {
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inQ := false
|
||||
qChar := byte(0)
|
||||
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
ch := cmd[i]
|
||||
if inQ {
|
||||
if ch == qChar {
|
||||
inQ = false
|
||||
} else if ch == '\\' && i+1 < len(cmd) {
|
||||
i++
|
||||
cur.WriteByte(cmd[i])
|
||||
} else {
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
} else {
|
||||
if ch == '"' || ch == '\'' {
|
||||
inQ = true
|
||||
qChar = ch
|
||||
} else if ch == ' ' || ch == '\t' {
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
} else {
|
||||
cur.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ── MongoDB ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func isMongoDriver(driver string) bool {
|
||||
|
||||
Reference in New Issue
Block a user