diff --git a/pkg/services/sql_split.go b/pkg/services/sql_split.go new file mode 100644 index 0000000..988ac2c --- /dev/null +++ b/pkg/services/sql_split.go @@ -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() +} diff --git a/pkg/services/sql_split_test.go b/pkg/services/sql_split_test.go new file mode 100644 index 0000000..85aa46c --- /dev/null +++ b/pkg/services/sql_split_test.go @@ -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") + } +} diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html index 23ba251..4701a42 100644 --- a/resources/views/query_runner.html +++ b/resources/views/query_runner.html @@ -1591,12 +1591,67 @@ document.addEventListener('alpine:init', () => { }, // ── 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) { - // Split by semicolons, trim whitespace, remove empty - return text - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0 && !s.toUpperCase().startsWith('--') && !s.startsWith('#')); + const out = []; + let actual = ''; + const agregar = () => { + const s = actual.trim(); + 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) { @@ -1628,7 +1683,7 @@ document.addEventListener('alpine:init', () => { this.activeTab = 'results'; try { 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 || ''); sqls.forEach((sql, i) => formData.append('sqls[]', sql)); const res = await axios.post('/app/query-runner/run-batch', formData, { diff --git a/rest/controllers/query_runner_controller.go b/rest/controllers/query_runner_controller.go index dec7ff2..aff603f 100644 --- a/rest/controllers/query_runner_controller.go +++ b/rest/controllers/query_runner_controller.go @@ -196,26 +196,26 @@ func RunBatchQuery(c *fiber.Ctx) error { defer f.Close() buf := new(bytes.Buffer) buf.ReadFrom(f) - content := buf.String() - // Dividir por ; - for _, stmt := range strings.Split(content, ";") { - stmt = strings.TrimSpace(stmt) - if stmt != "" { - statements = append(statements, stmt) - } - } + statements = append(statements, services.DividirSentenciasSQL(buf.String())...) } } // 2. Si no hay archivo, leer del body JSON if len(statements) == 0 { var body struct { - ConxDbID uint `json:"conx_db_id"` - Database string `json:"database"` - SQLs []string `json:"sqls"` + ConxDbID uint `json:"conx_db_id" form:"conx_db_id"` + Database string `json:"database" form:"database"` + SQLs []string `json:"sqls" form:"sqls"` } 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 != "" { database = body.Database }