fix(query-runner): "conx_db_id inválido" al correr un .sql, y el parser que se comía el script
Dos bugs encadenados. El segundo es mucho peor que el que se veía.
1. EL ERROR VISIBLE. RunBatchQuery leía conx_db_id del formulario y lo guardaba
bien, y después lo pisaba con cero. El struct del body solo tenía etiquetas
json, pero en multipart Fiber mapea por la etiqueta form: no encontraba
conx_db_id, dejaba el campo en cero — y BodyParser NO devuelve error cuando
no mapea nada, así que la rama se ejecutaba igual y sobreescribía el valor
bueno. De ahí el "conx_db_id inválido" con la conexión bien elegida en
pantalla. Reproducido con un test antes de tocar nada.
2. EL QUE NO SE VEÍA. El script se partía con split(';') y se descartaba todo
trozo que empezara con "--". En un script documentado, donde cada sentencia
va debajo de su encabezado en comentarios, eso se saltaba casi todo el
archivo en silencio. Con el .sql que lo destapó: de 10 sentencias reales
pasaban 3 — y una de esas tres era un fragmento corrupto, porque un punto y
coma dentro de un comentario había partido una sentencia al medio. Se
perdían los dos INSERT de alta y el ALTER TABLE, que era el único cambio
estructural del script.
Es decir: arreglar solo el bug 1 habría sido peor que dejarlo. El usuario
habría leído "3 consultas ejecutadas" y se habría ido tranquilo con la
migración a medio aplicar.
DividirSentenciasSQL parte respetando dónde el punto y coma no separa nada:
comentarios de línea (-- y #), de bloque, y cadenas con ' " ` incluyendo
escapes con barra y comillas duplicadas. Y en vez de descartar lo que empieza
con "--", pregunta si al trozo le queda SQL después de sacarle los comentarios
— un "--" adentro de una cadena ya no oculta la sentencia.
Mismo criterio en los dos lados: el front arma las sentencias y el backend
divide los archivos subidos, y las dos implementaciones dan las mismas 10 sobre
el archivo real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b7c52f174a
commit
93f5fdec7a
@@ -0,0 +1,152 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// DividirSentenciasSQL parte un script en sentencias por punto y coma,
|
||||||
|
// respetando dónde ese punto y coma NO es un separador.
|
||||||
|
//
|
||||||
|
// Partir con strings.Split(texto, ";") parece que funciona hasta que el script
|
||||||
|
// trae un punto y coma dentro de un comentario o de una cadena — ahí corta una
|
||||||
|
// sentencia al medio y produce SQL corrupto que igual se manda a ejecutar.
|
||||||
|
//
|
||||||
|
// Las sentencias se devuelven con sus comentarios: MySQL los acepta, y sirven
|
||||||
|
// para que el usuario reconozca en los resultados qué se ejecutó.
|
||||||
|
func DividirSentenciasSQL(texto string) []string {
|
||||||
|
var sentencias []string
|
||||||
|
var actual strings.Builder
|
||||||
|
r := []rune(texto)
|
||||||
|
|
||||||
|
agregar := func() {
|
||||||
|
if s := strings.TrimSpace(actual.String()); TieneSQLReal(s) {
|
||||||
|
sentencias = append(sentencias, s)
|
||||||
|
}
|
||||||
|
actual.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(r); i++ {
|
||||||
|
c := r[i]
|
||||||
|
var sig rune
|
||||||
|
if i+1 < len(r) {
|
||||||
|
sig = r[i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// Comentario de línea: -- … o # …
|
||||||
|
case (c == '-' && sig == '-') || c == '#':
|
||||||
|
for i < len(r) && r[i] != '\n' {
|
||||||
|
actual.WriteRune(r[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < len(r) {
|
||||||
|
actual.WriteRune('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comentario de bloque: /* … */
|
||||||
|
case c == '/' && sig == '*':
|
||||||
|
actual.WriteString("/*")
|
||||||
|
i += 2
|
||||||
|
for i < len(r) && !(r[i] == '*' && i+1 < len(r) && r[i+1] == '/') {
|
||||||
|
actual.WriteRune(r[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
actual.WriteString("*/")
|
||||||
|
i++
|
||||||
|
|
||||||
|
// Cadenas y nombres citados: ' " `
|
||||||
|
case c == '\'' || c == '"' || c == '`':
|
||||||
|
comilla := c
|
||||||
|
actual.WriteRune(c)
|
||||||
|
i++
|
||||||
|
for i < len(r) {
|
||||||
|
if r[i] == '\\' && i+1 < len(r) {
|
||||||
|
// Escape con barra: se copia el par entero.
|
||||||
|
actual.WriteRune(r[i])
|
||||||
|
actual.WriteRune(r[i+1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r[i] == comilla {
|
||||||
|
actual.WriteRune(r[i])
|
||||||
|
// Comilla duplicada ('') es una comilla literal, no el cierre.
|
||||||
|
if i+1 < len(r) && r[i+1] == comilla {
|
||||||
|
actual.WriteRune(r[i+1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
actual.WriteRune(r[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
case c == ';':
|
||||||
|
agregar()
|
||||||
|
|
||||||
|
default:
|
||||||
|
actual.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agregar()
|
||||||
|
return sentencias
|
||||||
|
}
|
||||||
|
|
||||||
|
// TieneSQLReal dice si al trozo le queda algo ejecutable una vez sacados los
|
||||||
|
// comentarios. Es lo que distingue "esta sentencia viene precedida por un
|
||||||
|
// encabezado en comentarios" de "esto es solo un comentario suelto".
|
||||||
|
//
|
||||||
|
// El filtro viejo descartaba todo trozo que EMPEZARA con "--", y como en un
|
||||||
|
// script bien documentado casi todas las sentencias vienen debajo de su
|
||||||
|
// comentario, se saltaba la mayoría del archivo sin decir nada.
|
||||||
|
func TieneSQLReal(s string) bool {
|
||||||
|
limpio := quitarComentarios(s)
|
||||||
|
return strings.TrimSpace(limpio) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func quitarComentarios(s string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
r := []rune(s)
|
||||||
|
for i := 0; i < len(r); i++ {
|
||||||
|
c := r[i]
|
||||||
|
var sig rune
|
||||||
|
if i+1 < len(r) {
|
||||||
|
sig = r[i+1]
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case (c == '-' && sig == '-') || c == '#':
|
||||||
|
for i < len(r) && r[i] != '\n' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.WriteRune('\n')
|
||||||
|
case c == '/' && sig == '*':
|
||||||
|
i += 2
|
||||||
|
for i < len(r) && !(r[i] == '*' && i+1 < len(r) && r[i+1] == '/') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
out.WriteRune(' ')
|
||||||
|
case c == '\'' || c == '"' || c == '`':
|
||||||
|
// Una cadena cuenta como contenido real; se copia entera para no
|
||||||
|
// confundir un "--" que viva adentro con un comentario.
|
||||||
|
comilla := c
|
||||||
|
out.WriteRune(c)
|
||||||
|
i++
|
||||||
|
for i < len(r) {
|
||||||
|
if r[i] == '\\' && i+1 < len(r) {
|
||||||
|
out.WriteRune(r[i])
|
||||||
|
out.WriteRune(r[i+1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r[i] == comilla {
|
||||||
|
out.WriteRune(r[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.WriteRune(r[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El caso real que lo destapó: un script de migración bien documentado, donde
|
||||||
|
// cada sentencia viene debajo de su encabezado en comentarios y uno de esos
|
||||||
|
// comentarios contiene un punto y coma.
|
||||||
|
const scriptReal = `-- ============================================================
|
||||||
|
-- PARAMETROS WEBMASTER
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------
|
||||||
|
-- 1. EL SUBMODULO
|
||||||
|
-- ----------------------------------------------------------
|
||||||
|
INSERT INTO submodulo (nombre, url) VALUES ('parametroswebmaster', 'p.php');
|
||||||
|
|
||||||
|
-- 2. ACCESO
|
||||||
|
INSERT INTO usuario_has_submodulo (submodulo_id, usuario_id)
|
||||||
|
SELECT S.id, 1 FROM submodulo S WHERE S.nombre = 'parametroswebmaster';
|
||||||
|
|
||||||
|
-- 4. COLUMNAS DE IA
|
||||||
|
ALTER TABLE bot_tipos_config
|
||||||
|
ADD COLUMN IF NOT EXISTS ia_activo TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- 5. VERIFICACION
|
||||||
|
-- (cargar_menu() excluye el modulo 6 en duro; si sale 6, no aparecera)
|
||||||
|
SELECT S.id, S.nombre FROM submodulo S WHERE S.nombre = 'parametroswebmaster';
|
||||||
|
|
||||||
|
SHOW COLUMNS FROM bot_tipos_config;
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestDividirSentenciasScriptReal(t *testing.T) {
|
||||||
|
sentencias := DividirSentenciasSQL(scriptReal)
|
||||||
|
|
||||||
|
if len(sentencias) != 5 {
|
||||||
|
t.Fatalf("salieron %d sentencias, esperaba 5:\n%s", len(sentencias), strings.Join(sentencias, "\n---\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cada una tiene que terminar conteniendo su SQL, aunque venga precedida
|
||||||
|
// de comentarios. El parser viejo descartaba todas estas.
|
||||||
|
esperados := []string{"INSERT INTO submodulo", "INSERT INTO usuario_has_submodulo",
|
||||||
|
"ALTER TABLE bot_tipos_config", "SELECT S.id", "SHOW COLUMNS"}
|
||||||
|
for i, esperado := range esperados {
|
||||||
|
if !strings.Contains(sentencias[i], esperado) {
|
||||||
|
t.Errorf("sentencia %d no contiene %q:\n%s", i, esperado, sentencias[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El punto y coma dentro del comentario no puede haber partido nada: si
|
||||||
|
// partiera, quedaría un fragmento que empieza con el resto del comentario.
|
||||||
|
for _, s := range sentencias {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(s), "si sale 6") {
|
||||||
|
t.Error("se partió dentro de un comentario: quedó un fragmento corrupto")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDividirSentenciasCasosBorde(t *testing.T) {
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
entrada string
|
||||||
|
cuantas int
|
||||||
|
}{
|
||||||
|
{"punto y coma dentro de una cadena", `INSERT INTO t VALUES ('hola; chau'); SELECT 1;`, 2},
|
||||||
|
{"comilla escapada con barra", `INSERT INTO t VALUES ('a\'; b'); SELECT 1;`, 2},
|
||||||
|
{"comilla duplicada", `INSERT INTO t VALUES ('a''; b'); SELECT 1;`, 2},
|
||||||
|
{"comentario de bloque con punto y coma", `/* uno; dos */ SELECT 1;`, 1},
|
||||||
|
{"solo comentarios no es una sentencia", "-- nada que ver\n-- tampoco esto\n", 0},
|
||||||
|
{"sin punto y coma final", "SELECT 1", 1},
|
||||||
|
{"vacío", "", 0},
|
||||||
|
{"puntos y coma de más", "SELECT 1;;; SELECT 2;", 2},
|
||||||
|
{"backticks con punto y coma", "SELECT `col;raro` FROM t; SELECT 2;", 2},
|
||||||
|
{"almohadilla como comentario", "# comentario; con punto y coma\nSELECT 1;", 1},
|
||||||
|
}
|
||||||
|
for _, c := range casos {
|
||||||
|
got := DividirSentenciasSQL(c.entrada)
|
||||||
|
if len(got) != c.cuantas {
|
||||||
|
t.Errorf("%s: salieron %d, esperaba %d → %q", c.nombre, len(got), c.cuantas, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La regresión concreta: el filtro viejo tiraba toda sentencia que empezara
|
||||||
|
// con un comentario, que en un script documentado son casi todas.
|
||||||
|
func TestSentenciaConEncabezadoNoSeDescarta(t *testing.T) {
|
||||||
|
s := "-- Crea la tabla\n-- Es re-ejecutable\nCREATE TABLE t (id INT)"
|
||||||
|
if !TieneSQLReal(s) {
|
||||||
|
t.Error("una sentencia precedida por comentarios se está descartando")
|
||||||
|
}
|
||||||
|
if TieneSQLReal("-- solo un comentario\n") {
|
||||||
|
t.Error("un comentario suelto no es una sentencia")
|
||||||
|
}
|
||||||
|
// Un "--" adentro de una cadena no convierte la sentencia en comentario.
|
||||||
|
if !TieneSQLReal(`SELECT '-- esto no es comentario'`) {
|
||||||
|
t.Error("un guión doble dentro de una cadena no debe ocultar la sentencia")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1591,12 +1591,67 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Batch mode ──────────────────────────────────────────────
|
// ── Batch mode ──────────────────────────────────────────────
|
||||||
|
// Parte el script por punto y coma, respetando dónde ese punto y coma
|
||||||
|
// NO separa nada: dentro de un comentario o de una cadena.
|
||||||
|
//
|
||||||
|
// La versión vieja hacía split(';') y además descartaba todo trozo que
|
||||||
|
// empezara con '--'. En un script documentado, donde cada sentencia va
|
||||||
|
// debajo de su encabezado, eso se saltaba casi todo el archivo sin
|
||||||
|
// decir una palabra — y un punto y coma dentro de un comentario partía
|
||||||
|
// una sentencia al medio y mandaba el pedazo corrupto a ejecutar.
|
||||||
extractSQLStatements(text) {
|
extractSQLStatements(text) {
|
||||||
// Split by semicolons, trim whitespace, remove empty
|
const out = [];
|
||||||
return text
|
let actual = '';
|
||||||
.split(';')
|
const agregar = () => {
|
||||||
.map(s => s.trim())
|
const s = actual.trim();
|
||||||
.filter(s => s.length > 0 && !s.toUpperCase().startsWith('--') && !s.startsWith('#'));
|
if (this.tieneSQLReal(s)) out.push(s);
|
||||||
|
actual = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text[i], sig = text[i + 1];
|
||||||
|
|
||||||
|
if ((c === '-' && sig === '-') || c === '#') {
|
||||||
|
while (i < text.length && text[i] !== '\n') actual += text[i++];
|
||||||
|
if (i < text.length) actual += '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '/' && sig === '*') {
|
||||||
|
actual += '/*'; i += 2;
|
||||||
|
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) actual += text[i++];
|
||||||
|
actual += '*/'; i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "'" || c === '"' || c === '`') {
|
||||||
|
actual += c; i++;
|
||||||
|
while (i < text.length) {
|
||||||
|
if (text[i] === '\\') { actual += text[i] + (text[i + 1] || ''); i += 2; continue; }
|
||||||
|
if (text[i] === c) {
|
||||||
|
actual += text[i];
|
||||||
|
// Comilla duplicada: es literal, no cierra la cadena.
|
||||||
|
if (text[i + 1] === c) { actual += text[i + 1]; i += 2; continue; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
actual += text[i++];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === ';') { agregar(); continue; }
|
||||||
|
actual += c;
|
||||||
|
}
|
||||||
|
agregar();
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ¿Al trozo le queda algo ejecutable una vez sacados los comentarios?
|
||||||
|
// Es lo que distingue "sentencia con encabezado" de "comentario suelto".
|
||||||
|
tieneSQLReal(s) {
|
||||||
|
const sinBloques = s.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
||||||
|
const sinLinea = sinBloques
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.replace(/^\s*(--|#).*$/, ''))
|
||||||
|
.join('\n');
|
||||||
|
return sinLinea.trim().length > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadSQLFile(event) {
|
uploadSQLFile(event) {
|
||||||
@@ -1628,7 +1683,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.activeTab = 'results';
|
this.activeTab = 'results';
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('conx_db_id', this.selectedConxId);
|
formData.append('conx_db_id', parseInt(this.selectedConxId) || 0);
|
||||||
formData.append('database', this.selectedDb || '');
|
formData.append('database', this.selectedDb || '');
|
||||||
sqls.forEach((sql, i) => formData.append('sqls[]', sql));
|
sqls.forEach((sql, i) => formData.append('sqls[]', sql));
|
||||||
const res = await axios.post('/app/query-runner/run-batch', formData, {
|
const res = await axios.post('/app/query-runner/run-batch', formData, {
|
||||||
|
|||||||
@@ -196,26 +196,26 @@ func RunBatchQuery(c *fiber.Ctx) error {
|
|||||||
defer f.Close()
|
defer f.Close()
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
buf.ReadFrom(f)
|
buf.ReadFrom(f)
|
||||||
content := buf.String()
|
statements = append(statements, services.DividirSentenciasSQL(buf.String())...)
|
||||||
// Dividir por ;
|
|
||||||
for _, stmt := range strings.Split(content, ";") {
|
|
||||||
stmt = strings.TrimSpace(stmt)
|
|
||||||
if stmt != "" {
|
|
||||||
statements = append(statements, stmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Si no hay archivo, leer del body JSON
|
// 2. Si no hay archivo, leer del body JSON
|
||||||
if len(statements) == 0 {
|
if len(statements) == 0 {
|
||||||
var body struct {
|
var body struct {
|
||||||
ConxDbID uint `json:"conx_db_id"`
|
ConxDbID uint `json:"conx_db_id" form:"conx_db_id"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database" form:"database"`
|
||||||
SQLs []string `json:"sqls"`
|
SQLs []string `json:"sqls" form:"sqls"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&body); err == nil {
|
if err := c.BodyParser(&body); err == nil {
|
||||||
conxDbIDStr = strconv.Itoa(int(body.ConxDbID))
|
// Solo se pisa si el body trae algo. BodyParser NO falla cuando no
|
||||||
|
// mapea nada —en multipart, sin la etiqueta form: no encontraba
|
||||||
|
// conx_db_id— y este cero borraba la conexión que el formulario sí
|
||||||
|
// había mandado: de ahí el "conx_db_id inválido" con la conexión
|
||||||
|
// bien elegida en pantalla.
|
||||||
|
if body.ConxDbID != 0 {
|
||||||
|
conxDbIDStr = strconv.Itoa(int(body.ConxDbID))
|
||||||
|
}
|
||||||
if body.Database != "" {
|
if body.Database != "" {
|
||||||
database = body.Database
|
database = body.Database
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user