1919
2020namespace FacturaScripts \Plugins \Backup \Lib ;
2121
22- use DatabaseBackupManager \ MySQLBackup ;
22+ use FacturaScripts \ Core \ Base \ DataBase ;
2323use FacturaScripts \Core \Tools ;
2424use PDO ;
2525
@@ -30,50 +30,48 @@ class BackupSQL
3030{
3131 public static function generate (string $ channel = '' ): bool
3232 {
33- if (Tools::config ('db_type ' ) != 'mysql ' ) {
34- Tools::log ($ channel )->error ('mysql-support-only ' );
33+ $ folder = Tools::folder ('MyFiles ' , 'Backups ' );
34+ if (false === Tools::folderCheckOrCreate ($ folder )) {
35+ Tools::log ($ channel )->error ('folder-create-error ' );
3536 return false ;
3637 }
3738
38- // si el puerto no es el puerto por defecto, mostramos un aviso
39- if (Tools::config ('db_port ' ) != 3306 ) {
40- Tools::log ($ channel )->warning ('backup-port-warning ' , [
41- '%port% ' => Tools::config ('db_port ' )
42- ]);
43- }
44-
45- if (false === extension_loaded ('pdo_mysql ' )) {
46- Tools::log ($ channel )->error ('pdo-mysql-support-only ' );
39+ $ db = new DataBase ();
40+ $ type = $ db ->type ();
41+ if (false === in_array ($ type , ['mysql ' , 'postgresql ' ], true )) {
42+ Tools::log ($ channel )->error ('mysql-support-only ' );
4743 return false ;
4844 }
4945
50- if (false === extension_loaded ('zip ' )) {
51- Tools::log ($ channel )->error ('php-extension-not-found ' , ['%extension% ' => 'zip ' ]);
46+ $ file_path = Tools::folder ('MyFiles ' , 'Backups ' , date ('Y-m-d_H-i-s ' ) . '.sql ' );
47+ $ handle = fopen ($ file_path , 'w ' );
48+ if (false === $ handle ) {
49+ Tools::log ($ channel )->error ('record-save-error ' );
5250 return false ;
5351 }
5452
55- $ folder = Tools::folder ('MyFiles ' , 'Backups ' );
56- if (false === Tools::folderCheckOrCreate ($ folder )) {
57- Tools::log ($ channel )->error ('folder-create-error ' );
58- return false ;
59- }
53+ // cabecera del volcado
54+ fwrite ($ handle , static ::dumpHeader ($ db , $ type ));
6055
61- $ file_name = date ('Y-m-d_H-i-s ' ) . '.sql ' ;
56+ // las claves foráneas se emiten al final (necesario en postgresql para evitar
57+ // problemas de orden entre tablas; en mysql van inline en el SHOW CREATE TABLE)
58+ $ deferredForeignKeys = [];
6259
63- // Definimos la configuración de la base de datos y el directorio de backup
64- $ db = new PDO ( ' mysql:host= ' . Tools:: config ( ' db_host ' ) . ' ;port= ' . Tools:: config ( ' db_port ' ) . ' ;dbname= ' . Tools:: config ( ' db_name ' ), Tools:: config ( ' db_user ' ), Tools:: config ( ' db_pass ' ));
65- $ backupDir = Tools:: folder ( ' MyFiles ' , ' Backups ' );
60+ foreach ( $ db -> getTables () as $ table ) {
61+ // estructura de la tabla
62+ fwrite ( $ handle , static :: tableStructure ( $ db , $ type , $ table , $ deferredForeignKeys ) );
6663
67- $ backup = new MySQLBackup ($ db , $ backupDir );
64+ // datos de la tabla (en streaming, paginado para no agotar memoria)
65+ static ::tableData ($ db , $ table , $ handle );
66+ }
6867
69- // exportamos la base de datos a un archivo y le cambiamos el nombre para que tenga el formato correcto
70- $ file = $ backup ->backup ();
71- if (false === rename ($ file , Tools::folder ('MyFiles ' , 'Backups ' , $ file_name ))) {
72- Tools::log ($ channel )->error ('record-save-error ' );
73- return false ;
68+ // claves foráneas diferidas
69+ foreach ($ deferredForeignKeys as $ fkSql ) {
70+ fwrite ($ handle , $ fkSql . "\n" );
7471 }
7572
76- $ file_path = Tools::folder ('MyFiles ' , 'Backups ' , $ file_name );
73+ fclose ($ handle );
74+
7775 if (false === file_exists ($ file_path )) {
7876 Tools::log ($ channel )->error ('record-save-error ' );
7977 return false ;
@@ -103,18 +101,32 @@ public static function restore(PDO $db, string $sqlFile)
103101 return Tools::trans ('no-file-received ' );
104102 }
105103
106- // intentamos desactivar el modo estricto de InnoDB (red de seguridad ante errores de
107- // formato de fila). Algunos servidores no lo permiten por falta de privilegios; en ese
108- // caso lo ignoramos y continuamos.
109- try {
110- $ db ->exec ('SET SESSION innodb_strict_mode = OFF ' );
111- } catch (\Throwable $ e ) {
112- // sin privilegios para cambiar la variable; continuamos igualmente
104+ // arranque de sesión dependiente del motor. Todas las sentencias van protegidas:
105+ // si el servidor no permite cambiar la variable (falta de privilegios) lo ignoramos.
106+ $ driver = $ db ->getAttribute (PDO ::ATTR_DRIVER_NAME );
107+ if ($ driver === 'mysql ' ) {
108+ // desactivamos el modo estricto de InnoDB (red de seguridad ante errores de
109+ // formato de fila) y la comprobación de claves foráneas durante la importación
110+ try {
111+ $ db ->exec ('SET SESSION innodb_strict_mode = OFF ' );
112+ } catch (\Throwable $ e ) {
113+ // sin privilegios para cambiar la variable; continuamos igualmente
114+ }
115+ try {
116+ $ db ->exec ('SET FOREIGN_KEY_CHECKS = 0 ' );
117+ } catch (\Throwable $ e ) {
118+ // continuamos igualmente
119+ }
120+ } elseif ($ driver === 'pgsql ' ) {
121+ // desactivamos los disparadores de claves foráneas (las FK se crean al final
122+ // del volcado, así que normalmente no es necesario, pero es una red de seguridad)
123+ try {
124+ $ db ->exec ("SET session_replication_role = 'replica' " );
125+ } catch (\Throwable $ e ) {
126+ // sin privilegios; continuamos igualmente
127+ }
113128 }
114129
115- // desactivamos la comprobación de claves foráneas durante la importación
116- $ db ->exec ('SET FOREIGN_KEY_CHECKS = 0 ' );
117-
118130 $ statement = '' ;
119131 $ length = strlen ($ content );
120132 $ inString = false ;
@@ -214,6 +226,25 @@ public static function restore(PDO $db, string $sqlFile)
214226 return true ;
215227 }
216228
229+ /**
230+ * Devuelve la cabecera del volcado SQL, dependiente del motor.
231+ */
232+ private static function dumpHeader (DataBase $ db , string $ type ): string
233+ {
234+ $ header = '-- FacturaScripts SQL backup ' . "\n"
235+ . '-- Database: ' . Tools::config ('db_name ' ) . "\n"
236+ . '-- Engine: ' . $ type . ' ( ' . $ db ->version () . ') ' . "\n"
237+ . '-- Generated: ' . Tools::dateTime () . "\n\n" ;
238+
239+ if ($ type === 'mysql ' ) {
240+ // SET NAMES y desactivar comprobación de FK (sentencias sin privilegios especiales)
241+ $ header .= 'SET NAMES ' . Tools::config ('mysql_charset ' , 'utf8 ' ) . "; \n"
242+ . "SET FOREIGN_KEY_CHECKS = 0; \n\n" ;
243+ }
244+
245+ return $ header ;
246+ }
247+
217248 /**
218249 * Ejecuta una sentencia SQL. Devuelve null si fue correcta o ignorable, o el mensaje de error.
219250 */
@@ -232,4 +263,214 @@ private static function execStatement(PDO $db, string $statement): ?string
232263
233264 return null ;
234265 }
266+
267+ /**
268+ * Formatea un valor para SQL según el tipo de su columna, con escapado seguro.
269+ */
270+ private static function formatValue (DataBase $ db , array $ column , $ value ): string
271+ {
272+ if ($ value === null ) {
273+ return 'NULL ' ;
274+ }
275+
276+ $ type = strtolower ($ column ['type ' ] ?? '' );
277+
278+ // tipos numéricos: valor crudo (sin comillas)
279+ if (preg_match ('/^(int|integer|bigint|smallint|mediumint|tinyint|decimal|numeric|float|double|real|serial|bigserial)/ ' , $ type ) && is_numeric ($ value )) {
280+ return (string )$ value ;
281+ }
282+
283+ // tipos binarios: literal hexadecimal
284+ if (preg_match ('/(blob|binary|bytea)/ ' , $ type )) {
285+ $ hex = bin2hex ($ value );
286+ if ($ hex === '' ) {
287+ return "'' " ;
288+ }
289+ return $ db ->type () === 'postgresql ' ? "' \\x " . $ hex . "' " : '0x ' . $ hex ;
290+ }
291+
292+ // resto: cadena escapada según el motor
293+ return "' " . $ db ->escapeString ($ value ) . "' " ;
294+ }
295+
296+ /**
297+ * Estructura de tabla en mysql/mariadb mediante SHOW CREATE TABLE (exacto).
298+ */
299+ private static function mysqlTableStructure (DataBase $ db , string $ table ): string
300+ {
301+ $ rows = $ db ->select ('SHOW CREATE TABLE ' . $ db ->escapeColumn ($ table ));
302+ if (empty ($ rows ) || false === isset ($ rows [0 ]['Create Table ' ])) {
303+ // podría ser una vista u otro objeto que no es tabla: lo ignoramos
304+ return '' ;
305+ }
306+
307+ $ create = $ rows [0 ]['Create Table ' ];
308+
309+ // forzamos ROW_FORMAT=DYNAMIC si no lo trae, para evitar el error 1118
310+ // "Row size too large" al restaurar (habitual con utf8mb4 y filas anchas)
311+ if (stripos ($ create , 'ENGINE=InnoDB ' ) !== false && stripos ($ create , 'ROW_FORMAT ' ) === false ) {
312+ $ create .= ' ROW_FORMAT=DYNAMIC ' ;
313+ }
314+
315+ return '-- ' . "\n" . '-- Estructura de la tabla ` ' . $ table . '` ' . "\n" . '-- ' . "\n"
316+ . 'DROP TABLE IF EXISTS ' . $ db ->escapeColumn ($ table ) . "; \n"
317+ . $ create . "; \n\n" ;
318+ }
319+
320+ /**
321+ * Define una columna para el CREATE TABLE de postgresql.
322+ */
323+ private static function postgresqlColumnDef (array $ col ): string
324+ {
325+ $ type = $ col ['type ' ];
326+
327+ // columnas serie: si el default es nextval(...), usamos SERIAL/BIGSERIAL
328+ $ isSerial = isset ($ col ['default ' ]) && is_string ($ col ['default ' ]) && stripos ($ col ['default ' ], 'nextval( ' ) !== false ;
329+ if ($ isSerial ) {
330+ $ def = (stripos ($ type , 'big ' ) !== false ) ? 'BIGSERIAL ' : 'SERIAL ' ;
331+ } elseif (!empty ($ col ['character_maximum_length ' ]) && stripos ($ type , 'char ' ) !== false ) {
332+ $ def = $ type . '( ' . $ col ['character_maximum_length ' ] . ') ' ;
333+ } else {
334+ $ def = $ type ;
335+ }
336+
337+ if (($ col ['is_nullable ' ] ?? 'YES ' ) === 'NO ' ) {
338+ $ def .= ' NOT NULL ' ;
339+ }
340+
341+ if (false === $ isSerial && isset ($ col ['default ' ]) && $ col ['default ' ] !== null && $ col ['default ' ] !== '' ) {
342+ $ def .= ' DEFAULT ' . $ col ['default ' ];
343+ }
344+
345+ return $ def ;
346+ }
347+
348+ /**
349+ * Estructura de tabla en postgresql reconstruida desde la introspección del core.
350+ * Las claves foráneas se acumulan en $deferredForeignKeys para emitirlas al final.
351+ */
352+ private static function postgresqlTableStructure (DataBase $ db , string $ table , array &$ deferredForeignKeys ): string
353+ {
354+ $ columns = $ db ->getColumns ($ table );
355+ if (empty ($ columns )) {
356+ return '' ;
357+ }
358+
359+ $ defs = [];
360+ foreach ($ columns as $ col ) {
361+ $ defs [] = ' ' . $ db ->escapeColumn ($ col ['name ' ]) . ' ' . static ::postgresqlColumnDef ($ col );
362+ }
363+
364+ $ constraints = $ db ->getConstraints ($ table , true );
365+
366+ // clave primaria
367+ $ pkColumns = [];
368+ foreach ($ constraints as $ con ) {
369+ if (strtoupper ($ con ['type ' ] ?? '' ) === 'PRIMARY KEY ' && !empty ($ con ['column_name ' ])) {
370+ $ pkColumns [$ con ['column_name ' ]] = $ db ->escapeColumn ($ con ['column_name ' ]);
371+ }
372+ }
373+ if ($ pkColumns ) {
374+ $ defs [] = ' PRIMARY KEY ( ' . implode (', ' , $ pkColumns ) . ') ' ;
375+ }
376+
377+ $ sql = '-- ' . "\n" . '-- Estructura de la tabla " ' . $ table . '" ' . "\n" . '-- ' . "\n"
378+ . 'DROP TABLE IF EXISTS ' . $ db ->escapeColumn ($ table ) . " CASCADE; \n"
379+ . 'CREATE TABLE ' . $ db ->escapeColumn ($ table ) . " ( \n"
380+ . implode (", \n" , $ defs )
381+ . "\n); \n" ;
382+
383+ // índices que no sean la clave primaria
384+ foreach ($ db ->getAllIndexes ($ table ) as $ idx ) {
385+ if (empty ($ idx ['name ' ]) || empty ($ idx ['column ' ]) || isset ($ pkColumns [$ idx ['column ' ]])) {
386+ continue ;
387+ }
388+ $ sql .= 'CREATE INDEX ' . $ idx ['name ' ] . ' ON ' . $ db ->escapeColumn ($ table )
389+ . ' ( ' . $ db ->escapeColumn ($ idx ['column ' ]) . "); \n" ;
390+ }
391+
392+ // recogemos las claves foráneas para emitirlas al final
393+ foreach ($ constraints as $ con ) {
394+ if (strtoupper ($ con ['type ' ] ?? '' ) === 'FOREIGN KEY ' && !empty ($ con ['foreign_table_name ' ])) {
395+ $ deferredForeignKeys [] = 'ALTER TABLE ' . $ db ->escapeColumn ($ table )
396+ . ' ADD CONSTRAINT ' . $ con ['name ' ]
397+ . ' FOREIGN KEY ( ' . $ db ->escapeColumn ($ con ['column_name ' ]) . ') '
398+ . ' REFERENCES ' . $ db ->escapeColumn ($ con ['foreign_table_name ' ])
399+ . ' ( ' . $ db ->escapeColumn ($ con ['foreign_column_name ' ]) . '); ' ;
400+ }
401+ }
402+
403+ return $ sql . "\n" ;
404+ }
405+
406+ /**
407+ * Vuelca los datos de una tabla al archivo en streaming, paginando para no agotar memoria.
408+ */
409+ private static function tableData (DataBase $ db , string $ table , $ handle ): void
410+ {
411+ $ columns = $ db ->getColumns ($ table );
412+ if (empty ($ columns )) {
413+ return ;
414+ }
415+
416+ $ colNames = array_keys ($ columns );
417+ $ escapedCols = [];
418+ foreach ($ colNames as $ name ) {
419+ $ escapedCols [] = $ db ->escapeColumn ($ name );
420+ }
421+ $ intoPrefix = 'INSERT INTO ' . $ db ->escapeColumn ($ table )
422+ . ' ( ' . implode (', ' , $ escapedCols ) . ') VALUES ' ;
423+
424+ $ pageSize = 1000 ;
425+ $ offset = 0 ;
426+ $ maxBuffer = 1000000 ;
427+
428+ while (true ) {
429+ $ rows = $ db ->selectLimit ('SELECT * FROM ' . $ db ->escapeColumn ($ table ), $ pageSize , $ offset );
430+ if (empty ($ rows )) {
431+ break ;
432+ }
433+
434+ $ buffer = '' ;
435+ $ bufferRows = 0 ;
436+ foreach ($ rows as $ row ) {
437+ $ values = [];
438+ foreach ($ colNames as $ name ) {
439+ $ values [] = static ::formatValue ($ db , $ columns [$ name ], $ row [$ name ] ?? null );
440+ }
441+ $ tuple = '( ' . implode (', ' , $ values ) . ') ' ;
442+
443+ $ buffer .= ($ bufferRows === 0 ) ? ($ intoPrefix . $ tuple ) : (', ' . $ tuple );
444+ $ bufferRows ++;
445+
446+ // cerramos el INSERT si el buffer crece demasiado (límite max_allowed_packet)
447+ if (strlen ($ buffer ) >= $ maxBuffer ) {
448+ fwrite ($ handle , $ buffer . "; \n" );
449+ $ buffer = '' ;
450+ $ bufferRows = 0 ;
451+ }
452+ }
453+
454+ if ($ bufferRows > 0 ) {
455+ fwrite ($ handle , $ buffer . "; \n" );
456+ }
457+
458+ $ offset += $ pageSize ;
459+ }
460+
461+ fwrite ($ handle , "\n" );
462+ }
463+
464+ /**
465+ * Devuelve el DDL de la estructura de una tabla. En mysql/mariadb usa SHOW CREATE TABLE;
466+ * en postgresql lo reconstruye desde la introspección del core.
467+ */
468+ private static function tableStructure (DataBase $ db , string $ type , string $ table , array &$ deferredForeignKeys ): string
469+ {
470+ if ($ type === 'mysql ' ) {
471+ return static ::mysqlTableStructure ($ db , $ table );
472+ }
473+
474+ return static ::postgresqlTableStructure ($ db , $ table , $ deferredForeignKeys );
475+ }
235476}
0 commit comments