From 63d124215b34067a484900a1a86cc6a35eddc63a Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Wed, 20 May 2026 21:07:33 -0500 Subject: [PATCH] feat(query-runner): soporte completo Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- go.mod | 8 +- go.sum | 6 + pkg/services/query_runner_service.go | 325 +++++++++++++++++++++++++++ resources/views/query_runner.html | 165 +++++++++++++- 4 files changed, 493 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index af29f92..9bf6288 100755 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/sujit-baniya/fiber-boilerplate -go 1.22.0 - -toolchain go1.22.3 +go 1.24 require ( github.com/BurntSushi/toml v1.4.0 // indirect @@ -32,7 +30,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 github.com/xhit/go-simple-mail/v2 v2.16.0 golang.org/x/crypto v0.31.0 - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gorm.io/driver/mysql v1.5.7 @@ -114,6 +112,7 @@ require ( github.com/oschwald/maxminddb-golang v1.13.1 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pyroscope-io/dotnetdiag v1.2.1 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect @@ -130,6 +129,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect diff --git a/go.sum b/go.sum index 321df45..e564825 100755 --- a/go.sum +++ b/go.sum @@ -586,6 +586,8 @@ github.com/pyroscope-io/dotnetdiag v1.2.1 h1:3XEMrfFJnZ87BiEhozyQKmCUAuMd/Spq7KC github.com/pyroscope-io/dotnetdiag v1.2.1/go.mod h1:eFUEHCp4eD1TgcXMlJihC+R4MrqGf7nTRdWxNADbDHA= github.com/pyroscope-io/pyroscope v0.37.2 h1:MOgLU/oO7VfV6jWqb0xoFH/YPSVbWD5pGsX+tZVGh98= github.com/pyroscope-io/pyroscope v0.37.2/go.mod h1:r4wq4ajJvN7g1OeXGyNvmwzBfQ+Tm5alYvmxqEQSTsc= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -712,6 +714,8 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/otel v0.13.0/go.mod h1:dlSNewoRYikTkotEnxdmuBHgzT+k/idJSfDv/FxEnOY= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -960,6 +964,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go index c6a44d7..126ce4a 100644 --- a/pkg/services/query_runner_service.go +++ b/pkg/services/query_runner_service.go @@ -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 { diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html index 42f7184..93f13c2 100644 --- a/resources/views/query_runner.html +++ b/resources/views/query_runner.html @@ -47,7 +47,7 @@
-

+