- GetSchemaRelations: trae todas las llaves foráneas de la base con consulta específica por motor (Postgres/MySQL/MSSQL/SQLite). No aplica a Redis/Mongo (schemaless). - Endpoint GET /query-runner/relations, con el mismo chequeo de autorización por conexión que ya usa RunQuery — de paso se lo agrego también a GetDatabases/GetTables, que no lo tenían. - UI: botón "Ver relaciones" (diagrama completo) y un ícono por tabla en el árbol lateral (relaciones solo de esa tabla), renderizado con Mermaid.js servido localmente (public/js/mermaid.min.js, sin CDN).
1557 lines
45 KiB
Go
1557 lines
45 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
_ "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"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// connTimeout es el tiempo máximo para establecer una conexión a la DB externa.
|
|
const connTimeout = 8 * time.Second
|
|
|
|
// QueryResult contiene el resultado de una consulta SQL.
|
|
type QueryResult struct {
|
|
Columns []string `json:"columns"`
|
|
Rows []map[string]any `json:"rows"`
|
|
RowCount int `json:"row_count"`
|
|
AffectedRows int64 `json:"affected_rows"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
IsSelect bool `json:"is_select"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// openDynamicDB abre una conexión a la base de datos indicada por ConxDb.
|
|
func openDynamicDB(c models.ConxDb) (*sql.DB, error) {
|
|
driver := strings.ToLower(c.TipoDb.Nombre)
|
|
host := c.HostEfectivo()
|
|
port := c.Puerto
|
|
user := c.Usuario
|
|
pass := c.Password
|
|
|
|
var dsn string
|
|
var driverName string
|
|
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
driverName = "postgres"
|
|
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable connect_timeout=8", host, port, user, pass)
|
|
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
|
driverName = "mysql"
|
|
dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/?timeout=8s&readTimeout=20s&writeTimeout=20s", user, pass, host, port)
|
|
case strings.Contains(driver, "sqlite"):
|
|
driverName = "sqlite3"
|
|
dsn = host // para sqlite el host es la ruta del archivo
|
|
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
|
driverName = "sqlserver"
|
|
dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s?dial+timeout=8", user, pass, host, port)
|
|
default:
|
|
return nil, fmt.Errorf("driver no soportado: %s", driver)
|
|
}
|
|
|
|
db, err := sql.Open(driverName, dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetConnMaxLifetime(30 * time.Second)
|
|
db.SetMaxOpenConns(2)
|
|
// Verificar conectividad inmediatamente para fallar rápido en lugar de bloquear al hacer la primera query
|
|
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("no se pudo conectar al servidor de base de datos: %w", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult.
|
|
// También guarda en query_history.
|
|
func ExecuteSQL(conx models.ConxDb, database, sqlText string, userID ...uint) QueryResult {
|
|
uid := uint(0)
|
|
if len(userID) > 0 {
|
|
uid = userID[0]
|
|
}
|
|
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
|
return mongoExecuteSQL(conx, database, sqlText, uid)
|
|
}
|
|
if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
|
return redisExecuteCommand(conx, database, sqlText, uid)
|
|
}
|
|
start := time.Now()
|
|
|
|
db, err := openDynamicDB(conx)
|
|
if err != nil {
|
|
saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds())
|
|
return QueryResult{Error: err.Error()}
|
|
}
|
|
defer db.Close()
|
|
|
|
// Si se especifica una base de datos para seleccionar
|
|
if database != "" {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
if strings.Contains(driver, "postgres") {
|
|
// En postgres se cambia con SET search_path o reconectando con dbname en DSN
|
|
db2, err2 := openDynamicDBWithName(conx, database)
|
|
if err2 == nil {
|
|
db.Close()
|
|
db = db2
|
|
}
|
|
} else {
|
|
if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil {
|
|
saveHistory(conx.ID, uid, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds())
|
|
return QueryResult{Error: err2.Error()}
|
|
}
|
|
}
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(sqlText)
|
|
isSelect := isSelectStatement(trimmed)
|
|
|
|
var result QueryResult
|
|
result.IsSelect = isSelect
|
|
|
|
if isSelect {
|
|
rows, err := db.Query(trimmed)
|
|
if err != nil {
|
|
elapsed := time.Since(start).Milliseconds()
|
|
saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, elapsed)
|
|
return QueryResult{Error: err.Error(), IsSelect: true}
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols, _ := rows.Columns()
|
|
result.Columns = cols
|
|
|
|
for rows.Next() {
|
|
vals := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
rows.Scan(ptrs...)
|
|
row := make(map[string]any, len(cols))
|
|
for i, col := range cols {
|
|
v := vals[i]
|
|
if b, ok := v.([]byte); ok {
|
|
row[col] = string(b)
|
|
} else {
|
|
row[col] = v
|
|
}
|
|
}
|
|
result.Rows = append(result.Rows, row)
|
|
}
|
|
result.RowCount = len(result.Rows)
|
|
} else {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
// PostgreSQL: cuando se envían múltiples sentencias en un solo Exec, el servidor
|
|
// las envuelve en una transacción implícita. CREATE DATABASE/DROP DATABASE no
|
|
// pueden correr dentro de una transacción, así que dividimos y ejecutamos c/u por separado.
|
|
if strings.Contains(driver, "postgres") {
|
|
stmts := splitPostgresStatements(trimmed)
|
|
if len(stmts) > 1 {
|
|
var totalAffected int64
|
|
for _, stmt := range stmts {
|
|
res, execErr := db.Exec(stmt)
|
|
if execErr != nil {
|
|
elapsed := time.Since(start).Milliseconds()
|
|
preview := stmt
|
|
if len(preview) > 80 {
|
|
preview = preview[:80] + "..."
|
|
}
|
|
saveHistory(conx.ID, uid, sqlText, "error", execErr.Error(), 0, elapsed)
|
|
return QueryResult{Error: fmt.Sprintf("[%s]: %s", preview, execErr.Error())}
|
|
}
|
|
if affected, err2 := res.RowsAffected(); err2 == nil {
|
|
totalAffected += affected
|
|
}
|
|
}
|
|
elapsed := time.Since(start).Milliseconds()
|
|
result.AffectedRows = totalAffected
|
|
result.DurationMs = elapsed
|
|
saveHistory(conx.ID, uid, sqlText, "ok", "", totalAffected, elapsed)
|
|
return result
|
|
}
|
|
}
|
|
|
|
res, err := db.Exec(trimmed)
|
|
elapsed := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, elapsed)
|
|
return QueryResult{Error: err.Error()}
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
result.AffectedRows = affected
|
|
result.DurationMs = elapsed
|
|
saveHistory(conx.ID, uid, sqlText, "ok", "", affected, elapsed)
|
|
return result
|
|
}
|
|
|
|
result.DurationMs = time.Since(start).Milliseconds()
|
|
saveHistory(conx.ID, uid, sqlText, "ok", "", int64(result.RowCount), result.DurationMs)
|
|
return result
|
|
}
|
|
|
|
// ListDatabases devuelve la lista de bases de datos del servidor.
|
|
func ListDatabases(conx models.ConxDb) ([]string, error) {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
if isMongoDriver(driver) {
|
|
return mongoListDatabases(conx)
|
|
}
|
|
if isRedisDriver(driver) {
|
|
return redisListDatabases(conx)
|
|
}
|
|
db, err := openDynamicDB(conx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
var query string
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
query = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
|
|
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
|
query = "SHOW DATABASES"
|
|
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
|
query = "SELECT name FROM sys.databases ORDER BY name"
|
|
default:
|
|
return []string{"main"}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var dbs []string
|
|
for rows.Next() {
|
|
var name string
|
|
rows.Scan(&name)
|
|
dbs = append(dbs, name)
|
|
}
|
|
return dbs, nil
|
|
}
|
|
|
|
// ListTables devuelve las tablas de una base de datos.
|
|
func ListTables(conx models.ConxDb, database string) ([]string, error) {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
if isMongoDriver(driver) {
|
|
return mongoListCollections(conx, database)
|
|
}
|
|
if isRedisDriver(driver) {
|
|
return redisListKeys(conx, database)
|
|
}
|
|
|
|
var db *sql.DB
|
|
var err error
|
|
|
|
if strings.Contains(driver, "postgres") {
|
|
db, err = openDynamicDBWithName(conx, database)
|
|
} else {
|
|
db, err = openDynamicDB(conx)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
var query string
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
query = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
|
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
|
if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
|
|
return nil, err
|
|
}
|
|
query = "SHOW TABLES"
|
|
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
|
query = fmt.Sprintf("USE [%s]; SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", database)
|
|
default:
|
|
query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
}
|
|
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tables []string
|
|
for rows.Next() {
|
|
var name string
|
|
rows.Scan(&name)
|
|
tables = append(tables, name)
|
|
}
|
|
return tables, nil
|
|
}
|
|
|
|
// ColumnInfo describe una columna de tabla.
|
|
type ColumnInfo struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Nullable string `json:"nullable"`
|
|
Key string `json:"key"`
|
|
Default string `json:"default"`
|
|
}
|
|
|
|
// GetTableColumns devuelve la info de columnas de una tabla.
|
|
func GetTableColumns(conx models.ConxDb, database, table string) ([]ColumnInfo, error) {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
db, err := openDynamicDB(conx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
// Seleccionar base de datos si es necesario
|
|
if database != "" {
|
|
if strings.Contains(driver, "postgres") {
|
|
db2, err2 := openDynamicDBWithName(conx, database)
|
|
if err2 == nil {
|
|
db.Close()
|
|
db = db2
|
|
}
|
|
} else {
|
|
if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil {
|
|
// Intentar de todas formas
|
|
}
|
|
}
|
|
}
|
|
|
|
var query string
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
query = fmt.Sprintf(`SELECT column_name, data_type, is_nullable,
|
|
COALESCE(column_default,'') as column_default,
|
|
'' as column_key
|
|
FROM information_schema.columns
|
|
WHERE table_name = '%s' AND table_schema = 'public'
|
|
ORDER BY ordinal_position`, table)
|
|
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
|
query = fmt.Sprintf("SHOW COLUMNS FROM `%s`", table)
|
|
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
|
query = fmt.Sprintf(`SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type,
|
|
IS_NULLABLE as is_nullable, COALESCE(COLUMN_DEFAULT,'') as column_default,
|
|
'' as column_key
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_NAME = '%s' ORDER BY ORDINAL_POSITION`, table)
|
|
default:
|
|
query = fmt.Sprintf("PRAGMA table_info('%s')", table)
|
|
}
|
|
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cols []ColumnInfo
|
|
driverLower := strings.ToLower(driver)
|
|
isMySQL := strings.Contains(driverLower, "mysql") || strings.Contains(driverLower, "mariadb")
|
|
|
|
for rows.Next() {
|
|
var ci ColumnInfo
|
|
if isMySQL {
|
|
var field, colType, null, key, extra string
|
|
var defaultVal *string
|
|
if err := rows.Scan(&field, &colType, &null, &key, &defaultVal, &extra); err != nil {
|
|
continue
|
|
}
|
|
ci.Name = field
|
|
ci.Type = colType
|
|
ci.Nullable = null
|
|
ci.Key = key
|
|
if defaultVal != nil {
|
|
ci.Default = *defaultVal
|
|
}
|
|
} else {
|
|
if err := rows.Scan(&ci.Name, &ci.Type, &ci.Nullable, &ci.Default, &ci.Key); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
cols = append(cols, ci)
|
|
}
|
|
if cols == nil {
|
|
cols = []ColumnInfo{}
|
|
}
|
|
return cols, nil
|
|
}
|
|
|
|
// ForeignKeyInfo describe una relación de llave foránea entre dos tablas.
|
|
type ForeignKeyInfo struct {
|
|
ConstraintName string `json:"constraint_name"`
|
|
FromTable string `json:"from_table"`
|
|
FromColumn string `json:"from_column"`
|
|
ToTable string `json:"to_table"`
|
|
ToColumn string `json:"to_column"`
|
|
}
|
|
|
|
// GetSchemaRelations trae todas las llaves foráneas de una base de datos, para
|
|
// poder armar un mapa de relaciones (no una tabla puntual: la idea es ver
|
|
// todo el esquema de una vez). Solo aplica a motores relacionales — Redis y
|
|
// Mongo son schemaless, no tienen FK que consultar.
|
|
func GetSchemaRelations(conx models.ConxDb, database string) ([]ForeignKeyInfo, error) {
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
if isMongoDriver(driver) || isRedisDriver(driver) {
|
|
return []ForeignKeyInfo{}, nil
|
|
}
|
|
|
|
var db *sql.DB
|
|
var err error
|
|
if strings.Contains(driver, "postgres") {
|
|
db, err = openDynamicDBWithName(conx, database)
|
|
} else {
|
|
db, err = openDynamicDB(conx)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
return postgresRelations(db)
|
|
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
|
return mysqlRelations(db, conx, database)
|
|
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
|
return mssqlRelations(db, database)
|
|
default:
|
|
return sqliteRelations(db)
|
|
}
|
|
}
|
|
|
|
func postgresRelations(db *sql.DB) ([]ForeignKeyInfo, error) {
|
|
query := `
|
|
SELECT con.conname AS constraint_name,
|
|
tf.relname AS from_table,
|
|
af.attname AS from_column,
|
|
tt.relname AS to_table,
|
|
at.attname AS to_column
|
|
FROM pg_constraint con
|
|
JOIN pg_class tf ON tf.oid = con.conrelid
|
|
JOIN pg_class tt ON tt.oid = con.confrelid
|
|
JOIN unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
|
|
JOIN unnest(con.confkey) WITH ORDINALITY AS ct(attnum, ord) ON ct.ord = ck.ord
|
|
JOIN pg_attribute af ON af.attrelid = con.conrelid AND af.attnum = ck.attnum
|
|
JOIN pg_attribute at ON at.attrelid = con.confrelid AND at.attnum = ct.attnum
|
|
WHERE con.contype = 'f'
|
|
ORDER BY tf.relname, con.conname`
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanRelations(rows)
|
|
}
|
|
|
|
func mysqlRelations(db *sql.DB, conx models.ConxDb, database string) ([]ForeignKeyInfo, error) {
|
|
if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
|
|
return nil, err
|
|
}
|
|
query := `
|
|
SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
FROM information_schema.KEY_COLUMN_USAGE
|
|
WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
ORDER BY TABLE_NAME, CONSTRAINT_NAME`
|
|
rows, err := db.Query(query, database)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanRelations(rows)
|
|
}
|
|
|
|
func mssqlRelations(db *sql.DB, database string) ([]ForeignKeyInfo, error) {
|
|
query := fmt.Sprintf(`
|
|
USE [%s];
|
|
SELECT fk.name AS constraint_name,
|
|
tf.name AS from_table,
|
|
cf.name AS from_column,
|
|
tt.name AS to_table,
|
|
ct.name AS to_column
|
|
FROM sys.foreign_keys fk
|
|
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
|
JOIN sys.tables tf ON tf.object_id = fkc.parent_object_id
|
|
JOIN sys.tables tt ON tt.object_id = fkc.referenced_object_id
|
|
JOIN sys.columns cf ON cf.object_id = fkc.parent_object_id AND cf.column_id = fkc.parent_column_id
|
|
JOIN sys.columns ct ON ct.object_id = fkc.referenced_object_id AND ct.column_id = fkc.referenced_column_id
|
|
ORDER BY tf.name, fk.name`, database)
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanRelations(rows)
|
|
}
|
|
|
|
// sqliteRelations recorre cada tabla con PRAGMA foreign_key_list, ya que
|
|
// SQLite no tiene un catálogo único con todas las FK de la base a la vez.
|
|
func sqliteRelations(db *sql.DB) ([]ForeignKeyInfo, error) {
|
|
tableRows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tables []string
|
|
for tableRows.Next() {
|
|
var name string
|
|
if err := tableRows.Scan(&name); err == nil {
|
|
tables = append(tables, name)
|
|
}
|
|
}
|
|
tableRows.Close()
|
|
|
|
var out []ForeignKeyInfo
|
|
for _, table := range tables {
|
|
rows, err := db.Query(fmt.Sprintf("PRAGMA foreign_key_list('%s')", table))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for rows.Next() {
|
|
var id, seq int
|
|
var toTable, fromCol, toCol, onUpdate, onDelete, match string
|
|
if err := rows.Scan(&id, &seq, &toTable, &fromCol, &toCol, &onUpdate, &onDelete, &match); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, ForeignKeyInfo{
|
|
ConstraintName: fmt.Sprintf("%s_fk_%d", table, id),
|
|
FromTable: table,
|
|
FromColumn: fromCol,
|
|
ToTable: toTable,
|
|
ToColumn: toCol,
|
|
})
|
|
}
|
|
rows.Close()
|
|
}
|
|
if out == nil {
|
|
out = []ForeignKeyInfo{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func scanRelations(rows *sql.Rows) ([]ForeignKeyInfo, error) {
|
|
var out []ForeignKeyInfo
|
|
for rows.Next() {
|
|
var fk ForeignKeyInfo
|
|
if err := rows.Scan(&fk.ConstraintName, &fk.FromTable, &fk.FromColumn, &fk.ToTable, &fk.ToColumn); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, fk)
|
|
}
|
|
if out == nil {
|
|
out = []ForeignKeyInfo{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// TestConnection verifica si la conexión es válida.
|
|
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
|
|
}
|
|
defer db.Close()
|
|
return db.Ping()
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) {
|
|
host := c.HostEfectivo()
|
|
port := c.Puerto
|
|
user := c.Usuario
|
|
pass := c.Password
|
|
driver := strings.ToLower(c.TipoDb.Nombre)
|
|
|
|
var dsn string
|
|
switch {
|
|
case strings.Contains(driver, "postgres"):
|
|
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable connect_timeout=8", host, port, user, pass, dbName)
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetConnMaxLifetime(30 * time.Second)
|
|
db.SetMaxOpenConns(2)
|
|
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("no se pudo conectar a la base de datos '%s': %w", dbName, err)
|
|
}
|
|
return db, nil
|
|
default:
|
|
return openDynamicDB(c)
|
|
}
|
|
}
|
|
|
|
// splitPostgresStatements divide SQL multi-sentencia en sentencias individuales
|
|
// respetando dollar-quoting ($$ ... $$), strings con comillas simples y comentarios.
|
|
// Esto permite ejecutar cada sentencia por separado evitando que PostgreSQL
|
|
// envuelva múltiples statements en una transacción implícita (que bloquea CREATE DATABASE).
|
|
func splitPostgresStatements(sqlText string) []string {
|
|
var stmts []string
|
|
var cur strings.Builder
|
|
i, n := 0, len(sqlText)
|
|
|
|
for i < n {
|
|
ch := sqlText[i]
|
|
|
|
// Dollar-quoting: $tag$ ... $tag$ (tag puede ser vacío: $$)
|
|
if ch == '$' {
|
|
j := i + 1
|
|
for j < n && sqlText[j] != '$' && sqlText[j] != '\n' {
|
|
j++
|
|
}
|
|
if j < n && sqlText[j] == '$' {
|
|
tag := sqlText[i : j+1] // e.g. "$$" or "$func$"
|
|
cur.WriteString(tag)
|
|
i = j + 1
|
|
closeIdx := strings.Index(sqlText[i:], tag)
|
|
if closeIdx >= 0 {
|
|
cur.WriteString(sqlText[i : i+closeIdx+len(tag)])
|
|
i = i + closeIdx + len(tag)
|
|
} else {
|
|
cur.WriteString(sqlText[i:])
|
|
i = n
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// String con comillas simples
|
|
if ch == '\'' {
|
|
cur.WriteByte(ch)
|
|
i++
|
|
for i < n {
|
|
c := sqlText[i]
|
|
cur.WriteByte(c)
|
|
i++
|
|
if c == '\'' {
|
|
if i < n && sqlText[i] == '\'' {
|
|
cur.WriteByte(sqlText[i])
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Comentario de línea (--)
|
|
if ch == '-' && i+1 < n && sqlText[i+1] == '-' {
|
|
cur.WriteByte(ch)
|
|
i++
|
|
for i < n && sqlText[i] != '\n' {
|
|
cur.WriteByte(sqlText[i])
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Comentario de bloque /* ... */
|
|
if ch == '/' && i+1 < n && sqlText[i+1] == '*' {
|
|
cur.WriteString("/*")
|
|
i += 2
|
|
for i < n {
|
|
if sqlText[i] == '*' && i+1 < n && sqlText[i+1] == '/' {
|
|
cur.WriteString("*/")
|
|
i += 2
|
|
break
|
|
}
|
|
cur.WriteByte(sqlText[i])
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Punto y coma: fin de sentencia
|
|
if ch == ';' {
|
|
if stmt := strings.TrimSpace(cur.String()); stmt != "" {
|
|
stmts = append(stmts, stmt)
|
|
}
|
|
cur.Reset()
|
|
i++
|
|
continue
|
|
}
|
|
|
|
cur.WriteByte(ch)
|
|
i++
|
|
}
|
|
|
|
if stmt := strings.TrimSpace(cur.String()); stmt != "" {
|
|
stmts = append(stmts, stmt)
|
|
}
|
|
return stmts
|
|
}
|
|
|
|
func isSelectStatement(sql string) bool {
|
|
upper := strings.ToUpper(strings.TrimSpace(sql))
|
|
keywords := []string{"SELECT ", "SHOW ", "DESCRIBE ", "EXPLAIN ", "WITH ", "PRAGMA "}
|
|
for _, kw := range keywords {
|
|
if strings.HasPrefix(upper, kw) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func quoteIdentifier(name, driver string) string {
|
|
d := strings.ToLower(driver)
|
|
if strings.Contains(d, "postgres") {
|
|
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
|
}
|
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
|
}
|
|
|
|
func saveHistory(conxID, userID uint, sqlText, status, errMsg string, rows, durationMs int64) {
|
|
models.SaveQueryHistory(models.QueryHistory{
|
|
ConxDbID: conxID,
|
|
UserID: userID,
|
|
SQL: sqlText,
|
|
Status: status,
|
|
ErrorMsg: errMsg,
|
|
RowsAffect: rows,
|
|
DurationMs: durationMs,
|
|
ExecutedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// ── 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.HostEfectivo()
|
|
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, userID ...uint) QueryResult {
|
|
uid := uint(0)
|
|
if len(userID) > 0 {
|
|
uid = userID[0]
|
|
}
|
|
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, uid, 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, uid, 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, uid, cmdText, "error", execErr.Error(), 0, elapsed)
|
|
return QueryResult{Error: execErr.Error(), DurationMs: elapsed}
|
|
}
|
|
|
|
result := redisResultToQueryResult(val, lines[0])
|
|
result.DurationMs = elapsed
|
|
saveHistory(conx.ID, uid, 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 {
|
|
return strings.Contains(driver, "mongo")
|
|
}
|
|
|
|
func mongoURI(c models.ConxDb) string {
|
|
host := c.HostEfectivo()
|
|
port := c.Puerto
|
|
user := c.Usuario
|
|
pass := c.Password
|
|
if user != "" && pass != "" {
|
|
return fmt.Sprintf("mongodb://%s:%s@%s:%s/?directConnection=true&serverSelectionTimeoutMS=8000", user, pass, host, port)
|
|
}
|
|
return fmt.Sprintf("mongodb://%s:%s/?directConnection=true&serverSelectionTimeoutMS=8000", host, port)
|
|
}
|
|
|
|
func mongoConnect(c models.ConxDb) (*mongo.Client, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
|
|
defer cancel()
|
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI(c)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := client.Ping(ctx, nil); err != nil {
|
|
client.Disconnect(context.Background()) //nolint
|
|
return nil, fmt.Errorf("no se pudo conectar a MongoDB: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func mongoTestConnection(c models.ConxDb) error {
|
|
client, err := mongoConnect(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client.Disconnect(context.Background()) //nolint
|
|
return nil
|
|
}
|
|
|
|
func mongoListDatabases(c models.ConxDb) ([]string, error) {
|
|
client, err := mongoConnect(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Disconnect(context.Background()) //nolint
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
return client.ListDatabaseNames(ctx, bson.M{})
|
|
}
|
|
|
|
func mongoListCollections(c models.ConxDb, database string) ([]string, error) {
|
|
client, err := mongoConnect(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Disconnect(context.Background()) //nolint
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
return client.Database(database).ListCollectionNames(ctx, bson.M{})
|
|
}
|
|
|
|
var mongoQueryRe = regexp.MustCompile(`(?s)^db\.(\w[\w\d_]*)\((.*)\)\s*$|^db\.(\w[\w\d_]*)\.(\w+)\((.*)\)\s*$`)
|
|
|
|
func mongoStripComments(text string) string {
|
|
var lines []string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" || strings.HasPrefix(t, "--") || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "#") {
|
|
continue
|
|
}
|
|
lines = append(lines, t)
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
// Regexes para convertir sintaxis shell de Mongo a Extended JSON.
|
|
var (
|
|
reNewDateEmpty = regexp.MustCompile(`\bnew\s+Date\s*\(\s*\)`)
|
|
reNewDateStr = regexp.MustCompile(`\bnew\s+Date\s*\(\s*["']([^"']+)["']\s*\)`)
|
|
reNewDateMs = regexp.MustCompile(`\bnew\s+Date\s*\(\s*(\d+)\s*\)`)
|
|
reISODate = regexp.MustCompile(`\bISODate\s*\(\s*["']([^"']+)["']\s*\)`)
|
|
reISODateEmpty = regexp.MustCompile(`\bISODate\s*\(\s*\)`)
|
|
reObjectId = regexp.MustCompile(`\bObjectId\s*\(\s*["']([0-9a-fA-F]{24})["']\s*\)`)
|
|
reNumberInt = regexp.MustCompile(`\bNumberInt\s*\(\s*(\d+)\s*\)`)
|
|
reNumberLong = regexp.MustCompile(`\bNumberLong\s*\(\s*(\d+)\s*\)`)
|
|
reNumberDecimal = regexp.MustCompile(`\bNumberDecimal\s*\(\s*["']?([0-9.eE+\-]+)["']?\s*\)`)
|
|
reBinData = regexp.MustCompile(`\bBinData\s*\(\s*(\d+)\s*,\s*["']([^"']*)["']\s*\)`)
|
|
reTimestamp = regexp.MustCompile(`\bTimestamp\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)`)
|
|
reMinKey = regexp.MustCompile(`\bMinKey\b`)
|
|
reMaxKey = regexp.MustCompile(`\bMaxKey\b`)
|
|
reUndefined = regexp.MustCompile(`\bundefined\b`)
|
|
reUnquotedKeys = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)`)
|
|
)
|
|
|
|
// mongoShellToExtJSON convierte sintaxis shell de Mongo a Extended JSON strict
|
|
// para que bson.UnmarshalExtJSON pueda parsearlo correctamente.
|
|
func mongoShellToExtJSON(s string) string {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
// new Date() sin argumentos → fecha actual
|
|
s = reNewDateEmpty.ReplaceAllStringFunc(s, func(_ string) string {
|
|
return `{"$date":"` + now + `"}`
|
|
})
|
|
// new Date("iso-string")
|
|
s = reNewDateStr.ReplaceAllString(s, `{"$$date":"$1"}`)
|
|
// new Date(milliseconds)
|
|
s = reNewDateMs.ReplaceAllString(s, `{"$$date":{"$$numberLong":"$1"}}`)
|
|
// ISODate("iso-string")
|
|
s = reISODate.ReplaceAllString(s, `{"$$date":"$1"}`)
|
|
// ISODate() sin argumentos → fecha actual
|
|
s = reISODateEmpty.ReplaceAllStringFunc(s, func(_ string) string {
|
|
return `{"$date":"` + now + `"}`
|
|
})
|
|
// ObjectId("hex")
|
|
s = reObjectId.ReplaceAllString(s, `{"$$oid":"$1"}`)
|
|
// NumberInt(n)
|
|
s = reNumberInt.ReplaceAllString(s, `{"$$numberInt":"$1"}`)
|
|
// NumberLong(n)
|
|
s = reNumberLong.ReplaceAllString(s, `{"$$numberLong":"$1"}`)
|
|
// NumberDecimal(n)
|
|
s = reNumberDecimal.ReplaceAllString(s, `{"$$numberDecimal":"$1"}`)
|
|
// BinData(subtype, base64)
|
|
s = reBinData.ReplaceAllString(s, `{"$$binary":{"base64":"$2","subType":"$1"}}`)
|
|
// Timestamp(t, i)
|
|
s = reTimestamp.ReplaceAllString(s, `{"$$timestamp":{"t":$1,"i":$2}}`)
|
|
// MinKey / MaxKey
|
|
s = reMinKey.ReplaceAllString(s, `{"$$minKey":1}`)
|
|
s = reMaxKey.ReplaceAllString(s, `{"$$maxKey":1}`)
|
|
// undefined → null
|
|
s = reUndefined.ReplaceAllString(s, `null`)
|
|
// Comillas simples → dobles (solo en valores string, no dentro de ya-convertidos)
|
|
s = mongoSingleToDoubleQuotes(s)
|
|
// Claves sin comillas → claves con comillas dobles
|
|
s = reUnquotedKeys.ReplaceAllString(s, `$1"$2"$3`)
|
|
return s
|
|
}
|
|
|
|
// mongoSingleToDoubleQuotes convierte 'valor' → "valor" respetando escapes.
|
|
func mongoSingleToDoubleQuotes(s string) string {
|
|
var b strings.Builder
|
|
inDouble := false
|
|
inSingle := false
|
|
runes := []rune(s)
|
|
for i := 0; i < len(runes); i++ {
|
|
c := runes[i]
|
|
switch {
|
|
case c == '\\' && (inDouble || inSingle):
|
|
b.WriteRune(c)
|
|
i++
|
|
if i < len(runes) {
|
|
b.WriteRune(runes[i])
|
|
}
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
b.WriteRune(c)
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
if inSingle {
|
|
b.WriteRune('"')
|
|
} else {
|
|
b.WriteRune('"')
|
|
}
|
|
default:
|
|
b.WriteRune(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func mongoExecuteSQL(conx models.ConxDb, database, queryText string, userID ...uint) QueryResult {
|
|
uid := uint(0)
|
|
if len(userID) > 0 {
|
|
uid = userID[0]
|
|
}
|
|
start := time.Now()
|
|
client, err := mongoConnect(conx)
|
|
if err != nil {
|
|
saveHistory(conx.ID, uid, queryText, "error", err.Error(), 0, time.Since(start).Milliseconds())
|
|
return QueryResult{Error: err.Error()}
|
|
}
|
|
defer client.Disconnect(context.Background()) //nolint
|
|
db := client.Database(database)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
result, err := mongoRunQuery(ctx, db, strings.TrimSpace(queryText))
|
|
elapsed := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
saveHistory(conx.ID, uid, queryText, "error", err.Error(), 0, elapsed)
|
|
return QueryResult{Error: err.Error()}
|
|
}
|
|
result.DurationMs = elapsed
|
|
saveHistory(conx.ID, uid, queryText, "ok", "", int64(result.RowCount)+result.AffectedRows, elapsed)
|
|
return *result
|
|
}
|
|
|
|
func mongoRunQuery(ctx context.Context, db *mongo.Database, query string) (*QueryResult, error) {
|
|
// Eliminar comentarios y líneas vacías
|
|
query = mongoStripComments(query)
|
|
// Convertir sintaxis shell de Mongo (new Date(), ObjectId, etc.) a Extended JSON
|
|
query = mongoShellToExtJSON(query)
|
|
|
|
m := mongoQueryRe.FindStringSubmatch(query)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("sintaxis no reconocida. Use: db.coleccion.metodo({...})")
|
|
}
|
|
|
|
var collName, method, argsRaw string
|
|
// El regex tiene dos alternativas:
|
|
// Alt 1 (m[1],m[2]): db.metodo(args) → para db.runCommand(...)
|
|
// Alt 2 (m[3],m[4],m[5]): db.coleccion.metodo(args)
|
|
if m[1] != "" {
|
|
// db.runCommand(args)
|
|
collName = m[1]
|
|
method = strings.ToLower(m[1])
|
|
argsRaw = strings.TrimSpace(m[2])
|
|
} else {
|
|
collName = m[3]
|
|
method = strings.ToLower(m[4])
|
|
argsRaw = strings.TrimSpace(m[5])
|
|
}
|
|
|
|
if strings.ToLower(collName) == "runcommand" {
|
|
return mongoRunCommand(ctx, db, argsRaw)
|
|
}
|
|
coll := db.Collection(collName)
|
|
args := splitTopLevelArgs(argsRaw)
|
|
switch method {
|
|
case "find":
|
|
return mongoFind(ctx, coll, args, false)
|
|
case "findone":
|
|
return mongoFind(ctx, coll, args, true)
|
|
case "insertone":
|
|
return mongoInsertOne(ctx, coll, args)
|
|
case "insertmany":
|
|
return mongoInsertMany(ctx, coll, args)
|
|
case "updateone":
|
|
return mongoUpdate(ctx, coll, args, false)
|
|
case "updatemany":
|
|
return mongoUpdate(ctx, coll, args, true)
|
|
case "deleteone":
|
|
return mongoDelete(ctx, coll, args, false)
|
|
case "deletemany":
|
|
return mongoDelete(ctx, coll, args, true)
|
|
case "countdocuments":
|
|
return mongoCount(ctx, coll, args)
|
|
case "aggregate":
|
|
return mongoAggregate(ctx, coll, argsRaw)
|
|
case "drop":
|
|
if err := coll.Drop(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryResult{IsSelect: false}, nil
|
|
default:
|
|
return nil, fmt.Errorf("método MongoDB no soportado: %s", m[2])
|
|
}
|
|
}
|
|
|
|
func mongoFind(ctx context.Context, coll *mongo.Collection, args []string, one bool) (*QueryResult, error) {
|
|
var filter bson.M
|
|
if len(args) > 0 && args[0] != "" {
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &filter); err != nil {
|
|
return nil, fmt.Errorf("filtro inválido: %w", err)
|
|
}
|
|
} else {
|
|
filter = bson.M{}
|
|
}
|
|
var docs []bson.M
|
|
if one {
|
|
var doc bson.M
|
|
if err := coll.FindOne(ctx, filter).Decode(&doc); err != nil {
|
|
if err == mongo.ErrNoDocuments {
|
|
return &QueryResult{IsSelect: true, Columns: []string{}, Rows: []map[string]any{}}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
docs = []bson.M{doc}
|
|
} else {
|
|
cursor, err := coll.Find(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cursor.Close(ctx)
|
|
if err := cursor.All(ctx, &docs); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return bsonDocsToResult(docs), nil
|
|
}
|
|
|
|
func mongoInsertOne(ctx context.Context, coll *mongo.Collection, args []string) (*QueryResult, error) {
|
|
if len(args) == 0 || args[0] == "" {
|
|
return nil, fmt.Errorf("insertOne requiere un documento")
|
|
}
|
|
var doc bson.M
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &doc); err != nil {
|
|
return nil, fmt.Errorf("documento inválido: %w", err)
|
|
}
|
|
res, err := coll.InsertOne(ctx, doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryResult{
|
|
IsSelect: true, AffectedRows: 1,
|
|
Columns: []string{"insertedId"},
|
|
Rows: []map[string]any{{"insertedId": fmt.Sprintf("%v", res.InsertedID)}},
|
|
RowCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
func mongoInsertMany(ctx context.Context, coll *mongo.Collection, args []string) (*QueryResult, error) {
|
|
if len(args) == 0 || args[0] == "" {
|
|
return nil, fmt.Errorf("insertMany requiere un array de documentos")
|
|
}
|
|
var arr bson.A
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &arr); err != nil {
|
|
return nil, fmt.Errorf("documentos inválidos: %w", err)
|
|
}
|
|
docs := make([]interface{}, len(arr))
|
|
copy(docs, arr)
|
|
res, err := coll.InsertMany(ctx, docs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryResult{IsSelect: false, AffectedRows: int64(len(res.InsertedIDs))}, nil
|
|
}
|
|
|
|
func mongoUpdate(ctx context.Context, coll *mongo.Collection, args []string, many bool) (*QueryResult, error) {
|
|
if len(args) < 2 {
|
|
return nil, fmt.Errorf("update requiere filtro y documento de actualización")
|
|
}
|
|
var filter, update bson.M
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &filter); err != nil {
|
|
return nil, fmt.Errorf("filtro inválido: %w", err)
|
|
}
|
|
if err := bson.UnmarshalExtJSON([]byte(args[1]), true, &update); err != nil {
|
|
return nil, fmt.Errorf("update inválido: %w", err)
|
|
}
|
|
var affected int64
|
|
if many {
|
|
res, err := coll.UpdateMany(ctx, filter, update)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
affected = res.ModifiedCount
|
|
} else {
|
|
res, err := coll.UpdateOne(ctx, filter, update)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
affected = res.ModifiedCount
|
|
}
|
|
return &QueryResult{IsSelect: false, AffectedRows: affected}, nil
|
|
}
|
|
|
|
func mongoDelete(ctx context.Context, coll *mongo.Collection, args []string, many bool) (*QueryResult, error) {
|
|
var filter bson.M
|
|
if len(args) > 0 && args[0] != "" {
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &filter); err != nil {
|
|
return nil, fmt.Errorf("filtro inválido: %w", err)
|
|
}
|
|
} else {
|
|
filter = bson.M{}
|
|
}
|
|
var deleted int64
|
|
if many {
|
|
res, err := coll.DeleteMany(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deleted = res.DeletedCount
|
|
} else {
|
|
res, err := coll.DeleteOne(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deleted = res.DeletedCount
|
|
}
|
|
return &QueryResult{IsSelect: false, AffectedRows: deleted}, nil
|
|
}
|
|
|
|
func mongoCount(ctx context.Context, coll *mongo.Collection, args []string) (*QueryResult, error) {
|
|
var filter bson.M
|
|
if len(args) > 0 && args[0] != "" {
|
|
if err := bson.UnmarshalExtJSON([]byte(args[0]), true, &filter); err != nil {
|
|
return nil, fmt.Errorf("filtro inválido: %w", err)
|
|
}
|
|
} else {
|
|
filter = bson.M{}
|
|
}
|
|
count, err := coll.CountDocuments(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryResult{
|
|
IsSelect: true,
|
|
Columns: []string{"count"},
|
|
Rows: []map[string]any{{"count": count}},
|
|
RowCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
func mongoAggregate(ctx context.Context, coll *mongo.Collection, pipelineStr string) (*QueryResult, error) {
|
|
var pipeline bson.A
|
|
if err := bson.UnmarshalExtJSON([]byte(pipelineStr), true, &pipeline); err != nil {
|
|
return nil, fmt.Errorf("pipeline inválido: %w", err)
|
|
}
|
|
cursor, err := coll.Aggregate(ctx, pipeline)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cursor.Close(ctx)
|
|
var docs []bson.M
|
|
if err := cursor.All(ctx, &docs); err != nil {
|
|
return nil, err
|
|
}
|
|
return bsonDocsToResult(docs), nil
|
|
}
|
|
|
|
func mongoRunCommand(ctx context.Context, db *mongo.Database, cmdStr string) (*QueryResult, error) {
|
|
var cmd bson.D
|
|
if err := bson.UnmarshalExtJSON([]byte(cmdStr), true, &cmd); err != nil {
|
|
return nil, fmt.Errorf("comando inválido: %w", err)
|
|
}
|
|
var result bson.M
|
|
if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
return bsonDocsToResult([]bson.M{result}), nil
|
|
}
|
|
|
|
func bsonDocsToResult(docs []bson.M) *QueryResult {
|
|
if len(docs) == 0 {
|
|
return &QueryResult{IsSelect: true, Columns: []string{}, Rows: []map[string]any{}}
|
|
}
|
|
keyIdx := make(map[string]int)
|
|
for _, doc := range docs {
|
|
for k := range doc {
|
|
if _, seen := keyIdx[k]; !seen {
|
|
keyIdx[k] = len(keyIdx)
|
|
}
|
|
}
|
|
}
|
|
cols := make([]string, len(keyIdx))
|
|
for k, i := range keyIdx {
|
|
cols[i] = k
|
|
}
|
|
rows := make([]map[string]any, len(docs))
|
|
for i, doc := range docs {
|
|
row := make(map[string]any, len(doc))
|
|
for k, v := range doc {
|
|
switch v.(type) {
|
|
case bson.M, bson.A, bson.D:
|
|
b, _ := json.Marshal(v)
|
|
row[k] = string(b)
|
|
default:
|
|
row[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
rows[i] = row
|
|
}
|
|
return &QueryResult{IsSelect: true, Columns: cols, Rows: rows, RowCount: len(rows)}
|
|
}
|
|
|
|
func splitTopLevelArgs(s string) []string {
|
|
var args []string
|
|
depth := 0
|
|
inStr := false
|
|
start := 0
|
|
s = strings.TrimSpace(s)
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch ch {
|
|
case '"':
|
|
if i == 0 || s[i-1] != '\\' {
|
|
inStr = !inStr
|
|
}
|
|
case '{', '[', '(':
|
|
if !inStr {
|
|
depth++
|
|
}
|
|
case '}', ']', ')':
|
|
if !inStr {
|
|
depth--
|
|
}
|
|
case ',':
|
|
if !inStr && depth == 0 {
|
|
args = append(args, strings.TrimSpace(s[start:i]))
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
if last := strings.TrimSpace(s[start:]); last != "" {
|
|
args = append(args, last)
|
|
}
|
|
return args
|
|
}
|